Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 29 additions & 2 deletions srt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<!\s)" + SRT_REGEX.pattern, re.DOTALL)

ZERO_TIMEDELTA = timedelta(0)

# Info message if truthy return -> Function taking a Subtitle, skip if True
Expand Down Expand Up @@ -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) < (
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/test_srt.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os
import re
import string
import time
from io import StringIO

import pytest
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 == []