diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 281b0f6..e785aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ jobs: matrix: # Pin to 20.04 for 3.6: https://github.com/actions/setup-python/issues/544 os: [ubuntu-20.04, macos-latest, windows-latest] - python-version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] runs-on: ${{ matrix.os }} steps: diff --git a/srt.py b/srt.py index 9d0b586..36287b4 100755 --- a/srt.py +++ b/srt.py @@ -74,6 +74,10 @@ re.DOTALL, ) +# Used by _iter_srt_matches: SRT_REGEX, but only at positions where no match +# could have started one character earlier. +_SRT_REGEX_NOT_AFTER_WS = re.compile(r"(? Function taking a Subtitle, skip if True @@ -126,7 +130,7 @@ def __hash__(self): return hash(frozenset(vars(self).items())) def __eq__(self, other): - return vars(self) == vars(other) + return isinstance(other, Subtitle) and vars(self) == vars(other) def __lt__(self, other): return (self.start, self.end, self.index) < ( @@ -370,7 +374,7 @@ def parse(srt, ignore_errors=False): if isinstance(srt, FILE_TYPES): srt = srt.read() - for match in SRT_REGEX.finditer(srt): + for match in _iter_srt_matches(srt): actual_start = match.start() _check_contiguity(srt, expected_start, actual_start, ignore_errors) raw_index, raw_start, raw_end, proprietary, content = match.groups() @@ -406,6 +410,29 @@ def parse(srt, ignore_errors=False): _check_contiguity(srt, expected_start, len(srt), ignore_errors) +def _iter_srt_matches(srt): + """ + Yield the same matches as ``SRT_REGEX.finditer(srt)``, in linear rather + than quadratic time on long whitespace runs that contain no subtitle. + + ``SRT_REGEX`` starts with ``\\s*``, so if a match starts at position ``p`` + and ``srt[p - 1]`` is whitespace, a match also starts at ``p - 1``. The + leftmost match at or after ``pos`` therefore either starts at ``pos`` or + at a position not preceded by whitespace. ``finditer`` retries every + position inside a whitespace run, and each retry rescans the rest of the + run; ``_SRT_REGEX_NOT_AFTER_WS`` skips those positions in constant time. + """ + pos = 0 + while pos <= len(srt): + match = SRT_REGEX.match(srt, pos) + if match is None: + match = _SRT_REGEX_NOT_AFTER_WS.search(srt, pos + 1) + if match is None: + return + yield match + pos = match.end() + + def _check_contiguity(srt, expected_start, actual_start, warn_only): """ If ``warn_only`` is False, raise :py:class:`SRTParseError` with diagnostic diff --git a/tests/test_srt.py b/tests/test_srt.py index 05608e0..33b7f3a 100644 --- a/tests/test_srt.py +++ b/tests/test_srt.py @@ -8,6 +8,7 @@ import os import re import string +import time from io import StringIO import pytest @@ -241,6 +242,12 @@ def test_subtitle_inequality(sub_1): assert sub_1 != sub_2 +@given(subtitles()) +def test_subtitle_inequality_to_non_matching_type(sub_1): + assert sub_1 != None + assert sub_1 != 1 + + @given(subtitles()) def test_subtitle_from_scratch_equality(subtitle): srt_block = subtitle.to_srt() @@ -765,3 +772,24 @@ def test_parse_file_with_missing_index(input_subs): # cf. issue #51 # ...and sort the same. assert srt.compose(subs_no_index) == srt.compose(subs_zero_index) + + +@given(st.text(alphabet=" \t\r\n0123456789:,->x", max_size=60)) +def test_iter_srt_matches_same_as_finditer(text): + # _iter_srt_matches is a faster finditer; it must find exactly the same + # matches, including on garbage and whitespace between subtitles. + subs_text = "1\n00:00:01,000 --> 00:00:02,000\nhello\n\n" + for srt_data in (text, subs_text + text, text + subs_text): + expected = [(m.span(), m.groups()) for m in srt.SRT_REGEX.finditer(srt_data)] + actual = [(m.span(), m.groups()) for m in srt._iter_srt_matches(srt_data)] + assert actual == expected + + +def test_parse_long_whitespace_is_not_quadratic(): + # Each finditer retry inside a whitespace run used to rescan the rest of + # the run: about 20 seconds for 32000 spaces. + for srt_data in (" " * 32000, "\n" * 32000, "x" + " " * 32000): + start = time.perf_counter() + subs = list(srt.parse(srt_data, ignore_errors=True)) + assert time.perf_counter() - start < 1 + assert subs == []