From 11d8264be99ff7a1e7b353f44db02c39888b36ae Mon Sep 17 00:00:00 2001 From: Tomas Karabela Date: Sun, 18 Oct 2020 22:57:39 +0200 Subject: [PATCH] Drop Python 2 support; move to Python 3.7 - added type hints - added explicit keyword arguments to `SSAEvent`, `SSAStyle` - bumped year in license statement --- .travis.yml | 6 +- LICENSE.txt | 2 +- docs/api-reference.rst | 2 - docs/conf.py | 2 +- docs/index.rst | 6 +- docs/tutorial.rst | 2 +- pysubs2/cli.py | 21 +++--- pysubs2/common.py | 16 ++--- pysubs2/exceptions.py | 5 ++ pysubs2/formatbase.py | 12 ++-- pysubs2/formats.py | 22 ++++--- pysubs2/jsonformat.py | 10 +-- pysubs2/microdvd.py | 5 +- pysubs2/mpl2.py | 3 - pysubs2/ssaevent.py | 89 ++++++++++++++------------ pysubs2/ssafile.py | 67 +++++++++---------- pysubs2/ssastyle.py | 110 ++++++++++++++++++-------------- pysubs2/subrip.py | 2 - pysubs2/substation.py | 17 ++--- pysubs2/time.py | 36 +++++++---- pysubs2/tmp.py | 3 +- pysubs2/{webtt.py => webvtt.py} | 2 +- setup.py | 7 +- tests/test_ssaevent.py | 4 +- 24 files changed, 230 insertions(+), 221 deletions(-) rename pysubs2/{webtt.py => webvtt.py} (95%) diff --git a/.travis.yml b/.travis.yml index ab050d8..5a9a9a6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,7 @@ language: python python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - "3.7" + - "3.8" + - "3.9" install: pip install . script: nosetests diff --git a/LICENSE.txt b/LICENSE.txt index f7b5dc0..af77dbf 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2014-2019 Tomas Karabela +Copyright (c) 2014-2020 Tomas Karabela Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/api-reference.rst b/docs/api-reference.rst index ba57c96..588ffc4 100644 --- a/docs/api-reference.rst +++ b/docs/api-reference.rst @@ -1,8 +1,6 @@ API Reference ============= -.. note:: The documentation is written from Python 3 point of view; a "string" means Unicode string. - Supported input/output formats ------------------------------ diff --git a/docs/conf.py b/docs/conf.py index d48902b..fe8d592 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,7 +48,7 @@ # General information about the project. project = u'pysubs2' -copyright = u'2014-2017, Tomas Karabela' +copyright = u'2014-2020, Tomas Karabela' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/docs/index.rst b/docs/index.rst index 0c03b45..1eb10c4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,7 +20,7 @@ the native format of `Aegisub `_; it also supports *Su line.text = "{\\be1}" + line.text subs.save("my_subtitles_edited.ass") -pysubs2 works with Python 2.7 and 3.4+. It’s available under the MIT license (see bottom of the page). +pysubs2 works with Python 3.7 or newer. It’s available under the MIT license (see bottom of the page). To install pysubs2, just use `pip `_: ``pip install pysubs2``. You can also clone `the GitHub repository `_ and install via ``python setup.py install``. @@ -43,9 +43,9 @@ Documentation License ------- -:: +.. code-block:: text - Copyright (c) 2014-2019 Tomas Karabela + Copyright (c) 2014-2020 Tomas Karabela Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/tutorial.rst b/docs/tutorial.rst index b03b880..c28b87d 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -27,7 +27,7 @@ Now that we have a real file on the harddrive, let's import pysubs2 and load it. >>> subs -.. tip:: pysubs2 is written with Python 3 in mind, meaning that it speaks Unicode. By default, it uses UTF-8 when reading and writing files. Use the ``encoding`` keyword argument in case you need something else. +.. tip:: By default, pysubs2 uses UTF-8 encoding when reading and writing files. Use the ``encoding`` keyword argument in case you need something else. Now we have a subtitle file, the :class:`pysubs2.SSAFile` object. It has two "events", ie. subtitles. You can treat ``subs`` as a list: diff --git a/pysubs2/cli.py b/pysubs2/cli.py index ba59c2d..90bbe06 100644 --- a/pysubs2/cli.py +++ b/pysubs2/cli.py @@ -1,4 +1,3 @@ -from __future__ import unicode_literals, print_function import argparse import codecs import os @@ -11,35 +10,35 @@ from .formats import get_file_extension, FORMAT_IDENTIFIERS from .time import make_time from .ssafile import SSAFile -from .common import PY3, VERSION +from .common import VERSION -def positive_float(s): +def positive_float(s: str) -> float: x = float(s) if not x > 0: raise argparse.ArgumentTypeError("%r is not a positive number" % s) return x -def character_encoding(s): +def character_encoding(s: str) -> str: try: codecs.lookup(s) return s except LookupError: raise argparse.ArgumentError -def time(s): +def time(s: str): d = {} for v, k in re.findall(r"(\d*\.?\d*)(ms|m|s|h)", s): d[k] = float(v) return make_time(**d) -def change_ext(path, ext): +def change_ext(path: str, ext: str) -> str: base, _ = op.splitext(path) return base + ext -class Pysubs2CLI(object): +class Pysubs2CLI: def __init__(self): parser = self.parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, prog="pysubs2", @@ -140,12 +139,8 @@ def main(self, argv): with open(outpath, "w", encoding=args.output_enc) as outfile: subs.to_file(outfile, output_format, args.fps) else: - if PY3: - infile = io.TextIOWrapper(sys.stdin.buffer, args.input_enc) - outfile = io.TextIOWrapper(sys.stdout.buffer, args.output_enc) - else: - infile = io.TextIOWrapper(sys.stdin, args.input_enc) - outfile = io.TextIOWrapper(sys.stdout, args.output_enc) + infile = io.TextIOWrapper(sys.stdin.buffer, args.input_enc) + outfile = io.TextIOWrapper(sys.stdout.buffer, args.output_enc) subs = SSAFile.from_file(infile, args.input_format, args.fps) self.process(subs, args) diff --git a/pysubs2/common.py b/pysubs2/common.py index 4688e5d..b10bb6b 100644 --- a/pysubs2/common.py +++ b/pysubs2/common.py @@ -1,30 +1,26 @@ from collections import namedtuple -import sys +from typing import Union + _Color = namedtuple("Color", "r g b a") + class Color(_Color): """ (r, g, b, a) namedtuple for 8-bit RGB color with alpha channel. All values are ints from 0 to 255. """ - def __new__(cls, r, g, b, a=0): + def __new__(cls, r: int, g: int, b: int, a: int=0): for value in r, g, b, a: if value not in range(256): raise ValueError("Color channels must have values 0-255") return _Color.__new__(cls, r, g, b, a) + #: Version of the pysubs2 library. VERSION = "0.2.4" -PY3 = sys.version_info.major == 3 - -if PY3: - text_type = str - binary_string_type = bytes -else: - text_type = unicode - binary_string_type = str +IntOrFloat = Union[int, float] diff --git a/pysubs2/exceptions.py b/pysubs2/exceptions.py index b9d5285..9568fa5 100644 --- a/pysubs2/exceptions.py +++ b/pysubs2/exceptions.py @@ -1,17 +1,22 @@ class Pysubs2Error(Exception): """Base class for pysubs2 exceptions.""" + class UnknownFPSError(Pysubs2Error): """Framerate was not specified and couldn't be inferred otherwise.""" + class UnknownFileExtensionError(Pysubs2Error): """File extension does not pertain to any known subtitle format.""" + class UnknownFormatIdentifierError(Pysubs2Error): """Unknown subtitle format identifier (ie. string like ``"srt"``).""" + class FormatAutodetectionError(Pysubs2Error): """Subtitle format is ambiguous or unknown.""" + class ContentNotUsable(Pysubs2Error): """Current content not usable for specified format""" diff --git a/pysubs2/formatbase.py b/pysubs2/formatbase.py index 1f33661..21ea9c4 100644 --- a/pysubs2/formatbase.py +++ b/pysubs2/formatbase.py @@ -1,4 +1,8 @@ -class FormatBase(object): +from typing import Optional +import io + + +class FormatBase: """ Base class for subtitle format implementations. @@ -14,7 +18,7 @@ class FormatBase(object): """ @classmethod - def from_file(cls, subs, fp, format_, **kwargs): + def from_file(cls, subs, fp: io.TextIOBase, format_: str, **kwargs): """ Load subtitle file into an empty SSAFile. @@ -37,7 +41,7 @@ def from_file(cls, subs, fp, format_, **kwargs): raise NotImplementedError("Parsing is not supported for this format") @classmethod - def to_file(cls, subs, fp, format_, **kwargs): + def to_file(cls, subs, fp: io.TextIOBase, format_: str, **kwargs): """ Write SSAFile into a file. @@ -62,7 +66,7 @@ def to_file(cls, subs, fp, format_, **kwargs): raise NotImplementedError("Writing is not supported for this format") @classmethod - def guess_format(self, text): + def guess_format(self, text: str) -> Optional[str]: """ Return format identifier of recognized format, or None. diff --git a/pysubs2/formats.py b/pysubs2/formats.py index b97e16b..7ce3a1c 100644 --- a/pysubs2/formats.py +++ b/pysubs2/formats.py @@ -1,3 +1,5 @@ +from typing import Dict, Type + from .formatbase import FormatBase from .microdvd import MicroDVDFormat from .subrip import SubripFormat @@ -5,11 +7,11 @@ from .substation import SubstationFormat from .mpl2 import MPL2Format from .tmp import TmpFormat -from .webtt import WebTTFormat +from .webvtt import WebVTTFormat from .exceptions import * #: Dict mapping file extensions to format identifiers. -FILE_EXTENSION_TO_FORMAT_IDENTIFIER = { +FILE_EXTENSION_TO_FORMAT_IDENTIFIER: Dict[str, str] = { ".srt": "srt", ".ass": "ass", ".ssa": "ssa", @@ -20,7 +22,7 @@ } #: Dict mapping format identifiers to implementations (FormatBase subclasses). -FORMAT_IDENTIFIER_TO_FORMAT_CLASS = { +FORMAT_IDENTIFIER_TO_FORMAT_CLASS: Dict[str, Type[FormatBase]] = { "srt": SubripFormat, "ass": SubstationFormat, "ssa": SubstationFormat, @@ -28,26 +30,29 @@ "json": JSONFormat, "mpl2": MPL2Format, "tmp": TmpFormat, - "vtt": WebTTFormat, + "vtt": WebVTTFormat, } FORMAT_IDENTIFIERS = list(FORMAT_IDENTIFIER_TO_FORMAT_CLASS.keys()) -def get_format_class(format_): + +def get_format_class(format_: str) -> Type[FormatBase]: """Format identifier -> format class (ie. subclass of FormatBase)""" try: return FORMAT_IDENTIFIER_TO_FORMAT_CLASS[format_] except KeyError: raise UnknownFormatIdentifierError(format_) -def get_format_identifier(ext): + +def get_format_identifier(ext: str) -> str: """File extension -> format identifier""" try: return FILE_EXTENSION_TO_FORMAT_IDENTIFIER[ext] except KeyError: raise UnknownFileExtensionError(ext) -def get_file_extension(format_): + +def get_file_extension(format_: str) -> str: """Format identifier -> file extension""" if format_ not in FORMAT_IDENTIFIER_TO_FORMAT_CLASS: raise UnknownFormatIdentifierError(format_) @@ -58,7 +63,8 @@ def get_file_extension(format_): raise RuntimeError("No file extension for format %r" % format_) -def autodetect_format(content): + +def autodetect_format(content: str) -> str: """Return format identifier for given fragment or raise FormatAutodetectionError.""" formats = set() for impl in FORMAT_IDENTIFIER_TO_FORMAT_CLASS.values(): diff --git a/pysubs2/jsonformat.py b/pysubs2/jsonformat.py index cbd8c29..4b31334 100644 --- a/pysubs2/jsonformat.py +++ b/pysubs2/jsonformat.py @@ -1,7 +1,5 @@ -from __future__ import unicode_literals, print_function - import json -from .common import Color, PY3 +from .common import Color from .ssaevent import SSAEvent from .ssastyle import SSAStyle from .formatbase import FormatBase @@ -39,8 +37,4 @@ def to_file(cls, subs, fp, format_, **kwargs): "events": [ev.as_dict() for ev in subs.events] } - if PY3: - json.dump(data, fp) - else: - text = json.dumps(data, fp) - fp.write(unicode(text)) + json.dump(data, fp) diff --git a/pysubs2/microdvd.py b/pysubs2/microdvd.py index f4c9fea..0109a6b 100644 --- a/pysubs2/microdvd.py +++ b/pysubs2/microdvd.py @@ -1,8 +1,5 @@ -from __future__ import unicode_literals, print_function - from functools import partial import re -from .common import text_type from .exceptions import UnknownFPSError from .ssaevent import SSAEvent from .ssastyle import SSAStyle @@ -86,7 +83,7 @@ def is_drawing(line): # insert an artificial first line telling the framerate if write_fps_declaration: - subs.insert(0, SSAEvent(start=0, end=0, text=text_type(fps))) + subs.insert(0, SSAEvent(start=0, end=0, text=str(fps))) for line in subs: if line.is_comment or is_drawing(line): diff --git a/pysubs2/mpl2.py b/pysubs2/mpl2.py index 5c90bb4..acb330d 100644 --- a/pysubs2/mpl2.py +++ b/pysubs2/mpl2.py @@ -1,6 +1,3 @@ -# coding=utf-8 - -from __future__ import print_function, division, unicode_literals import re from .time import times_to_ms diff --git a/pysubs2/ssaevent.py b/pysubs2/ssaevent.py index 4d9dac8..4b70aec 100644 --- a/pysubs2/ssaevent.py +++ b/pysubs2/ssaevent.py @@ -1,10 +1,11 @@ -from __future__ import unicode_literals import re +from typing import Optional, Dict, Any + +from .common import IntOrFloat from .time import ms_to_str, make_time -from .common import PY3 -class SSAEvent(object): +class SSAEvent: """ A SubStation Event, ie. one subtitle. @@ -29,28 +30,34 @@ class SSAEvent(object): "name", "marginl", "marginr", "marginv", "effect", "type" ]) - def __init__(self, **fields): - self.start = 0 #: Subtitle start time (in milliseconds) - self.end = 10000 #: Subtitle end time (in milliseconds) - self.text = "" #: Text of subtitle (with SubStation override tags) - self.marked = False #: (SSA only) - self.layer = 0 #: Layer number, 0 is the lowest layer (ASS only) - self.style = "Default" #: Style name - self.name = "" #: Actor name - self.marginl = 0 #: Left margin - self.marginr = 0 #: Right margin - self.marginv = 0 #: Vertical margin - self.effect = "" #: Line effect - self.type = "Dialogue" #: Line type (Dialogue/Comment) - - for k, v in fields.items(): - if k in self.FIELDS: - setattr(self, k, v) - else: - raise ValueError("SSAEvent has no field named %r" % k) + def __init__(self, + start: int = 0, + end: int = 10000, + text: str = "", + marked: bool = False, + layer: int = 0, + style: str = "Default", + name: str = "", + marginl: int = 0, + marginr: int = 0, + marginv: int = 0, + effect: str = "", + type: str = "Dialogue"): + self.start: int = start #: Subtitle start time (in milliseconds) + self.end: int = end #: Subtitle end time (in milliseconds) + self.text: str = text #: Text of subtitle (with SubStation override tags) + self.marked: bool = marked #: (SSA only) + self.layer: int = layer #: Layer number, 0 is the lowest layer (ASS only) + self.style: str = style #: Style name + self.name: str = name #: Actor name + self.marginl: int = marginl #: Left margin + self.marginr: int = marginr #: Right margin + self.marginv: int = marginv #: Vertical margin + self.effect: str = effect #: Line effect + self.type: str = type #: Line type (Dialogue/Comment) @property - def duration(self): + def duration(self) -> IntOrFloat: """ Subtitle duration in milliseconds (read/write property). @@ -60,14 +67,14 @@ def duration(self): return self.end - self.start @duration.setter - def duration(self, ms): + def duration(self, ms: int): if ms >= 0: self.end = self.start + ms else: raise ValueError("Subtitle duration cannot be negative") @property - def is_comment(self): + def is_comment(self) -> bool: """ When true, the subtitle is a comment, ie. not visible (read/write property). @@ -77,14 +84,14 @@ def is_comment(self): return self.type == "Comment" @is_comment.setter - def is_comment(self, value): + def is_comment(self, value: bool): if value: self.type = "Comment" else: self.type = "Dialogue" @property - def plaintext(self): + def plaintext(self) -> str: """ Subtitle text as multi-line string with no tags (read/write property). @@ -99,10 +106,11 @@ def plaintext(self): return text @plaintext.setter - def plaintext(self, text): + def plaintext(self, text: str): self.text = text.replace("\n", r"\N") - def shift(self, h=0, m=0, s=0, ms=0, frames=None, fps=None): + def shift(self, h: IntOrFloat=0, m: IntOrFloat=0, s: IntOrFloat=0, ms: IntOrFloat=0, + frames: Optional[int]=None, fps: Optional[float]=None): """ Shift start and end times. @@ -113,41 +121,38 @@ def shift(self, h=0, m=0, s=0, ms=0, frames=None, fps=None): self.start += delta self.end += delta - def copy(self): + def copy(self) -> "SSAEvent": """Return a copy of the SSAEvent.""" return SSAEvent(**self.as_dict()) - def as_dict(self): + def as_dict(self) -> Dict[str, Any]: return {field: getattr(self, field) for field in self.FIELDS} - def equals(self, other): + def equals(self, other: "SSAEvent") -> bool: """Field-based equality for SSAEvents.""" if isinstance(other, SSAEvent): return self.as_dict() == other.as_dict() else: raise TypeError("Cannot compare to non-SSAEvent object") - def __eq__(self, other): + def __eq__(self, other: "SSAEvent"): # XXX document this return self.start == other.start and self.end == other.end - def __ne__(self, other): + def __ne__(self, other: "SSAEvent"): return self.start != other.start or self.end != other.end - def __lt__(self, other): + def __lt__(self, other: "SSAEvent"): return (self.start, self.end) < (other.start, other.end) - def __le__(self, other): + def __le__(self, other: "SSAEvent"): return (self.start, self.end) <= (other.start, other.end) - def __gt__(self, other): + def __gt__(self, other: "SSAEvent"): return (self.start, self.end) > (other.start, other.end) - def __ge__(self, other): + def __ge__(self, other: "SSAEvent"): return (self.start, self.end) >= (other.start, other.end) def __repr__(self): - s = "".format( - self=self, start=ms_to_str(self.start), end=ms_to_str(self.end)) - if not PY3: s = s.encode("utf-8") - return s + return f"" diff --git a/pysubs2/ssafile.py b/pysubs2/ssafile.py index 390a31b..9545251 100644 --- a/pysubs2/ssafile.py +++ b/pysubs2/ssafile.py @@ -1,16 +1,17 @@ -from __future__ import print_function, unicode_literals, division -from collections import MutableSequence, OrderedDict +from collections import MutableSequence import io from io import open -from itertools import starmap, chain +from itertools import chain import os.path import logging +from typing import Optional, List, Dict + +from .common import IntOrFloat from .formats import autodetect_format, get_format_class, get_format_identifier from .substation import is_valid_field_content from .ssaevent import SSAEvent from .ssastyle import SSAStyle from .time import make_time, ms_to_str -from .common import PY3 class SSAFile(MutableSequence): @@ -31,25 +32,26 @@ class SSAFile(MutableSequence): """ - DEFAULT_INFO = OrderedDict([ - ("WrapStyle", "0"), - ("ScaledBorderAndShadow", "yes"), - ("Collisions", "Normal")]) + DEFAULT_INFO = { + "WrapStyle": "0", + "ScaledBorderAndShadow": "yes", + "Collisions": "Normal" + } def __init__(self): - self.events = [] #: List of :class:`SSAEvent` instances, ie. individual subtitles. - self.styles = OrderedDict([("Default", SSAStyle.DEFAULT_STYLE.copy())]) #: Dict of :class:`SSAStyle` instances. - self.info = self.DEFAULT_INFO.copy() #: Dict with script metadata, ie. ``[Script Info]``. - self.aegisub_project = OrderedDict() #: Dict with Aegisub project, ie. ``[Aegisub Project Garbage]``. - self.fps = None #: Framerate used when reading the file, if applicable. - self.format = None #: Format of source subtitle file, if applicable, eg. ``"srt"``. + self.events: List[SSAEvent] = [] #: List of :class:`SSAEvent` instances, ie. individual subtitles. + self.styles: Dict[str, SSAStyle] = {"Default": SSAStyle.DEFAULT_STYLE.copy()} #: Dict of :class:`SSAStyle` instances. + self.info: Dict[str, str] = self.DEFAULT_INFO.copy() #: Dict with script metadata, ie. ``[Script Info]``. + self.aegisub_project: Dict[str, str] = {} #: Dict with Aegisub project, ie. ``[Aegisub Project Garbage]``. + self.fps: Optional[float] = None #: Framerate used when reading the file, if applicable. + self.format: Optional[str] = None #: Format of source subtitle file, if applicable, eg. ``"srt"``. # ------------------------------------------------------------------------ # I/O methods # ------------------------------------------------------------------------ @classmethod - def load(cls, path, encoding="utf-8", format_=None, fps=None, **kwargs): + def load(cls, path: str, encoding: str="utf-8", format_: Optional[str]=None, fps: Optional[float]=None, **kwargs) -> "SSAFile": """ Load subtitle file from given path. @@ -100,7 +102,7 @@ def load(cls, path, encoding="utf-8", format_=None, fps=None, **kwargs): return cls.from_file(fp, format_, fps=fps, **kwargs) @classmethod - def from_string(cls, string, format_=None, fps=None, **kwargs): + def from_string(cls, string: str, format_: Optional[str]=None, fps: Optional[float]=None, **kwargs) -> "SSAFile": """ Load subtitle file from string. @@ -126,7 +128,7 @@ def from_string(cls, string, format_=None, fps=None, **kwargs): return cls.from_file(fp, format_, fps=fps, **kwargs) @classmethod - def from_file(cls, fp, format_=None, fps=None, **kwargs): + def from_file(cls, fp: io.TextIOBase, format_: Optional[str]=None, fps: Optional[float]=None, **kwargs) -> "SSAFile": """ Read subtitle file from file object. @@ -160,7 +162,7 @@ def from_file(cls, fp, format_=None, fps=None, **kwargs): impl.from_file(subs, fp, format_, fps=fps, **kwargs) return subs - def save(self, path, encoding="utf-8", format_=None, fps=None, **kwargs): + def save(self, path: str, encoding: str="utf-8", format_: Optional[str]=None, fps: Optional[float]=None, **kwargs): """ Save subtitle file to given path. @@ -197,7 +199,7 @@ def save(self, path, encoding="utf-8", format_=None, fps=None, **kwargs): with open(path, "w", encoding=encoding) as fp: self.to_file(fp, format_, fps=fps, **kwargs) - def to_string(self, format_, fps=None, **kwargs): + def to_string(self, format_: str, fps: Optional[float]=None, **kwargs) -> str: """ Get subtitle file as a string. @@ -211,7 +213,7 @@ def to_string(self, format_, fps=None, **kwargs): self.to_file(fp, format_, fps=fps, **kwargs) return fp.getvalue() - def to_file(self, fp, format_, fps=None, **kwargs): + def to_file(self, fp: io.TextIOBase, format_: str, fps: Optional[float]=None, **kwargs): """ Write subtitle file to file object. @@ -233,7 +235,8 @@ def to_file(self, fp, format_, fps=None, **kwargs): # Retiming subtitles # ------------------------------------------------------------------------ - def shift(self, h=0, m=0, s=0, ms=0, frames=None, fps=None): + def shift(self, h: IntOrFloat=0, m: IntOrFloat=0, s: IntOrFloat=0, ms: IntOrFloat=0, + frames: Optional[int]=None, fps: Optional[float]=None): """ Shift all subtitles by constant time amount. @@ -255,7 +258,7 @@ def shift(self, h=0, m=0, s=0, ms=0, frames=None, fps=None): line.start += delta line.end += delta - def transform_framerate(self, in_fps, out_fps): + def transform_framerate(self, in_fps: float, out_fps: float): """ Rescale all timestamps by ratio of in_fps/out_fps. @@ -282,7 +285,7 @@ def transform_framerate(self, in_fps, out_fps): # Working with styles # ------------------------------------------------------------------------ - def rename_style(self, old_name, new_name): + def rename_style(self, old_name: str, new_name: str): """ Rename a style, including references to it. @@ -311,7 +314,7 @@ def rename_style(self, old_name, new_name): if line.style == old_name: line.style = new_name - def import_styles(self, subs, overwrite=True): + def import_styles(self, subs: "SSAFile", overwrite: bool=True): """ Merge in styles from other SSAFile. @@ -332,7 +335,7 @@ def import_styles(self, subs, overwrite=True): # Helper methods # ------------------------------------------------------------------------ - def equals(self, other): + def equals(self, other: "SSAFile"): """ Equality of two SSAFiles. @@ -389,12 +392,10 @@ def equals(self, other): def __repr__(self): if self.events: max_time = max(ev.end for ev in self) - s = "" % \ - (len(self), len(self.styles), ms_to_str(max_time)) + s = f"" else: - s = "" % len(self.styles) + s = f"" - if not PY3: s = s.encode("utf-8") return s # ------------------------------------------------------------------------ @@ -405,22 +406,22 @@ def sort(self): """Sort subtitles time-wise, in-place.""" self.events.sort() - def __getitem__(self, item): + def __getitem__(self, item: int): return self.events[item] - def __setitem__(self, key, value): + def __setitem__(self, key: int, value: SSAEvent): if isinstance(value, SSAEvent): self.events[key] = value else: raise TypeError("SSAFile.events must contain only SSAEvent objects") - def __delitem__(self, key): + def __delitem__(self, key: int): del self.events[key] def __len__(self): return len(self.events) - def insert(self, index, value): + def insert(self, index: int, value: SSAEvent): if isinstance(value, SSAEvent): self.events.insert(index, value) else: diff --git a/pysubs2/ssastyle.py b/pysubs2/ssastyle.py index 3d10502..3fc5abb 100644 --- a/pysubs2/ssastyle.py +++ b/pysubs2/ssastyle.py @@ -1,8 +1,9 @@ -from __future__ import unicode_literals -from .common import Color, PY3 +from typing import Dict, Any +from .common import Color -class SSAStyle(object): + +class SSAStyle: """ A SubStation Style. @@ -17,7 +18,7 @@ class SSAStyle(object): This class defines equality (equality of all fields). """ - DEFAULT_STYLE = None + DEFAULT_STYLE: "SSAStyle" = None #: All fields in SSAStyle. FIELDS = frozenset([ @@ -29,64 +30,79 @@ class SSAStyle(object): "marginl", "marginr", "marginv", "alphalevel", "encoding" ]) - def __init__(self, **fields): - self.fontname = "Arial" #: Font name - self.fontsize = 20.0 #: Font size (in pixels) - self.primarycolor = Color(255, 255, 255, 0) #: Primary color (:class:`pysubs2.Color` instance) - self.secondarycolor = Color(255, 0, 0, 0) #: Secondary color (:class:`pysubs2.Color` instance) - self.tertiarycolor = Color(0, 0, 0, 0) #: Tertiary color (:class:`pysubs2.Color` instance) - self.outlinecolor = Color(0, 0, 0, 0) #: Outline color (:class:`pysubs2.Color` instance) - self.backcolor = Color(0, 0, 0, 0) #: Back, ie. shadow color (:class:`pysubs2.Color` instance) - self.bold = False #: Bold - self.italic = False #: Italic - self.underline = False #: Underline (ASS only) - self.strikeout = False #: Strikeout (ASS only) - self.scalex = 100.0 #: Horizontal scaling (ASS only) - self.scaley = 100.0 #: Vertical scaling (ASS only) - self.spacing = 0.0 #: Letter spacing (ASS only) - self.angle = 0.0 #: Rotation (ASS only) - self.borderstyle = 1 #: Border style - self.outline = 2.0 #: Outline width (in pixels) - self.shadow = 2.0 #: Shadow depth (in pixels) - self.alignment = 2 #: Numpad-style alignment, eg. 7 is "top left" (that is, ASS alignment semantics) - self.marginl = 10 #: Left margin (in pixels) - self.marginr = 10 #: Right margin (in pixels) - self.marginv = 10 #: Vertical margin (in pixels) - self.alphalevel = 0 #: Old, unused SSA-only field - self.encoding = 1 #: Charset + def __init__(self, + fontname: str = "Arial", + fontsize: float = 20.0, + primarycolor: Color = Color(255, 255, 255, 0), + secondarycolor: Color = Color(255, 0, 0, 0), + tertiarycolor: Color = Color(0, 0, 0, 0), + outlinecolor: Color = Color(0, 0, 0, 0), + backcolor: Color = Color(0, 0, 0, 0), + bold: bool = False, + italic: bool = False, + underline: bool = False, + strikeout: bool = False, + scalex: float = 100.0, + scaley: float = 100.0, + spacing: float = 0.0, + angle: float = 0.0, + borderstyle: int = 1, + outline: float = 2.0, + shadow: float = 2.0, + alignment: int = 2, + marginl: int = 10, + marginr: int = 10, + marginv: int = 10, + alphalevel: int = 0, + encoding: int = 1): + self.fontname: str = fontname #: Font name + self.fontsize: float = fontsize #: Font size (in pixels) + self.primarycolor: Color = primarycolor #: Primary color (:class:`pysubs2.Color` instance) + self.secondarycolor: Color = secondarycolor #: Secondary color (:class:`pysubs2.Color` instance) + self.tertiarycolor: Color = tertiarycolor #: Tertiary color (:class:`pysubs2.Color` instance) + self.outlinecolor: Color = outlinecolor #: Outline color (:class:`pysubs2.Color` instance) + self.backcolor: Color = backcolor #: Back, ie. shadow color (:class:`pysubs2.Color` instance) + self.bold: bool = bold #: Bold + self.italic: bool = italic #: Italic + self.underline: bool = underline #: Underline (ASS only) + self.strikeout: bool = strikeout #: Strikeout (ASS only) + self.scalex: float = scalex #: Horizontal scaling (ASS only) + self.scaley: float = scaley #: Vertical scaling (ASS only) + self.spacing: float = spacing #: Letter spacing (ASS only) + self.angle: float = angle #: Rotation (ASS only) + self.borderstyle: int = borderstyle #: Border style + self.outline: float = outline #: Outline width (in pixels) + self.shadow: float = shadow #: Shadow depth (in pixels) + self.alignment: int = alignment #: Numpad-style alignment, eg. 7 is "top left" (that is, ASS alignment semantics) + self.marginl: int = marginl #: Left margin (in pixels) + self.marginr: int = marginr #: Right margin (in pixels) + self.marginv: int = marginv #: Vertical margin (in pixels) + self.alphalevel: int = alphalevel #: Old, unused SSA-only field + self.encoding: int = encoding #: Charset # The following attributes cannot be defined for SSA styles themselves, # but can be used in override tags and thus are useful to keep here # for the `pysubs2.substation.parse_tags()` interface which returns # SSAStyles for text fragments. - self.drawing = False #: Drawing (ASS only override tag, see http://docs.aegisub.org/3.1/ASS_Tags/#drawing-tags) - - for k, v in fields.items(): - if k in self.FIELDS: - setattr(self, k, v) - else: - raise ValueError("SSAStyle has no field named %r" % k) + self.drawing: bool = False #: Drawing (ASS only override tag, see http://docs.aegisub.org/3.1/ASS_Tags/#drawing-tags) - def copy(self): + def copy(self) -> "SSAStyle": return SSAStyle(**self.as_dict()) - def as_dict(self): + def as_dict(self) -> Dict[str, Any]: return {field: getattr(self, field) for field in self.FIELDS} - def __eq__(self, other): + def __eq__(self, other: "SSAStyle"): return self.as_dict() == other.as_dict() - def __ne__(self, other): + def __ne__(self, other: "SSAStyle"): return not self == other def __repr__(self): - s = "" SSAStyle.DEFAULT_STYLE = SSAStyle() diff --git a/pysubs2/subrip.py b/pysubs2/subrip.py index 368ee02..0bd3803 100644 --- a/pysubs2/subrip.py +++ b/pysubs2/subrip.py @@ -1,5 +1,3 @@ -from __future__ import print_function, unicode_literals - import re from .formatbase import FormatBase from .ssaevent import SSAEvent diff --git a/pysubs2/substation.py b/pysubs2/substation.py index d4da2ec..23e534f 100644 --- a/pysubs2/substation.py +++ b/pysubs2/substation.py @@ -1,10 +1,9 @@ -from __future__ import print_function, division, unicode_literals import re from numbers import Number from .formatbase import FormatBase from .ssaevent import SSAEvent from .ssastyle import SSAStyle -from .common import text_type, Color, PY3, binary_string_type +from .common import Color from .time import make_time, ms_to_times, timestamp_to_ms, TIMESTAMP SSA_ALIGNMENT = (1, 2, 3, 9, 10, 11, 5, 6, 7) @@ -233,19 +232,11 @@ def field_to_string(f, v, line): elif f == "marked": return "Marked=%d" % v elif f == "alignment" and format_ == "ssa": - return text_type(ass_to_ssa_alignment(v)) + return str(ass_to_ssa_alignment(v)) elif isinstance(v, bool): return "-1" if v else "0" - elif isinstance(v, (text_type, Number)): - return text_type(v) - elif not PY3 and isinstance(v, binary_string_type): - # A convenience feature, see issue #12 - accept non-unicode strings - # when they are ASCII; this is useful in Python 2, especially for non-text - # fields like style names, where requiring Unicode type seems too stringent - if all(ord(c) < 128 for c in v): - return text_type(v) - else: - raise TypeError("Encountered binary string with non-ASCII codepoint in SubStation field {!r} for line {!r} - please use unicode string instead of str".format(f, line)) + elif isinstance(v, (str, Number)): + return str(v) elif isinstance(v, Color): if format_ == "ass": return color_to_ass_rgba(v) diff --git a/pysubs2/time.py b/pysubs2/time.py index 24e9ec0..828c406 100644 --- a/pysubs2/time.py +++ b/pysubs2/time.py @@ -1,15 +1,19 @@ -from __future__ import division - from collections import namedtuple import re #: Pattern that matches both SubStation and SubRip timestamps. +from typing import Optional, List, Tuple, Sequence + +from pysubs2.common import IntOrFloat + TIMESTAMP = re.compile(r"(\d{1,2}):(\d{2}):(\d{2})[.,](\d{2,3})") Times = namedtuple("Times", ["h", "m", "s", "ms"]) -def make_time(h=0, m=0, s=0, ms=0, frames=None, fps=None): + +def make_time(h: IntOrFloat=0, m: IntOrFloat=0, s: IntOrFloat=0, ms: IntOrFloat=0, + frames: Optional[int]=None, fps: Optional[float]=None): """ Convert time to milliseconds. @@ -33,7 +37,8 @@ def make_time(h=0, m=0, s=0, ms=0, frames=None, fps=None): else: raise ValueError("Both fps and frames must be specified") -def timestamp_to_ms(groups): + +def timestamp_to_ms(groups: Sequence[str]): """ Convert groups from :data:`pysubs2.time.TIMESTAMP` match to milliseconds. @@ -49,7 +54,8 @@ def timestamp_to_ms(groups): ms += h * 3600000 return ms -def tmptimestamp_to_ms(groups): + +def tmptimestamp_to_ms(groups: Sequence[str]): """ Convert groups from :data:`pysubs2.time.TMPTIMESTAMP` match to milliseconds. @@ -63,7 +69,9 @@ def tmptimestamp_to_ms(groups): ms += m * 60000 ms += h * 3600000 return ms -def times_to_ms(h=0, m=0, s=0, ms=0): + + +def times_to_ms(h: IntOrFloat=0, m: IntOrFloat=0, s: IntOrFloat=0, ms: IntOrFloat=0) -> int: """ Convert hours, minutes, seconds to milliseconds. @@ -79,7 +87,8 @@ def times_to_ms(h=0, m=0, s=0, ms=0): ms += h * 3600000 return int(round(ms)) -def frames_to_ms(frames, fps): + +def frames_to_ms(frames: int, fps: float) -> int: """ Convert frame-based duration to milliseconds. @@ -99,7 +108,8 @@ def frames_to_ms(frames, fps): return int(round(frames * (1000 / fps))) -def ms_to_frames(ms, fps): + +def ms_to_frames(ms: IntOrFloat, fps: float) -> int: """ Convert milliseconds to number of frames. @@ -119,7 +129,8 @@ def ms_to_frames(ms, fps): return int(round((ms / 1000) * fps)) -def ms_to_times(ms): + +def ms_to_times(ms: IntOrFloat) -> Tuple[int, int, int, int]: """ Convert milliseconds to normalized tuple (h, m, s, ms). @@ -138,7 +149,8 @@ def ms_to_times(ms): s, ms = divmod(ms, 1000) return Times(h, m, s, ms) -def ms_to_str(ms, fractions=False): + +def ms_to_str(ms: IntOrFloat, fractions: bool=False) -> str: """ Prettyprint milliseconds to [-]H:MM:SS[.mmm] @@ -156,6 +168,6 @@ def ms_to_str(ms, fractions=False): sgn = "-" if ms < 0 else "" h, m, s, ms = ms_to_times(abs(ms)) if fractions: - return sgn + "{:01d}:{:02d}:{:02d}.{:03d}".format(h, m, s, ms) + return f"{sgn}{h:01d}:{m:02d}:{s:02d}.{ms:03d}" else: - return sgn + "{:01d}:{:02d}:{:02d}".format(h, m, s) + return f"{sgn}{h:01d}:{m:02d}:{s:02d}" diff --git a/pysubs2/tmp.py b/pysubs2/tmp.py index 19ce1eb..1e2df3b 100644 --- a/pysubs2/tmp.py +++ b/pysubs2/tmp.py @@ -1,5 +1,3 @@ -from __future__ import print_function, unicode_literals - import re from .formatbase import FormatBase from .ssaevent import SSAEvent @@ -15,6 +13,7 @@ #: Largest timestamp allowed in Tmp, ie. 99:59:59. MAX_REPRESENTABLE_TIME = make_time(h=100) - 1 + def ms_to_timestamp(ms): """Convert ms to 'HH:MM:SS'""" # XXX throw on overflow/underflow? diff --git a/pysubs2/webtt.py b/pysubs2/webvtt.py similarity index 95% rename from pysubs2/webtt.py rename to pysubs2/webvtt.py index 65900da..2c69d12 100644 --- a/pysubs2/webtt.py +++ b/pysubs2/webvtt.py @@ -3,7 +3,7 @@ from .time import make_time -class WebTTFormat(SubripFormat): +class WebVTTFormat(SubripFormat): TIMESTAMP = re.compile(r"(\d{0,4}:)?(\d{2}):(\d{2})\.(\d{2,3})") @staticmethod diff --git a/setup.py b/setup.py index b3764db..c8d7fed 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- try: from setuptools import setup @@ -36,12 +35,10 @@ """), classifiers = [ "Programming Language :: Python", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Development Status :: 5 - Production/Stable", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Text Processing :: Markup", diff --git a/tests/test_ssaevent.py b/tests/test_ssaevent.py index 6bae9be..1f0d6a8 100644 --- a/tests/test_ssaevent.py +++ b/tests/test_ssaevent.py @@ -4,13 +4,13 @@ def test_repr_dialogue(): ev = SSAEvent(start=make_time(m=1, s=30), end=make_time(m=1, s=35), text="Hello\\Nworld!") - ref = "" + ref = r"" assert repr(ev) == ref def test_repr_comment(): ev = SSAEvent(start=make_time(m=1, s=30), end=make_time(m=1, s=35), text="Hello\\Nworld!") ev.is_comment = True - ref = "" + ref = r"" assert repr(ev) == ref def test_duration():