From d84b43c80431fde56e4dc062d9c2642ffa8af341 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 20 Jul 2026 07:42:02 -0700 Subject: [PATCH 01/26] Add stepped frame-range parsing and %x formatting support --- .gitignore | 5 +- README.md | 19 ++++++ docs/cli-tools.md | 4 ++ docs/examples.md | 42 ++++++++++++ docs/formatting.md | 28 ++++++++ lib/pyseq/frange.py | 161 ++++++++++++++++++++++++++++++++++++++++++++ lib/pyseq/seq.py | 106 ++++++++++++++++------------- lib/pyseq/util.py | 76 ++++++++++++--------- tests/test_pyseq.py | 21 ++++++ 9 files changed, 381 insertions(+), 81 deletions(-) create mode 100644 lib/pyseq/frange.py diff --git a/.gitignore b/.gitignore index ec64e06..e2de1e8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,7 @@ tmp/ venv*/ .vscode/ pyseq.egg-info/ -default.env \ No newline at end of file +default.env +.codex +.agents/ +.pytest_cache/ \ No newline at end of file diff --git a/README.md b/README.md index 6bdfc7b..6b99300 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,25 @@ Deserialize a compressed sequence string: 1001 012_vb_110_v001.%04d.png [1-1001] ``` +Extended serialized range syntax is also supported for uncompression and +sequence reference parsing: + +```python +>>> s = uncompress('render.%04d.exr 1001-1010x3', fmt='%h%p%t %x') +>>> print(s.frames()) +[1001, 1004, 1007, 1010] +>>> print(s.format('%h%p%t %x')) +render.%04d.exr 1001-1010x3 +``` + +Supported serialized range forms include: + +- `1001-1100` +- `1001-1100x2` +- `1001-1100x10, 1200, 1300-1320x5` +- `10-1x3` +- `-10--1x3` + ## Production Usage pyseq has been used for many years in production visual effects and animation diff --git a/docs/cli-tools.md b/docs/cli-tools.md index 60fc827..3ceba89 100644 --- a/docs/cli-tools.md +++ b/docs/cli-tools.md @@ -11,6 +11,7 @@ List files and group matching names into sequences. lss tests/files lss -r tests lss tests/files/*.png -f "%l %h%r%t %M" +lss tests/files/*.png -f "%h%p%t %x" ``` ## `stree` @@ -54,6 +55,7 @@ sequence. ```bash scopy input.%04d.exr output/ scopy input.1-100.exr scene.1001-1100.exr +scopy input.%04d.exr\ 1001-1010x2 output/ ``` ## `smv` @@ -63,6 +65,7 @@ Move or rename a sequence, with optional renumbering. ```bash smv old.%04d.exr new.%04d.exr smv old.%04d.exr archive/ --renumber 1001 +smv old.%04d.exr\ 1001-1005x2 new.%04d.exr\ 2001-2003 ``` ## `srm` @@ -71,6 +74,7 @@ Remove a sequence or a frame range embedded in a compressed sequence string. ```bash srm input.1-100.exr +srm input.%04d.exr\ 1001-1010x2 ``` ## Common Pipeline Use Cases diff --git a/docs/examples.md b/docs/examples.md index 7af1e70..8a66719 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -99,6 +99,48 @@ comp.1001.exr comp.1005.exr ``` +## Parse a Stepped Sequence String + +```python +from pyseq import uncompress + +seq = uncompress("render.%04d.exr 1001-1010x3", fmt="%h%p%t %x") + +print(seq.frames()) +print(seq.format("%h%p%t %x")) +``` + +Expected output: + +```text +[1001, 1004, 1007, 1010] +render.%04d.exr 1001-1010x3 +``` + +## Parse Signed Serialized Frame Ranges + +Serialized range strings may include signed frame numbers: + +```python +from pyseq import uncompress + +seq = uncompress("render.%04d.exr -10--1x3", fmt="%h%p%t %x") + +print(seq.frames()) +print(seq.format("%x")) +``` + +Expected output: + +```text +[-10, -7, -4, -1] +-10--1x3 +``` + +This currently applies to serialized range strings passed to `uncompress()` +and the sequence-aware CLI tools. Full on-disk discovery of negative filenames +is a separate follow-up. + ## Resolve a Sequence Pattern on Disk `resolve_sequence` looks up files on disk from a compressed sequence pattern and diff --git a/docs/formatting.md b/docs/formatting.md index afe72b3..23c5da6 100644 --- a/docs/formatting.md +++ b/docs/formatting.md @@ -16,12 +16,35 @@ CLI tools. | `%p` | padding, e.g. %06d | | `%r` | implied range, start-end | | `%R` | explicit broken range, [1-10, 15-20] | +| `%x` | stepped explicit range, 1-10x2, 20 | | `%d` | disk usage | | `%H` | disk usage (human readable) | | `%D` | parent directory | | `%h` | string preceding sequence number | | `%t` | string after the sequence number | +## Serialized Range Syntax + +PySeq supports the following serialized range forms when parsing sequence +strings with `uncompress()` and the sequence-aware CLI tools: + +```text +1001 +1001-1100 +1001-1100x2 +1001-1100x10, 1200, 1300-1320x5 +10-1x3 +-10--1x3 +``` + +Notes: + +- `xN` means "every Nth frame" anchored to the segment start. +- Descending ranges are accepted when parsing, but `Sequence.frames()` and + `%x` formatting normalize frames into ascending order. +- Signed frame numbers are currently documented for serialized range strings. + Full on-disk discovery of negative filenames is a separate follow-up. + ## CLI Examples ```bash @@ -33,6 +56,9 @@ $ lss tests/files/a*.tga -f "%l %h%r%t" $ lss tests/files/a*.tga -f "%l %h%r%t %M" 7 a.1-14.tga [4-9, 11] + +$ python3 -c "import pyseq; s = pyseq.uncompress('render.%04d.exr 1001-1010x3', fmt='%h%p%t %x'); print(s.format('%x'))" +1001-1010x3 ``` ## Python Examples @@ -45,6 +71,7 @@ seq = pyseq.get_sequences("tests/files/a*.tga")[0] print(seq.format("%h%r%t")) print(seq.format("%l %h%r%t")) print(seq.format("%l %h%r%t %M")) +print(pyseq.uncompress("render.%04d.exr 1001-1010x3", fmt="%h%p%t %x").format("%x")) ``` Expected output: @@ -53,4 +80,5 @@ Expected output: a.1-14.tga 7 a.1-14.tga 7 a.1-14.tga [4-9, 11] +1001-1010x3 ``` diff --git a/lib/pyseq/frange.py b/lib/pyseq/frange.py new file mode 100644 index 0000000..5b6bb82 --- /dev/null +++ b/lib/pyseq/frange.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python +# +# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# + +"""Frame range parsing and formatting helpers.""" + +import re +from typing import List, Optional, Tuple + +from pyseq.config import range_join + + +FRAME_RANGE_SEGMENT_RE = re.compile( + r"^\s*(?P-?\d+)(?:\s*-\s*(?P-?\d+)(?:\s*x\s*(?P\d+))?)?\s*$" +) +FRAME_RANGE_TEXT_RE = re.compile( + r"-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?)*" +) +SERIALIZED_RANGE_RE = re.compile( + rf"\[[^\]]+\]|\s+(?:{FRAME_RANGE_TEXT_RE.pattern})\s*$" +) +EMBEDDED_RANGE_RE = re.compile( + rf"^(?P.+?)(?P\[(?:[^\]]+)\]|{FRAME_RANGE_TEXT_RE.pattern})(?P\.[^/\s]+)$" +) + + +def has_serialized_range(text: str) -> bool: + """Return True when text includes explicit serialized frame range syntax.""" + return bool(SERIALIZED_RANGE_RE.search(text)) + + +def split_embedded_frame_range(text: str) -> Optional[Tuple[str, str, str]]: + """Split `headtail` strings like `plate.2-4.exr` into components.""" + match = EMBEDDED_RANGE_RE.match(text) + if not match: + return None + return match.group("head"), match.group("range"), match.group("tail") + + +def parse_frame_range(range_text: str) -> List[int]: + """Parse an extended frame range string into a list of frame numbers.""" + text = range_text.strip() + if not text: + return [] + if text.startswith("[") and text.endswith("]"): + text = text[1:-1].strip() + if not text: + return [] + + frames = [] + for segment in text.split(","): + segment = segment.strip() + if not segment: + raise ValueError(f"Invalid frame range syntax: {range_text}") + match = FRAME_RANGE_SEGMENT_RE.match(segment) + if not match: + raise ValueError(f"Invalid frame range syntax: {segment}") + + start = int(match.group("start")) + end = match.group("end") + step = match.group("step") + + if end is None: + frames.append(start) + continue + + end = int(end) + step = int(step) if step is not None else 1 + if step <= 0: + raise ValueError(f"Frame step must be positive: {segment}") + + direction = 1 if end >= start else -1 + stop = end + direction + frames.extend(range(start, stop, direction * step)) + + return frames + + +def _frame_groups(frames: List[int]): + """Return canonical arithmetic groups for a sorted frame list.""" + unique_frames = sorted(set(frames)) + if not unique_frames: + return [] + if len(unique_frames) == 1: + return [(unique_frames[0], unique_frames[0], None, 1)] + + groups = [] + start = unique_frames[0] + prev = unique_frames[0] + step = None + count = 1 + + for frame in unique_frames[1:]: + diff = frame - prev + if step is None: + step = diff + prev = frame + count += 1 + continue + if diff == step: + prev = frame + count += 1 + continue + groups.append((start, prev, step, count)) + start = prev = frame + step = None + count = 1 + + groups.append((start, prev, step, count)) + return groups + + +def format_frame_range_explicit( + frames: List[int], pad_with_brackets: bool = True +) -> str: + """Format frames as explicit contiguous segments.""" + if not frames: + return "" + + frange = [] + start = end = None + for frame in sorted(set(frames)): + if start is None: + start = end = frame + continue + if frame != end + 1: + if start == end: + frange.append(str(start)) + else: + frange.append(f"{start}-{end}") + start = end = frame + continue + end = frame + + if start is not None: + if start == end: + frange.append(str(start)) + else: + frange.append(f"{start}-{end}") + + body = range_join.join(frange) + return f"[{body}]" if pad_with_brackets else body + + +def format_frame_range_stepped(frames: List[int]) -> str: + """Format frames using canonical stepped-range syntax.""" + if not frames: + return "" + + parts = [] + for start, end, step, count in _frame_groups(frames): + if count == 1: + parts.append(str(start)) + elif step == 1: + parts.append(f"{start}-{end}") + elif count == 2: + parts.extend([str(start), str(end)]) + else: + parts.append(f"{start}-{end}x{step}") + return range_join.join(parts) diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index 2bfc17d..6975eff 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -40,6 +40,7 @@ import warnings from collections import deque from glob import glob, iglob +from itertools import zip_longest from typing import List, Callable, Union from pyseq import config @@ -51,6 +52,11 @@ range_join, strict_pad, ) +from pyseq.frange import ( + format_frame_range_explicit, + format_frame_range_stepped, + parse_frame_range, +) class SequenceError(Exception): @@ -392,6 +398,7 @@ def __attrs__(self): "p": self._get_padding, "r": functools.partial(self._get_framerange, self.frames(), missing=False), "R": functools.partial(self._get_framerange, self.frames(), missing=True), + "x": functools.partial(format_frame_range_stepped, self.frames()), "h": self.head, "t": self.tail, } @@ -514,6 +521,8 @@ def format(self, fmt: str = global_format): +-----------+--------------------------------------+ | ``%R`` | explicit broken range, [1-10, 15-20] | +-----------+--------------------------------------+ + | ``%x`` | stepped explicit range, 1-10x2, 20 | + +-----------+--------------------------------------+ | ``%d`` | disk usage | +-----------+--------------------------------------+ | ``%H`` | disk usage (human readable) | @@ -539,6 +548,7 @@ def format(self, fmt: str = global_format): "p": "s", "r": "s", "R": "s", + "x": "s", "d": "s", "H": "s", "D": "s", @@ -852,9 +862,6 @@ def _get_framerange(self, frames: List[int], missing: bool = True): :return: Formatted frame range string. """ - frange = [] - start = "" - end = "" if not missing: if frames: return "%s-%s" % (self.start(), self.end()) @@ -864,28 +871,22 @@ def _get_framerange(self, frames: List[int], missing: bool = True): if not frames: return "" - for i in range(0, len(frames)): - frame = frames[i] + frange = [] + expanded = [] + saw_ranges = False + for frame in frames: if isinstance(frame, range): + saw_ranges = True if frame.start != frame.stop: frange.append("%s-%s" % (frame.start, frame.stop - 1)) continue - prev = frames[i - 1] - if i != 0 and frame != prev + 1: - if start != end: - frange.append("%s-%s" % (str(start), str(end))) - elif start == end: - frange.append(str(start)) - start = end = frame - continue - if start == "" or int(start) > frame: - start = frame - if end == "" or int(end) < frame: - end = frame - if start == end: - frange.append(str(start)) - else: - frange.append("%s-%s" % (str(start), str(end))) + expanded.append(frame) + + explicit = format_frame_range_explicit(expanded, pad_with_brackets=False) + if explicit: + frange.extend(explicit.split(range_join)) + if saw_ranges: + frange.append("") return "[%s]" % range_join.join(frange) def _get_frames(self): @@ -1004,8 +1005,9 @@ def uncompress(seq_string: str, fmt: str = global_format): "l": r"\d+", "h": r"(.+)?", "t": r"(\S+)?", - "r": r"\d+-\d+", - "R": r"\[[\d\s?\-%s?]+\]" % re.escape(range_join), + "r": r"-?\d+\s*-\s*-?\d+(?:\s*x\s*\d+)?", + "R": r"\[[^\]]+\]", + "x": r"-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?)*", "p": r"%\d+d", "m": r"\[.*\]", "f": r"\[.*\]", @@ -1026,6 +1028,7 @@ def uncompress(seq_string: str, fmt: str = global_format): match = regex.match(name) frames = [] + frame_values = [] missing = [] s = None e = None @@ -1041,36 +1044,31 @@ def uncompress(seq_string: str, fmt: str = global_format): try: R = match.group("R") - R = R[1:-1] - number_groups = R.split(range_join) - pad_len = 0 - for number_group in number_groups: - if "-" in number_group: - splits = number_group.split("-") - pad_len = max(pad_len, len(splits[0]), len(splits[1])) - start = int(splits[0]) - end = int(splits[1]) - frames.extend(range(start, end + 1)) - - else: - end = int(number_group) - pad_len = max(pad_len, len(number_group)) - frames.append(end) + frame_values = parse_frame_range(R) + pad_len = max((len(str(abs(frame))) for frame in frame_values), default=0) if pad == "%d" and pad_len != 0: pad = "%0" + str(pad_len) + "d" except IndexError: try: r = match.group("r") - s, e = r.split("-") - frames = range(int(s), int(e) + 1) + frame_values = parse_frame_range(r) + if frame_values: + s = frame_values[0] + e = frame_values[-1] except IndexError: - s = match.group("s") - e = match.group("e") + try: + x = match.group("x") + frame_values = parse_frame_range(x) + except IndexError: + s = match.group("s") + e = match.group("e") + if s is not None and e is not None: + frame_values = parse_frame_range(f"{s}-{e}") try: - frames = eval(match.group("f")) + frame_values = eval(match.group("f")) except IndexError: pass @@ -1082,30 +1080,44 @@ def uncompress(seq_string: str, fmt: str = global_format): pass items = [] + emitted_frames = [] if missing: - for i in range(int(s), int(e) + 1): + for i in parse_frame_range(f"{s}-{e}"): if i in missing: continue - f = pad % i + f = ("-" if i < 0 else "") + (pad % abs(i)) name = "%s%s%s" % ( match.groupdict().get("h", ""), f, match.groupdict().get("t", ""), ) items.append(Item(os.path.join(dirname, name))) + emitted_frames.append(i) else: - for i in frames: - f = pad % i + for i in frame_values: + f = ("-" if i < 0 else "") + (pad % abs(i)) name = "%s%s%s" % ( match.groupdict().get("h", ""), f, match.groupdict().get("t", ""), ) items.append(Item(os.path.join(dirname, name))) + emitted_frames.append(i) seqs = get_sequences(items) if seqs: + if emitted_frames: + seq = seqs[0] + for item, frame in zip_longest(seq, emitted_frames): + if item is None or frame is None: + break + item.frame = frame + item.head = match.groupdict().get("h", "") + item.tail = match.groupdict().get("t", "") + item.pad = 0 if pad == "%d" else int(re.search(r"\d+", pad).group()) + seq._Sequence__frames = sorted(emitted_frames) + seq._Sequence__missing = None return seqs[0] return seqs diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index 442d317..5dfe15a 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -40,6 +40,11 @@ import pyseq from pyseq.config import range_join +from pyseq.frange import ( + has_serialized_range, + parse_frame_range, + split_embedded_frame_range, +) def deprecated(func): @@ -203,57 +208,62 @@ def parse_explicit_sequence_string(reference: str): """Parse a serialized sequence string, including embedded range syntax.""" dirname = os.path.dirname(reference) or "." basename = os.path.basename(reference) + has_explicit_range = has_serialized_range(basename) - embedded = re.match( - r"^(?P.+?)(?P\[(?:[^\]]+)\]|\d+-\d+)(?P\.[^/\s]+)$", - basename, + # Plain compressed sequence patterns like "file.%04d.exr" should be + # resolved against disk, not reinterpreted as explicit serialized ranges. + if is_compressed_format_string(basename) and not has_explicit_range: + return None + + embedded = ( + None + if is_compressed_format_string(basename) + else split_embedded_frame_range(basename) ) if embedded: - range_text = embedded.group("range") - frames = [] - if range_text.startswith("["): - for number_group in range_text[1:-1].split(range_join): - number_group = number_group.strip() - if not number_group: - continue - if "-" in number_group: - start, end = number_group.split("-", 1) - frames.extend(range(int(start), int(end) + 1)) - else: - frames.append(int(number_group)) - else: - start, end = range_text.split("-", 1) - frames = list(range(int(start), int(end) + 1)) + head, range_text, tail = embedded + frames = parse_frame_range(range_text) items = [ pyseq.Item( os.path.join( dirname, - f"{embedded.group('head')}{frame}{embedded.group('tail')}", + f"{head}{frame}{tail}", ) ) for frame in frames ] sequences = pyseq.get_sequences(items) if sequences: + seq = sequences[0] + for item, frame in zip(seq, frames): + item.frame = frame + item.head = head + item.tail = tail + item.pad = 0 + seq._Sequence__frames = sorted(frames) + seq._Sequence__missing = None return { - "seq": sequences[0], + "seq": seq, "has_pad": False, } - formats = ( - ("%h%p%t %R", True), - ("%h%p%t %r", True), - ("%h%R%t", False), - ("%h%r%t", False), - ) - for fmt, has_pad in formats: - seq = pyseq.uncompress(reference, fmt=fmt) - if seq: - return { - "seq": seq, - "has_pad": has_pad, - } + if has_explicit_range: + formats = ( + ("%h%p%t %x", True), + ("%h%p%t %R", True), + ("%h%p%t %r", True), + ("%h%x%t", False), + ("%h%R%t", False), + ("%h%r%t", False), + ) + for fmt, has_pad in formats: + seq = pyseq.uncompress(reference, fmt=fmt) + if seq: + return { + "seq": seq, + "has_pad": has_pad, + } return None diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index b164b9a..16fd11c 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -512,6 +512,26 @@ def test_uncompress_is_working_properly_8(self): self.assertEqual(96, len(seq8)) + def test_uncompress_extended_range_with_step(self): + seq = uncompress("render.%04d.exr 1001-1010x3", fmt="%h%p%t %x") + self.assertEqual(seq.frames(), [1001, 1004, 1007, 1010]) + self.assertEqual(seq.format("%x"), "1001-1010x3") + + def test_uncompress_extended_range_negative_frames(self): + seq = uncompress("render.%04d.exr -10--1x3", fmt="%h%p%t %x") + self.assertEqual(seq.frames(), [-10, -7, -4, -1]) + self.assertEqual(seq.format("%h%p%t %x"), "render.%04d.exr -10--1x3") + + def test_uncompress_extended_range_descending(self): + seq = uncompress("render.%04d.exr 10-1x3", fmt="%h%p%t %x") + self.assertEqual(seq.frames(), [1, 4, 7, 10]) + self.assertEqual(seq.format("%x"), "1-10x3") + + def test_format_extended_range_multiple_segments(self): + seq = uncompress("render.%04d.exr 1-10x3, 20-30x5, 42", fmt="%h%p%t %x") + self.assertEqual(seq.frames(), [1, 4, 7, 10, 20, 25, 30, 42]) + self.assertEqual(seq.format("%x"), "1-10x3, 20-30x5, 42") + def test_get_sequences_is_working_properly_1(self): """testing if get_sequences is working properly""" seqs = get_sequences("./files/") @@ -615,6 +635,7 @@ class PerformanceTests(unittest.TestCase): def test_performance_1(self): """tests performance for single 10k frame sequence""" + pyseq.strict_pad = False files = ["file.%03d.jpg" % i for i in range(1, 10000)] s = time.time() seq = Sequence(files) From 688e7fcc764213574d939606df6de8c55d2b99fe Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 20 Jul 2026 20:07:45 -0700 Subject: [PATCH 02/26] Adds small stepped on-disk fixture in tests/files --- lib/pyseq/util.py | 13 ++++++++++++- tests/files/stepA.1001.exr | 1 + tests/files/stepA.1004.exr | 1 + tests/files/stepA.1007.exr | 1 + tests/files/stepA.1010.exr | 1 + tests/test_pyseq.py | 20 ++++++++++++++++++++ 6 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/files/stepA.1001.exr create mode 100644 tests/files/stepA.1004.exr create mode 100644 tests/files/stepA.1007.exr create mode 100644 tests/files/stepA.1010.exr diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index 5dfe15a..1d27f87 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -201,7 +201,18 @@ def subset_sequence(seq, frames): sequences = pyseq.get_sequences(items) if not sequences: raise ValueError("No valid sequence found for requested frame subset") - return sequences[0] + + subset = sequences[0] + subset._Sequence__frames = sorted(frame_set) + subset._Sequence__missing = None + + # Preserve source sequence formatting metadata for resolved subsets. + for item in subset: + item.head = seq.head() + item.tail = seq.tail() + item.pad = seq.pad + + return subset def parse_explicit_sequence_string(reference: str): diff --git a/tests/files/stepA.1001.exr b/tests/files/stepA.1001.exr new file mode 100644 index 0000000..715b1fd --- /dev/null +++ b/tests/files/stepA.1001.exr @@ -0,0 +1 @@ +dummy frame 1001 diff --git a/tests/files/stepA.1004.exr b/tests/files/stepA.1004.exr new file mode 100644 index 0000000..6544903 --- /dev/null +++ b/tests/files/stepA.1004.exr @@ -0,0 +1 @@ +dummy frame 1004 diff --git a/tests/files/stepA.1007.exr b/tests/files/stepA.1007.exr new file mode 100644 index 0000000..f3912ce --- /dev/null +++ b/tests/files/stepA.1007.exr @@ -0,0 +1 @@ +dummy frame 1007 diff --git a/tests/files/stepA.1010.exr b/tests/files/stepA.1010.exr new file mode 100644 index 0000000..d7bfe70 --- /dev/null +++ b/tests/files/stepA.1010.exr @@ -0,0 +1 @@ +dummy frame 1010 diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index 16fd11c..b581a2a 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -46,6 +46,7 @@ from pyseq import Item, Sequence, diff, uncompress, get_sequences from pyseq import SequenceError from pyseq import seq as pyseq +from pyseq.util import resolve_sequence_reference pyseq.default_format = "%h%r%t" @@ -580,6 +581,24 @@ def test_get_sequences_is_working_properly_3(self): for seq, expected_result in zip(seqs, expected_results): self.assertEqual(expected_result, seq.format("%h%p%t %r")) + def test_resolve_sequence_reference_with_stepped_serialized_range(self): + pyseq.strict_pad = False + reference = "./tests/files/stepA.%04d.exr 1001-1010x3" + seq, dirname = resolve_sequence_reference(reference) + + self.assertEqual(dirname, "./tests/files") + self.assertEqual(seq.frames(), [1001, 1004, 1007, 1010]) + self.assertEqual(seq.format("%h%p%t %x"), "stepA.%d.exr 1001-1010x3") + + def test_resolve_sequence_reference_with_stepped_embedded_range(self): + pyseq.strict_pad = False + reference = "./tests/files/stepA.1001-1010x3.exr" + seq, dirname = resolve_sequence_reference(reference) + + self.assertEqual(dirname, "./tests/files") + self.assertEqual(seq.frames(), [1001, 1004, 1007, 1010]) + self.assertEqual(seq.format("%h%p%t %x"), "stepA.%d.exr 1001-1010x3") + class LSSTestCase(unittest.TestCase): """Tests lss command""" @@ -622,6 +641,7 @@ def test_lss_is_working_properly_1(self): 3 fileA.%04d.jpg [1-3] 3 fileA.%04d.png [1-3] 1 file_02.tif + 4 stepA.%d.exr [1001, 1004, 1007, 1010] 4 z1_001_v1.%d.png [1-4] 4 z1_002_v1.%d.png [1-4] 4 z1_002_v2.%d.png [9-12] From 98cf95284a19b08bb5291f3d40fc94d608b6c2a9 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 20 Jul 2026 20:23:22 -0700 Subject: [PATCH 03/26] Add coarse regression performance test --- tests/test_pyseq.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index b581a2a..8e6af36 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -668,6 +668,25 @@ def test_performance_1(self): # slower CI runners or across Python versions. self.assertLess(total_time, 0.5) + def test_performance_sparse_large_missing_range(self): + """tests sparse large ranges stay fast and do not eagerly expand.""" + pyseq.strict_pad = False + files = ["image-1.jpg", "image-1000.jpg", "image-50000000.jpg"] + + start = time.perf_counter() + seq = get_sequences(files)[0] + missing = seq._get_missing() + formatted = seq.format("%M") + elapsed = time.perf_counter() - start + + self.assertEqual(seq.frames(), [1, 1000, 50000000]) + self.assertEqual(len(missing), 2) + self.assertEqual(formatted, "[2-999, 1001-49999999, ]") + # This threshold is intentionally generous: the goal is to catch + # catastrophic regressions like eager huge-range expansion, not to + # micro-benchmark CI machines. + self.assertLess(elapsed, 1.0) + class TestIssues(unittest.TestCase): """tests reported issues on github""" From 08aeeebf9d69ee36a553c870b11c59924c052ffd Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 20 Jul 2026 20:28:50 -0700 Subject: [PATCH 04/26] Add performance module with three coarse regression tests --- tests/test_performance.py | 65 +++++++++++++++++++++++++++++++++++++++ tests/test_pyseq.py | 39 ----------------------- 2 files changed, 65 insertions(+), 39 deletions(-) create mode 100644 tests/test_performance.py diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..32a4216 --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# +# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# + +"""Performance regression tests for pyseq.""" + +import time +import unittest + +from pyseq import Sequence, get_sequences, uncompress +from pyseq import seq as pyseq + + +class PerformanceTests(unittest.TestCase): + """Coarse-grained performance regression tests.""" + + def test_sequence_construction_10k_frames(self): + """Single large contiguous sequences should remain fast.""" + pyseq.strict_pad = False + files = ["file.%03d.jpg" % i for i in range(1, 10000)] + + start = time.perf_counter() + seq = Sequence(files) + elapsed = time.perf_counter() - start + + self.assertEqual(str(seq), "file.1-9999.jpg") + self.assertEqual(len(seq), 9999) + self.assertLess(elapsed, 0.5) + + def test_sparse_large_missing_range(self): + """Sparse huge ranges should not trigger catastrophic expansion.""" + pyseq.strict_pad = False + files = ["image-1.jpg", "image-1000.jpg", "image-50000000.jpg"] + + start = time.perf_counter() + seq = get_sequences(files)[0] + missing = seq._get_missing() + formatted = seq.format("%M") + elapsed = time.perf_counter() - start + + self.assertEqual(seq.frames(), [1, 1000, 50000000]) + self.assertEqual(len(missing), 2) + self.assertEqual(formatted, "[2-999, 1001-49999999, ]") + # Keep this intentionally loose so we catch catastrophic regressions + # without making CI timing-sensitive. + self.assertLess(elapsed, 1.0) + + def test_stepped_range_parse_and_format(self): + """Medium-sized stepped ranges should parse and format quickly.""" + pyseq.strict_pad = False + + start = time.perf_counter() + seq = uncompress("render.%04d.exr 1001-10000x3", fmt="%h%p%t %x") + rendered = seq.format("%x") + elapsed = time.perf_counter() - start + + self.assertEqual(seq.frames()[0], 1001) + self.assertEqual(seq.frames()[-1], 9998) + self.assertEqual(rendered, "1001-9998x3") + self.assertLess(elapsed, 0.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index 8e6af36..446196c 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -39,7 +39,6 @@ import unittest import subprocess import sys -import time sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from conftest import get_installed_command @@ -650,44 +649,6 @@ def test_lss_is_working_properly_1(self): ) -class PerformanceTests(unittest.TestCase): - """tests the performance of pyseq""" - - def test_performance_1(self): - """tests performance for single 10k frame sequence""" - pyseq.strict_pad = False - files = ["file.%03d.jpg" % i for i in range(1, 10000)] - s = time.time() - seq = Sequence(files) - e = time.time() - total_time = e - s - print("time taken to create sequence: %s" % (total_time)) - self.assertEqual(str(seq), "file.1-9999.jpg") - self.assertEqual(len(seq), 9999) - # Keep a loose upper bound so this stays meaningful without flaking on - # slower CI runners or across Python versions. - self.assertLess(total_time, 0.5) - - def test_performance_sparse_large_missing_range(self): - """tests sparse large ranges stay fast and do not eagerly expand.""" - pyseq.strict_pad = False - files = ["image-1.jpg", "image-1000.jpg", "image-50000000.jpg"] - - start = time.perf_counter() - seq = get_sequences(files)[0] - missing = seq._get_missing() - formatted = seq.format("%M") - elapsed = time.perf_counter() - start - - self.assertEqual(seq.frames(), [1, 1000, 50000000]) - self.assertEqual(len(missing), 2) - self.assertEqual(formatted, "[2-999, 1001-49999999, ]") - # This threshold is intentionally generous: the goal is to catch - # catastrophic regressions like eager huge-range expansion, not to - # micro-benchmark CI machines. - self.assertLess(elapsed, 1.0) - - class TestIssues(unittest.TestCase): """tests reported issues on github""" From 1c248e902eede480011111e988c8797bcf157c64 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Tue, 21 Jul 2026 04:55:52 -0700 Subject: [PATCH 05/26] Adds benchmarks workflow and scripts --- .github/workflows/benchmarks.yml | 69 ++++++++++++ docs/README.md | 1 + docs/performance.md | 178 ++++++++++++++++++++++++++++++ scripts/benchmarks_cli.py | 184 +++++++++++++++++++++++++++++++ scripts/benchmarks_core.py | 176 +++++++++++++++++++++++++++++ 5 files changed, 608 insertions(+) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 docs/performance.md create mode 100644 scripts/benchmarks_cli.py create mode 100644 scripts/benchmarks_core.py diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..7ce3416 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,69 @@ +name: benchmarks + +on: + workflow_dispatch: + inputs: + profile: + description: Benchmark profile to run + required: false + default: smoke + type: choice + options: + - smoke + - full + schedule: + - cron: "0 9 * * 1" + +jobs: + benchmark: + runs-on: ubuntu-24.04 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install -e ".[dev]" + + - name: Select profile + id: profile + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "value=${{ inputs.profile }}" >> "$GITHUB_OUTPUT" + else + echo "value=smoke" >> "$GITHUB_OUTPUT" + fi + + - name: Run core benchmarks + run: | + python scripts/benchmarks_core.py \ + --profile "${{ steps.profile.outputs.value }}" \ + --json benchmark-core.json + + - name: Run CLI benchmarks + run: | + python scripts/benchmarks_cli.py \ + --profile "${{ steps.profile.outputs.value }}" \ + --json benchmark-cli.json + + - name: Write benchmark summary + run: | + python scripts/benchmarks_core.py --profile "${{ steps.profile.outputs.value }}" --summary >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + python scripts/benchmarks_cli.py --profile "${{ steps.profile.outputs.value }}" --summary >> "$GITHUB_STEP_SUMMARY" + + - name: Upload benchmark artifacts + uses: actions/upload-artifact@v4 + with: + name: benchmark-results-${{ steps.profile.outputs.value }} + path: | + benchmark-core.json + benchmark-cli.json diff --git a/docs/README.md b/docs/README.md index 513d9e8..ed6287a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ Available guides: - [Setup and Distribution](setup-and-distribution.md) - [Formatting Reference](formatting.md) - [Frame Patterns](frame-patterns.md) +- [Performance](performance.md) If you are evaluating pyseq for pipeline use, start with the examples guide and then review the CLI reference for the sequence-aware utilities included with diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..e580540 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,178 @@ +# Performance + +PySeq's performance matters most in sequence discovery, range formatting, and +CLI workflows over large file sets. + +This project uses two complementary approaches: + +- coarse regression tests in `tests/test_performance.py` +- repeatable benchmark scripts under `scripts/` + +The regression tests should stay stable and fast enough for normal CI. The +benchmark scripts are for collecting timing data over time, not for enforcing +fragile micro-optimizations in every pull request. + +## Benchmark Scripts + +Current benchmark entry points: + +- `scripts/benchmarks_core.py` +- `scripts/benchmarks_cli.py` + +Both scripts support: + +- `--profile smoke` +- `--profile full` +- `--json ` +- `--summary` + +Examples: + +```bash +python scripts/benchmarks_core.py --profile smoke --summary +python scripts/benchmarks_core.py --profile full --json core-benchmarks.json +python scripts/benchmarks_cli.py --profile smoke --summary +``` + +## Result Format + +Both benchmark scripts can emit machine-readable JSON for CI artifacts, +historical snapshots, or local comparison. + +Example core benchmark payload: + +```json +{ + "benchmarks": { + "sequence_construct_1000": { + "iterations": 5, + "max_s": 0.043952, + "median_s": 0.041054, + "min_s": 0.037981 + }, + "uncompress_stepped_10k": { + "iterations": 5, + "max_s": 0.153435, + "median_s": 0.132808, + "min_s": 0.130189 + } + }, + "kind": "core", + "platform": "Linux-6.x-x86_64", + "profile": "smoke", + "python": "3.11.x", + "timestamp_utc": "2026-07-21T00:00:00+00:00" +} +``` + +Example CLI benchmark payload: + +```json +{ + "benchmarks": { + "lss_recursive": { + "iterations": 3, + "max_s": 0.110399, + "median_s": 0.103545, + "min_s": 0.101037 + }, + "sfind_png": { + "iterations": 3, + "max_s": 0.095798, + "median_s": 0.093574, + "min_s": 0.093541 + } + }, + "kind": "cli", + "platform": "Linux-6.x-x86_64", + "profile": "smoke", + "python": "3.11.x", + "timestamp_utc": "2026-07-21T00:00:00+00:00" +} +``` + +Field notes: + +- `kind` distinguishes library benchmarks from CLI benchmarks. +- `profile` indicates whether the run used the `smoke` or `full` dataset. +- `median_s` is the primary comparison value. +- `min_s` and `max_s` help show runner noise and spread. +- `timestamp_utc`, `python`, and `platform` are useful when comparing + artifacts across runs. + +## What We Measure + +### Core library benchmarks + +- `Sequence(...)` construction at fixed scales +- `get_sequences(...)` on mixed file lists +- `uncompress(...)` for contiguous and stepped ranges +- `%M` formatting on sparse huge ranges +- stepped range parsing and formatting +- `resolve_sequence_reference(...)` for stepped serialized references + +### CLI benchmarks + +- `lss` +- `stree` +- `sfind` +- `sdiff` +- `sstat` + +CLI benchmarks intentionally include subprocess overhead because that is part +of the real user-facing cost. + +## Profiles + +### `smoke` + +Use this in normal CI and pull-request workflows. + +Goals: + +- catch catastrophic regressions +- keep runtime short +- publish comparable results + +### `full` + +Use this for scheduled runs or manual benchmarking. + +Goals: + +- collect more stable medians +- exercise larger synthetic datasets +- provide release-readiness data + +## Methodology + +The benchmark scripts: + +- generate synthetic datasets in memory or temporary directories +- use `time.perf_counter()` +- warm up once before measuring +- collect several samples +- report median, min, and max + +This keeps the results useful without pretending that a shared CI runner is a +perfect benchmarking machine. + +## Interpreting Results + +Treat GitHub-hosted benchmark numbers as trend indicators, not as precise +absolute truth. + +Good uses: + +- spotting order-of-magnitude regressions +- comparing broad trends on the same workflow +- attaching timing evidence to performance-focused pull requests + +Less reliable uses: + +- failing builds on small percentage differences +- comparing results across different operating systems or Python versions +- drawing conclusions from a single run + +If stronger consistency becomes important, prefer a self-hosted benchmark +runner with a stable machine configuration. diff --git a/scripts/benchmarks_cli.py b/scripts/benchmarks_cli.py new file mode 100644 index 0000000..7691c7c --- /dev/null +++ b/scripts/benchmarks_cli.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# + +"""CLI benchmarks for pyseq console tools.""" + +import argparse +import json +import os +import platform +import shutil +import statistics +import subprocess +import sys +import sysconfig +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path + + +PROFILE_CONFIG = { + "smoke": {"iterations": 3, "file_count": 200}, + "full": {"iterations": 5, "file_count": 1000}, +} + + +CLI_MODULES = { + "lss": "pyseq.lss", + "stree": "pyseq.stree", + "sfind": "pyseq.sfind", + "sdiff": "pyseq.sdiff", + "sstat": "pyseq.sstat", +} + + +def resolve_command(name): + scripts_dir = sysconfig.get_path("scripts") + candidates = [name] + if os.name == "nt": + candidates = [f"{name}.exe", f"{name}.cmd", f"{name}.bat", name] + + for candidate in candidates: + path = os.path.join(scripts_dir, candidate) + if os.path.exists(path): + return [path] + + path = shutil.which(name) + if path: + return [path] + + return [sys.executable, "-m", CLI_MODULES[name]] + + +def measure(func, iterations, warmups=1): + for _ in range(warmups): + func() + + samples = [] + for _ in range(iterations): + start = time.perf_counter() + func() + samples.append(time.perf_counter() - start) + + return { + "median_s": statistics.median(samples), + "min_s": min(samples), + "max_s": max(samples), + "iterations": iterations, + } + + +def run_cli(command, *args): + cmd = resolve_command(command) + list(args) + subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def create_fixture_tree(root, file_count): + nested = root / "nested" / "plates" + nested.mkdir(parents=True, exist_ok=True) + + for frame in range(1, file_count + 1): + (root / f"renderA.{frame:04d}.exr").write_text("frame\n") + (root / f"renderB.{frame:04d}.exr").write_text("frame\n") + + for frame in range(1, max(10, file_count // 10) + 1): + (nested / f"plate.{frame:04d}.png").write_text("frame\n") + + (root / "notes.txt").write_text("not a sequence\n") + + +def run_profile(profile): + config = PROFILE_CONFIG[profile] + results = {} + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + create_fixture_tree(root, config["file_count"]) + + seq_a = str(root / "renderA.%04d.exr") + seq_b = str(root / "renderB.%04d.exr") + + results["lss_recursive"] = measure( + lambda: run_cli("lss", "-r", str(root)), + config["iterations"], + ) + results["lss_format_x"] = measure( + lambda: run_cli("lss", str(root), "-f", "%h%x%t"), + config["iterations"], + ) + results["stree"] = measure( + lambda: run_cli("stree", str(root)), + config["iterations"], + ) + results["sfind_png"] = measure( + lambda: run_cli("sfind", str(root), "-name", "*.png"), + config["iterations"], + ) + results["sdiff"] = measure( + lambda: run_cli("sdiff", seq_a, seq_b), + config["iterations"], + ) + results["sstat"] = measure( + lambda: run_cli("sstat", seq_a), + config["iterations"], + ) + + return results + + +def build_payload(profile): + return { + "kind": "cli", + "profile": profile, + "python": platform.python_version(), + "platform": platform.platform(), + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "benchmarks": run_profile(profile), + } + + +def write_summary(payload, stream): + print(f"# pyseq CLI benchmarks ({payload['profile']})", file=stream) + print("", file=stream) + print("| Benchmark | Median (s) | Min (s) | Max (s) |", file=stream) + print("| --- | ---: | ---: | ---: |", file=stream) + for name, result in payload["benchmarks"].items(): + print( + f"| `{name}` | {result['median_s']:.6f} | {result['min_s']:.6f} | {result['max_s']:.6f} |", + file=stream, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", choices=sorted(PROFILE_CONFIG), default="smoke") + parser.add_argument("--json", dest="json_path") + parser.add_argument("--summary", action="store_true") + args = parser.parse_args() + + payload = build_payload(args.profile) + + if args.json_path: + with open(args.json_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + + if args.summary: + write_summary(payload, sys.stdout) + + if not args.json_path and not args.summary: + json.dump(payload, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmarks_core.py b/scripts/benchmarks_core.py new file mode 100644 index 0000000..03996b6 --- /dev/null +++ b/scripts/benchmarks_core.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# + +"""Core library benchmarks for pyseq.""" + +import argparse +import json +import platform +import statistics +import sys +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path + +from pyseq import Sequence, get_sequences, uncompress +from pyseq import seq as pyseq +from pyseq.util import resolve_sequence_reference + + +PROFILE_CONFIG = { + "smoke": { + "iterations": 5, + "sizes": [100, 1000, 10000], + "mixed_factor": 2, + }, + "full": { + "iterations": 9, + "sizes": [100, 1000, 10000], + "mixed_factor": 3, + }, +} + + +def measure(func, iterations, warmups=1): + """Return timing statistics for a benchmark callable.""" + for _ in range(warmups): + func() + + samples = [] + for _ in range(iterations): + start = time.perf_counter() + func() + samples.append(time.perf_counter() - start) + + return { + "median_s": statistics.median(samples), + "min_s": min(samples), + "max_s": max(samples), + "iterations": iterations, + } + + +def contiguous_files(count): + return [f"render.{frame:04d}.exr" for frame in range(1, count + 1)] + + +def mixed_files(count, factor): + items = contiguous_files(count) + extras = [f"note_{index:05d}.txt" for index in range(count * (factor - 1))] + return sorted(items + extras) + + +def stepped_range_string(start, stop, step): + return f"render.%04d.exr {start}-{stop}x{step}" + + +def run_profile(profile): + config = PROFILE_CONFIG[profile] + pyseq.strict_pad = False + results = {} + + for size in config["sizes"]: + results[f"sequence_construct_{size}"] = measure( + lambda size=size: Sequence(contiguous_files(size)), + config["iterations"], + ) + + results[f"get_sequences_mixed_{size}"] = measure( + lambda size=size: get_sequences(mixed_files(size, config["mixed_factor"])), + config["iterations"], + ) + + results[f"uncompress_range_{size}"] = measure( + lambda size=size: uncompress( + f"render.%04d.exr 1001-{1000 + size}", fmt="%h%p%t %r" + ), + config["iterations"], + ) + + results["format_missing_sparse_large"] = measure( + lambda: get_sequences(["image-1.jpg", "image-1000.jpg", "image-50000000.jpg"])[ + 0 + ].format("%M"), + config["iterations"], + ) + + results["uncompress_stepped_10k"] = measure( + lambda: uncompress(stepped_range_string(1001, 10000, 3), fmt="%h%p%t %x"), + config["iterations"], + ) + + results["format_stepped_10k"] = measure( + lambda: uncompress(stepped_range_string(1001, 10000, 3), fmt="%h%p%t %x").format( + "%x" + ), + config["iterations"], + ) + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + for frame in (1001, 1004, 1007, 1010): + (tmp / f"stepA.{frame:04d}.exr").write_text("frame\n") + + results["resolve_sequence_reference_stepped"] = measure( + lambda: resolve_sequence_reference( + str(tmp / "stepA.%04d.exr") + " 1001-1010x3" + ), + config["iterations"], + ) + + return results + + +def build_payload(profile): + return { + "kind": "core", + "profile": profile, + "python": platform.python_version(), + "platform": platform.platform(), + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "benchmarks": run_profile(profile), + } + + +def write_summary(payload, stream): + print( + f"# pyseq core benchmarks ({payload['profile']})", + file=stream, + ) + print("", file=stream) + print("| Benchmark | Median (s) | Min (s) | Max (s) |", file=stream) + print("| --- | ---: | ---: | ---: |", file=stream) + for name, result in payload["benchmarks"].items(): + print( + f"| `{name}` | {result['median_s']:.6f} | {result['min_s']:.6f} | {result['max_s']:.6f} |", + file=stream, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", choices=sorted(PROFILE_CONFIG), default="smoke") + parser.add_argument("--json", dest="json_path") + parser.add_argument("--summary", action="store_true") + args = parser.parse_args() + + payload = build_payload(args.profile) + + if args.json_path: + with open(args.json_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + + if args.summary: + write_summary(payload, sys.stdout) + + if not args.json_path and not args.summary: + json.dump(payload, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() From be5a89914b988d6d5cd8c9bbbbb0d99eaac78fdd Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Tue, 21 Jul 2026 05:37:21 -0700 Subject: [PATCH 06/26] Add benchmark tooling and GitHub Pages docs publishing workflow --- .github/workflows/publish-docs.yml | 75 ++++++++++++++ scripts/benchmarks_core.py | 6 +- scripts/build_pages_site.py | 155 +++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/publish-docs.yml create mode 100644 scripts/build_pages_site.py diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 0000000..b811271 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,75 @@ +name: publish-docs + +on: + push: + branches: + - master + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install -e ".[dev]" + + - name: Generate benchmark reports + run: | + python scripts/benchmarks_core.py --profile smoke --json benchmark-core.json --summary > benchmark-core.md + python scripts/benchmarks_cli.py --profile smoke --json benchmark-cli.json --summary > benchmark-cli.md + + - name: Build Jekyll source tree + run: | + python scripts/build_pages_site.py \ + --output _site_src \ + --core-summary benchmark-core.md \ + --cli-summary benchmark-cli.md \ + --core-json benchmark-core.json \ + --cli-json benchmark-cli.json + + - name: Build site with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: _site_src + destination: _site + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-24.04 + needs: build + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/scripts/benchmarks_core.py b/scripts/benchmarks_core.py index 03996b6..a816dc0 100644 --- a/scripts/benchmarks_core.py +++ b/scripts/benchmarks_core.py @@ -103,9 +103,9 @@ def run_profile(profile): ) results["format_stepped_10k"] = measure( - lambda: uncompress(stepped_range_string(1001, 10000, 3), fmt="%h%p%t %x").format( - "%x" - ), + lambda: uncompress( + stepped_range_string(1001, 10000, 3), fmt="%h%p%t %x" + ).format("%x"), config["iterations"], ) diff --git a/scripts/build_pages_site.py b/scripts/build_pages_site.py new file mode 100644 index 0000000..01e56ae --- /dev/null +++ b/scripts/build_pages_site.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# + +"""Build a simple Jekyll-friendly docs site from repository markdown files.""" + +import argparse +import re +import shutil +from pathlib import Path + + +LINK_PATTERNS = ( + (r"\(README\.md\)", "(index.html)"), + (r"\(docs/README\.md\)", "(docs/index.html)"), + (r"\(docs/([^)]+)\.md\)", r"(docs/\1.html)"), + (r"\(([^:)#]+)\.md\)", r"(\1.html)"), +) + + +def rewrite_links(content: str) -> str: + """Rewrite local markdown links for generated HTML output.""" + updated = content + for pattern, replacement in LINK_PATTERNS: + updated = re.sub(pattern, replacement, updated) + return updated + + +def extract_title(content: str, fallback: str) -> str: + """Extract the first markdown H1 title or use a fallback.""" + for line in content.splitlines(): + if line.startswith("# "): + return line[2:].strip() + return fallback + + +def wrap_markdown(content: str, title: str) -> str: + """Add minimal Jekyll front matter to markdown content.""" + return f"---\nlayout: default\ntitle: {title}\n---\n\n{content}" + + +def write_markdown_page(src: Path, dst: Path, fallback_title: str): + """Copy a markdown file into the site tree with front matter and fixed links.""" + content = src.read_text(encoding="utf-8") + title = extract_title(content, fallback_title) + content = rewrite_links(content) + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(wrap_markdown(content, title), encoding="utf-8") + + +def write_site_config(output_dir: Path): + """Write a minimal Jekyll config file.""" + config = """title: pyseq +description: Python library for numbered file sequences +theme: minima +markdown: kramdown +permalink: pretty +""" + (output_dir / "_config.yml").write_text(config, encoding="utf-8") + + +def write_benchmarks_page( + output_dir: Path, + core_summary: Path = None, + cli_summary: Path = None, + core_json: Path = None, + cli_json: Path = None, +): + """Create a latest benchmark report page and copy JSON artifacts.""" + benchmarks_dir = output_dir / "benchmarks" + benchmarks_dir.mkdir(parents=True, exist_ok=True) + + sections = [ + "# Latest Benchmarks", + "", + "This page is generated automatically from the benchmark workflows.", + "", + ] + + if core_summary and core_summary.exists(): + sections.extend([core_summary.read_text(encoding="utf-8").strip(), ""]) + + if cli_summary and cli_summary.exists(): + sections.extend([cli_summary.read_text(encoding="utf-8").strip(), ""]) + + downloads = [] + if core_json and core_json.exists(): + shutil.copy2(core_json, benchmarks_dir / "benchmark-core.json") + downloads.append("- [Core benchmark JSON](benchmark-core.json)") + if cli_json and cli_json.exists(): + shutil.copy2(cli_json, benchmarks_dir / "benchmark-cli.json") + downloads.append("- [CLI benchmark JSON](benchmark-cli.json)") + + if downloads: + sections.extend(["## Downloads", "", *downloads, ""]) + + page = wrap_markdown("\n".join(sections).rstrip() + "\n", "Latest Benchmarks") + (benchmarks_dir / "index.md").write_text(page, encoding="utf-8") + + +def build_site(args): + repo_root = Path(args.repo_root).resolve() + output_dir = Path(args.output).resolve() + + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + write_site_config(output_dir) + + # Home page from the repository README. + write_markdown_page(repo_root / "README.md", output_dir / "index.md", "pyseq") + + # Docs pages. + docs_dir = repo_root / "docs" + for src in docs_dir.glob("*.md"): + if src.name == "README.md": + dst = output_dir / "docs" / "index.md" + fallback = "Docs" + else: + dst = output_dir / "docs" / src.name + fallback = src.stem.replace("-", " ").title() + write_markdown_page(src, dst, fallback) + + if (docs_dir / "assets").exists(): + shutil.copytree(docs_dir / "assets", output_dir / "docs" / "assets") + + cname = repo_root / "CNAME" + if cname.exists(): + shutil.copy2(cname, output_dir / "CNAME") + + write_benchmarks_page( + output_dir, + core_summary=Path(args.core_summary) if args.core_summary else None, + cli_summary=Path(args.cli_summary) if args.cli_summary else None, + core_json=Path(args.core_json) if args.core_json else None, + cli_json=Path(args.cli_json) if args.cli_json else None, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", default=".") + parser.add_argument("--output", required=True) + parser.add_argument("--core-summary") + parser.add_argument("--cli-summary") + parser.add_argument("--core-json") + parser.add_argument("--cli-json") + args = parser.parse_args() + build_site(args) + + +if __name__ == "__main__": + main() From f0dd17750dcd33843dbeb020855a0549ef9e4ed8 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Tue, 21 Jul 2026 05:56:47 -0700 Subject: [PATCH 07/26] Add compact %x formatting and negative frame filename support --- docs/examples.md | 5 ++- docs/formatting.md | 7 +++-- lib/pyseq/config.py | 11 ++++--- lib/pyseq/frange.py | 2 +- lib/pyseq/seq.py | 16 ++++++++-- lib/pyseq/util.py | 6 ++-- tests/files/negA.-0001.exr | 1 + tests/files/negA.-0002.exr | 1 + tests/files/negA.0000.exr | 1 + tests/files/negA.0001.exr | 1 + tests/test_pyseq.py | 63 +++++++++++++++++++++++++++++++++++++- 11 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 tests/files/negA.-0001.exr create mode 100644 tests/files/negA.-0002.exr create mode 100644 tests/files/negA.0000.exr create mode 100644 tests/files/negA.0001.exr diff --git a/docs/examples.md b/docs/examples.md index 8a66719..3850670 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -137,9 +137,8 @@ Expected output: -10--1x3 ``` -This currently applies to serialized range strings passed to `uncompress()` -and the sequence-aware CLI tools. Full on-disk discovery of negative filenames -is a separate follow-up. +Signed frame ranges also work with on-disk filename patterns such as +`render.-0010.exr` when resolving or discovering sequences. ## Resolve a Sequence Pattern on Disk diff --git a/docs/formatting.md b/docs/formatting.md index 23c5da6..da32182 100644 --- a/docs/formatting.md +++ b/docs/formatting.md @@ -16,7 +16,7 @@ CLI tools. | `%p` | padding, e.g. %06d | | `%r` | implied range, start-end | | `%R` | explicit broken range, [1-10, 15-20] | -| `%x` | stepped explicit range, 1-10x2, 20 | +| `%x` | stepped explicit range, 1-10x2,20 | | `%d` | disk usage | | `%H` | disk usage (human readable) | | `%D` | parent directory | @@ -42,8 +42,9 @@ Notes: - `xN` means "every Nth frame" anchored to the segment start. - Descending ranges are accepted when parsing, but `Sequence.frames()` and `%x` formatting normalize frames into ascending order. -- Signed frame numbers are currently documented for serialized range strings. - Full on-disk discovery of negative filenames is a separate follow-up. +- `%x` uses compact canonical formatting with no spaces after commas. +- Signed frame numbers are supported in serialized range strings and in + on-disk filename patterns such as `render.-0010.exr`. ## CLI Examples diff --git a/lib/pyseq/config.py b/lib/pyseq/config.py index 21e4a32..8ac8b82 100644 --- a/lib/pyseq/config.py +++ b/lib/pyseq/config.py @@ -49,12 +49,13 @@ PYSEQ_NOT_STRICT = os.getenv("PYSEQ_NOT_STRICT", 1) strict_pad = int(PYSEQ_STRICT_PAD) == 1 or int(PYSEQ_NOT_STRICT) == 0 -# regex pattern for matching all numbers in a filename -digits_re = re.compile(r"\d+") - # regex pattern for matching frame numbers only -# the default is \d+ for maximum compatibility -DEFAULT_FRAME_PATTERN = r"\d+" +# the default preserves compatibility with common positive filenames while +# also allowing signed frame tokens like render.-0010.exr +DEFAULT_FRAME_PATTERN = r"(?:(?<=^)|(?<=[._]))-\d+|\d+" + +# regex pattern for matching all numeric sequence tokens in a filename +digits_re = re.compile(DEFAULT_FRAME_PATTERN) PYSEQ_FRAME_PATTERN = os.getenv("PYSEQ_FRAME_PATTERN", DEFAULT_FRAME_PATTERN) diff --git a/lib/pyseq/frange.py b/lib/pyseq/frange.py index 5b6bb82..6d1e13f 100644 --- a/lib/pyseq/frange.py +++ b/lib/pyseq/frange.py @@ -158,4 +158,4 @@ def format_frame_range_stepped(frames: List[int]) -> str: parts.extend([str(start), str(end)]) else: parts.append(f"{start}-{end}x{step}") - return range_join.join(parts) + return ",".join(parts) diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index 6975eff..134e4c5 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -71,6 +71,11 @@ class FormatError(Exception): pass +def _frame_width(frame: str) -> int: + """Return the digit width of a frame token, excluding any sign.""" + return len(frame[1:] if frame.startswith("-") else frame) + + def padsize(item, frame): """ Determines the pad size for a given Item. Return value may depend on @@ -86,13 +91,16 @@ def padsize(item, frame): # strict: frame size (%d) must match between frames (default) # for example: test.09.jpg, test.10.jpg, test.11.jpg + width = _frame_width(frame) + digits = frame[1:] if frame.startswith("-") else frame + if strict_pad: - return item.pad or len(frame) + return item.pad or width # not strict: frame size can change between frames # for example: test.9.jpg, test.10.jpg, test.11.jpg else: - return item.pad or len(frame) if frame.startswith("0") else 0 + return item.pad or width if digits.startswith("0") else 0 class Item(str): @@ -947,7 +955,9 @@ def diff(f1: Union[str, Item], f2: Union[str, Item]): if len(f1.number_matches) == len(f2.number_matches): for m1, m2 in zip(f1.number_matches, f2.number_matches): if (m1.start() == m2.start()) and (m1.group() != m2.group()): - if strict_pad is True and (len(m1.group()) != len(m2.group())): + if strict_pad is True and ( + _frame_width(m1.group()) != _frame_width(m2.group()) + ): continue d.append( { diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index 1d27f87..64fdd34 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -153,13 +153,13 @@ def resolve_sequence(sequence_string: str): padding = match.group(1) if padding: pad = int(padding) - glob_part = filename.replace(f"%0{pad}d", "?" * pad) + glob_part = filename.replace(f"%0{pad}d", "*") regex_pattern = re.escape(filename).replace( - f"%0{pad}d", r"\d{" + str(pad) + r"}" + f"%0{pad}d", r"-?\d{" + str(pad) + r"}" ) else: glob_part = filename.replace("%d", "*") - regex_pattern = re.escape(filename).replace("%d", r"\d+") + regex_pattern = re.escape(filename).replace("%d", r"-?\d+") # glob all files in the directory using glob pattern glob_pattern = os.path.join(directory, glob_part) diff --git a/tests/files/negA.-0001.exr b/tests/files/negA.-0001.exr new file mode 100644 index 0000000..88355c3 --- /dev/null +++ b/tests/files/negA.-0001.exr @@ -0,0 +1 @@ +dummy frame -0001 diff --git a/tests/files/negA.-0002.exr b/tests/files/negA.-0002.exr new file mode 100644 index 0000000..959bfb0 --- /dev/null +++ b/tests/files/negA.-0002.exr @@ -0,0 +1 @@ +dummy frame -0002 diff --git a/tests/files/negA.0000.exr b/tests/files/negA.0000.exr new file mode 100644 index 0000000..cd1b05b --- /dev/null +++ b/tests/files/negA.0000.exr @@ -0,0 +1 @@ +dummy frame 0000 diff --git a/tests/files/negA.0001.exr b/tests/files/negA.0001.exr new file mode 100644 index 0000000..211d717 --- /dev/null +++ b/tests/files/negA.0001.exr @@ -0,0 +1 @@ +dummy frame 0001 diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index 446196c..5b47d21 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -36,6 +36,7 @@ import os import re import random +import tempfile import unittest import subprocess import sys @@ -157,6 +158,13 @@ def test_is_sibling_method_is_working_properly(self): self.assertTrue(item1.is_sibling(item2)) self.assertTrue(item2.is_sibling(item1)) + def test_is_sibling_method_with_negative_frames(self): + item1 = Item("render.-0002.exr") + item2 = Item("render.-0001.exr") + + self.assertTrue(item1.is_sibling(item2)) + self.assertTrue(item2.is_sibling(item1)) + class SequenceTestCase(unittest.TestCase): """tests the pyseq""" @@ -530,7 +538,7 @@ def test_uncompress_extended_range_descending(self): def test_format_extended_range_multiple_segments(self): seq = uncompress("render.%04d.exr 1-10x3, 20-30x5, 42", fmt="%h%p%t %x") self.assertEqual(seq.frames(), [1, 4, 7, 10, 20, 25, 30, 42]) - self.assertEqual(seq.format("%x"), "1-10x3, 20-30x5, 42") + self.assertEqual(seq.format("%x"), "1-10x3,20-30x5,42") def test_get_sequences_is_working_properly_1(self): """testing if get_sequences is working properly""" @@ -554,6 +562,7 @@ def test_get_sequences_is_working_properly_1(self): "fileA.1-3.jpg", "fileA.1-3.png", "file_02.tif", + "negA.-2-1.exr", "z1_001_v1.1-4.png", "z1_002_v1.1-4.png", "z1_002_v2.1-4.png", @@ -598,6 +607,57 @@ def test_resolve_sequence_reference_with_stepped_embedded_range(self): self.assertEqual(seq.frames(), [1001, 1004, 1007, 1010]) self.assertEqual(seq.format("%h%p%t %x"), "stepA.%d.exr 1001-1010x3") + def test_get_sequences_with_negative_filenames(self): + pyseq.strict_pad = True + seq = get_sequences( + [ + "render.-0002.exr", + "render.-0001.exr", + "render.0000.exr", + "render.0001.exr", + ] + )[0] + + self.assertEqual(seq.frames(), [-2, -1, 0, 1]) + self.assertEqual(seq.format("%h%p%t %x"), "render.%04d.exr -2-1") + + def test_get_sequences_with_negative_fixture_files(self): + pyseq.strict_pad = True + seq = get_sequences("./tests/files/negA*")[0] + + self.assertEqual(str(seq), "negA.-2-1.exr") + self.assertEqual(seq.frames(), [-2, -1, 0, 1]) + self.assertEqual(seq.format("%h%p%t %x"), "negA.%04d.exr -2-1") + + def test_resolve_sequence_reference_with_negative_filenames(self): + pyseq.strict_pad = True + with tempfile.TemporaryDirectory() as tmpdir: + for frame in (-2, -1, 0, 1): + if frame < 0: + frame_text = f"-{abs(frame):04d}" + else: + frame_text = f"{frame:04d}" + with open( + os.path.join(tmpdir, f"render.{frame_text}.exr"), "w" + ) as handle: + handle.write("dummy frame") + + seq, dirname = resolve_sequence_reference( + os.path.join(tmpdir, "render.%04d.exr -2-1") + ) + + self.assertEqual(dirname, tmpdir) + self.assertEqual(seq.frames(), [-2, -1, 0, 1]) + self.assertEqual(seq.format("%h%p%t %x"), "render.%04d.exr -2-1") + + def test_resolve_sequence_reference_with_negative_fixture_files(self): + pyseq.strict_pad = True + seq, dirname = resolve_sequence_reference("./tests/files/negA.%04d.exr -2-1") + + self.assertEqual(dirname, "./tests/files") + self.assertEqual(seq.frames(), [-2, -1, 0, 1]) + self.assertEqual(seq.format("%h%p%t %x"), "negA.%04d.exr -2-1") + class LSSTestCase(unittest.TestCase): """Tests lss command""" @@ -640,6 +700,7 @@ def test_lss_is_working_properly_1(self): 3 fileA.%04d.jpg [1-3] 3 fileA.%04d.png [1-3] 1 file_02.tif + 4 negA.%04d.exr [-2-1] 4 stepA.%d.exr [1001, 1004, 1007, 1010] 4 z1_001_v1.%d.png [1-4] 4 z1_002_v1.%d.png [1-4] From 99f2ba61921448b31433d9fd6244559e571e188c Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Tue, 21 Jul 2026 06:09:38 -0700 Subject: [PATCH 08/26] Add extended syntax support with negative-frame CLI coverage and release notes --- CHANGELOG.md | 9 +++++++++ lib/pyseq/__init__.py | 2 +- tests/test_lss.py | 16 ++++++++++++++++ tests/test_scopy.py | 26 ++++++++++++++++++++++++++ tests/test_smove.py | 27 +++++++++++++++++++++++++++ tests/test_srm.py | 24 ++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2e9656..73867c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +## 0.9.3 + +* Adds canonical stepped frame range formatting via `%x`, using compact syntax such as `1-10x2,20-30x5,42` +* Adds explicit stepped range parsing for serialized and embedded sequence references +* Adds support for signed frame numbers in serialized ranges and on-disk filenames such as `render.-0002.exr` +* Refactors explicit range parsing and formatting onto shared helpers to keep the syntax rules consistent +* Expands regression coverage for stepped ranges, negative frame handling, CLI tools, and performance-sensitive paths +* Adds benchmark tooling, benchmark documentation, and GitHub Pages publishing workflows for docs and reports + ## 0.9.2 * Renames the move CLI from `smove` to `smv` diff --git a/lib/pyseq/__init__.py b/lib/pyseq/__init__.py index 7800c27..d174cc4 100644 --- a/lib/pyseq/__init__.py +++ b/lib/pyseq/__init__.py @@ -48,6 +48,6 @@ """ __author__ = "Ryan Galloway" -__version__ = "0.9.2" +__version__ = "0.9.3" from .seq import * diff --git a/tests/test_lss.py b/tests/test_lss.py index 6c679ab..083ec18 100644 --- a/tests/test_lss.py +++ b/tests/test_lss.py @@ -95,3 +95,19 @@ def test_lss_stdin_input(lss_fixture): assert result.returncode == 0 out = result.stdout assert " 3 shot.%04d.exr [1-3]" in out + + +def test_lss_compact_extended_range_format(tmp_path): + """The %x directive should render compact stepped ranges.""" + for frame in (1001, 1004, 1007, 1010): + (tmp_path / f"stepA.{frame:04d}.exr").write_text("frame\n") + + result = subprocess.run( + [lss_bin, "-f", "%h%x%t", str(tmp_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert "stepA.1001-1010x3.exr" in result.stdout diff --git a/tests/test_scopy.py b/tests/test_scopy.py index 250066a..f36c548 100644 --- a/tests/test_scopy.py +++ b/tests/test_scopy.py @@ -45,6 +45,10 @@ scopy_bin = get_installed_command("scopy") +def _signed_frame_name(frame): + return f"{frame:05d}" if frame < 0 else f"{frame:04d}" + + @pytest.fixture def sample_sequence(tmp_path): """Create a dummy sequence: test.0001.exr through test.0003.exr""" @@ -140,3 +144,25 @@ def test_scopy_cli_embedded_range_source_and_dest(tmp_path): assert os.path.exists(tmp_path / f"plate.{i:04d}.rgb") for i in range(20, 23): assert os.path.exists(tmp_path / f"beauty.{i:04d}.rgb") + + +def test_scopy_cli_negative_sequence_string_source_and_dest(tmp_path): + """Serialized negative frame ranges should resolve before copying.""" + for frame in range(-2, 2): + (tmp_path / f"negA.{_signed_frame_name(frame)}.exr").write_text("dummy frame") + + src = str(tmp_path / "negA.%04d.exr") + " -2-0" + dest = str(tmp_path / "negB.%04d.exr") + " 100-102" + + result = subprocess.run( + [scopy_bin, src, dest], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + assert result.returncode == 0, result.stderr + for frame in range(-2, 2): + assert os.path.exists(tmp_path / f"negA.{_signed_frame_name(frame)}.exr") + for frame in range(100, 103): + assert os.path.exists(tmp_path / f"negB.{frame:04d}.exr") diff --git a/tests/test_smove.py b/tests/test_smove.py index bea5983..0baf4c3 100644 --- a/tests/test_smove.py +++ b/tests/test_smove.py @@ -45,6 +45,10 @@ smv_bin = get_installed_command("smv") +def _signed_frame_name(frame): + return f"{frame:05d}" if frame < 0 else f"{frame:04d}" + + @pytest.fixture def sample_sequence(): """Fixture to create a temporary directory with a sequence of files.""" @@ -245,3 +249,26 @@ def test_smv_cli_embedded_range_source_and_dest(tmp_path): assert os.path.exists(tmp_path / "plate.0005.rgb") for i in range(2, 5): assert not os.path.exists(tmp_path / f"plate.{i:04d}.rgb") + + +def test_smv_cli_negative_sequence_string_source_and_dest(tmp_path): + """Serialized negative frame ranges should resolve before moving.""" + for frame in range(-2, 2): + (tmp_path / f"negA.{_signed_frame_name(frame)}.exr").write_text("dummy frame") + + src = str(tmp_path / "negA.%04d.exr") + " -2-0" + dest = str(tmp_path / "negB.%04d.exr") + " 100-102" + + result = subprocess.run( + [smv_bin, src, dest], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + assert result.returncode == 0, result.stderr + for frame in range(100, 103): + assert os.path.exists(tmp_path / f"negB.{frame:04d}.exr") + for frame in range(-2, 1): + assert not os.path.exists(tmp_path / f"negA.{_signed_frame_name(frame)}.exr") + assert os.path.exists(tmp_path / "negA.0001.exr") diff --git a/tests/test_srm.py b/tests/test_srm.py index 5ee56bb..6e1c572 100644 --- a/tests/test_srm.py +++ b/tests/test_srm.py @@ -45,6 +45,10 @@ srm_bin = get_installed_command("srm") +def _signed_frame_name(frame): + return f"{frame:05d}" if frame < 0 else f"{frame:04d}" + + @pytest.fixture def sample_sequence(): """Fixture to create a temporary directory with a sequence of files.""" @@ -147,3 +151,23 @@ def test_srm_cli_embedded_range(tmp_path): assert os.path.exists(tmp_path / "plate.0005.rgb") for i in range(2, 5): assert not os.path.exists(tmp_path / f"plate.{i:04d}.rgb") + + +def test_srm_cli_negative_sequence_string(tmp_path): + """Serialized negative frame ranges should resolve before removal.""" + for frame in range(-2, 2): + (tmp_path / f"negA.{_signed_frame_name(frame)}.exr").write_text("dummy frame") + + src = str(tmp_path / "negA.%04d.exr") + " -2-0" + + result = subprocess.run( + [srm_bin, src], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + assert result.returncode == 0, result.stderr + for frame in range(-2, 1): + assert not os.path.exists(tmp_path / f"negA.{_signed_frame_name(frame)}.exr") + assert os.path.exists(tmp_path / "negA.0001.exr") From 71410b682dd0dc817284a27e71c12f91b80e4754 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Tue, 21 Jul 2026 06:25:42 -0700 Subject: [PATCH 09/26] Address PR comments --- lib/pyseq/seq.py | 50 ++++++++++++++++++++++----------------------- lib/pyseq/util.py | 38 +++++++++++++++++++++------------- tests/test_pyseq.py | 43 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 40 deletions(-) diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index 134e4c5..a007af8 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -38,9 +38,9 @@ import re import traceback import warnings +from ast import literal_eval from collections import deque from glob import glob, iglob -from itertools import zip_longest from typing import List, Callable, Union from pyseq import config @@ -125,10 +125,10 @@ def __init__(self, item: Union[str, os.PathLike]): self.__stat = None # modified by self.is_sibling() - self.frame = None - self.head = self.name - self.tail = "" - self.pad = None + self.frame = getattr(item, "frame", None) + self.head = getattr(item, "head", self.name) + self.tail = getattr(item, "tail", "") + self.pad = getattr(item, "pad", None) def __eq__(self, other): """ @@ -1078,54 +1078,52 @@ def uncompress(seq_string: str, fmt: str = global_format): frame_values = parse_frame_range(f"{s}-{e}") try: - frame_values = eval(match.group("f")) + frame_values = literal_eval(match.group("f")) except IndexError: pass try: - missing = eval(match.group("m")) + missing = literal_eval(match.group("m")) except IndexError: pass items = [] emitted_frames = [] + head = match.groupdict().get("h", "") + tail = match.groupdict().get("t", "") + item_pad = 0 if pad == "%d" else int(re.search(r"\d+", pad).group()) if missing: for i in parse_frame_range(f"{s}-{e}"): if i in missing: continue f = ("-" if i < 0 else "") + (pad % abs(i)) - name = "%s%s%s" % ( - match.groupdict().get("h", ""), - f, - match.groupdict().get("t", ""), - ) - items.append(Item(os.path.join(dirname, name))) + name = "%s%s%s" % (head, f, tail) + item = Item(os.path.join(dirname, name)) + item.frame = i + item.head = head + item.tail = tail + item.pad = item_pad + items.append(item) emitted_frames.append(i) else: for i in frame_values: f = ("-" if i < 0 else "") + (pad % abs(i)) - name = "%s%s%s" % ( - match.groupdict().get("h", ""), - f, - match.groupdict().get("t", ""), - ) - items.append(Item(os.path.join(dirname, name))) + name = "%s%s%s" % (head, f, tail) + item = Item(os.path.join(dirname, name)) + item.frame = i + item.head = head + item.tail = tail + item.pad = item_pad + items.append(item) emitted_frames.append(i) seqs = get_sequences(items) if seqs: if emitted_frames: seq = seqs[0] - for item, frame in zip_longest(seq, emitted_frames): - if item is None or frame is None: - break - item.frame = frame - item.head = match.groupdict().get("h", "") - item.tail = match.groupdict().get("t", "") - item.pad = 0 if pad == "%d" else int(re.search(r"\d+", pad).group()) seq._Sequence__frames = sorted(emitted_frames) seq._Sequence__missing = None return seqs[0] diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index 64fdd34..cdbfae0 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -153,17 +153,28 @@ def resolve_sequence(sequence_string: str): padding = match.group(1) if padding: pad = int(padding) - glob_part = filename.replace(f"%0{pad}d", "*") + positive_glob = filename.replace(f"%0{pad}d", "?" * pad) + negative_glob = filename.replace(f"%0{pad}d", "-" + ("?" * pad)) regex_pattern = re.escape(filename).replace( f"%0{pad}d", r"-?\d{" + str(pad) + r"}" ) else: - glob_part = filename.replace("%d", "*") + positive_glob = filename.replace("%d", "*") + negative_glob = None regex_pattern = re.escape(filename).replace("%d", r"-?\d+") - # glob all files in the directory using glob pattern - glob_pattern = os.path.join(directory, glob_part) - candidate_files = glob.glob(glob_pattern) + # Keep globbing narrow for padded sequences, including signed variants. + glob_patterns = [os.path.join(directory, positive_glob)] + if negative_glob and negative_glob != positive_glob: + glob_patterns.append(os.path.join(directory, negative_glob)) + + candidate_files = [] + seen = set() + for glob_pattern in glob_patterns: + for candidate in glob.glob(glob_pattern): + if candidate not in seen: + seen.add(candidate) + candidate_files.append(candidate) # filter using regex (because glob pattern is wide) regex = re.compile(f"^{regex_pattern}$") @@ -235,23 +246,22 @@ def parse_explicit_sequence_string(reference: str): head, range_text, tail = embedded frames = parse_frame_range(range_text) - items = [ - pyseq.Item( + items = [] + for frame in frames: + item = pyseq.Item( os.path.join( dirname, f"{head}{frame}{tail}", ) ) - for frame in frames - ] + item.frame = frame + item.head = head + item.tail = tail + item.pad = 0 + items.append(item) sequences = pyseq.get_sequences(items) if sequences: seq = sequences[0] - for item, frame in zip(seq, frames): - item.frame = frame - item.head = head - item.tail = tail - item.pad = 0 seq._Sequence__frames = sorted(frames) seq._Sequence__missing = None return { diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index 5b47d21..afa7a23 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -533,6 +533,15 @@ def test_uncompress_extended_range_negative_frames(self): def test_uncompress_extended_range_descending(self): seq = uncompress("render.%04d.exr 10-1x3", fmt="%h%p%t %x") self.assertEqual(seq.frames(), [1, 4, 7, 10]) + self.assertEqual( + [(item.name, item.frame) for item in seq], + [ + ("render.0001.exr", 1), + ("render.0004.exr", 4), + ("render.0007.exr", 7), + ("render.0010.exr", 10), + ], + ) self.assertEqual(seq.format("%x"), "1-10x3") def test_format_extended_range_multiple_segments(self): @@ -607,6 +616,40 @@ def test_resolve_sequence_reference_with_stepped_embedded_range(self): self.assertEqual(seq.frames(), [1001, 1004, 1007, 1010]) self.assertEqual(seq.format("%h%p%t %x"), "stepA.%d.exr 1001-1010x3") + def test_resolve_sequence_reference_with_descending_serialized_range(self): + pyseq.strict_pad = False + reference = "./tests/files/stepA.%04d.exr 1010-1001x3" + seq, dirname = resolve_sequence_reference(reference) + + self.assertEqual(dirname, "./tests/files") + self.assertEqual(seq.frames(), [1001, 1004, 1007, 1010]) + self.assertEqual( + [(item.name, item.frame) for item in seq], + [ + ("stepA.1001.exr", 1001), + ("stepA.1004.exr", 1004), + ("stepA.1007.exr", 1007), + ("stepA.1010.exr", 1010), + ], + ) + + def test_resolve_sequence_reference_with_descending_embedded_range(self): + pyseq.strict_pad = False + reference = "./tests/files/stepA.1010-1001x3.exr" + seq, dirname = resolve_sequence_reference(reference) + + self.assertEqual(dirname, "./tests/files") + self.assertEqual(seq.frames(), [1001, 1004, 1007, 1010]) + self.assertEqual( + [(item.name, item.frame) for item in seq], + [ + ("stepA.1001.exr", 1001), + ("stepA.1004.exr", 1004), + ("stepA.1007.exr", 1007), + ("stepA.1010.exr", 1010), + ], + ) + def test_get_sequences_with_negative_filenames(self): pyseq.strict_pad = True seq = get_sequences( From e1ef7aad7428718b42632efdf6c602d8f3605b2b Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Tue, 21 Jul 2026 06:50:09 -0700 Subject: [PATCH 10/26] Consolidate benchmarks into a single runner and run smoke benchmarks on PRs --- .github/workflows/benchmarks.yml | 34 ++- .github/workflows/publish-docs.yml | 11 +- docs/performance.md | 219 +++++++------- scripts/benchmark.py | 452 +++++++++++++++++++++++++++++ scripts/benchmarks_cli.py | 184 ------------ scripts/benchmarks_core.py | 176 ----------- scripts/build_pages_site.py | 36 +-- 7 files changed, 592 insertions(+), 520 deletions(-) create mode 100644 scripts/benchmark.py delete mode 100644 scripts/benchmarks_cli.py delete mode 100644 scripts/benchmarks_core.py diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 7ce3416..68a9f6a 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -1,6 +1,13 @@ name: benchmarks on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + - converted_to_draft workflow_dispatch: inputs: profile: @@ -38,32 +45,29 @@ jobs: run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "value=${{ inputs.profile }}" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "schedule" ]; then + echo "value=full" >> "$GITHUB_OUTPUT" else echo "value=smoke" >> "$GITHUB_OUTPUT" fi - - name: Run core benchmarks + - name: Run benchmarks run: | - python scripts/benchmarks_core.py \ - --profile "${{ steps.profile.outputs.value }}" \ - --json benchmark-core.json - - - name: Run CLI benchmarks - run: | - python scripts/benchmarks_cli.py \ - --profile "${{ steps.profile.outputs.value }}" \ - --json benchmark-cli.json + if [ "${{ steps.profile.outputs.value }}" = "full" ]; then + python scripts/benchmark.py --full --json benchmark.json > benchmark.md + else + python scripts/benchmark.py --json benchmark.json > benchmark.md + fi - name: Write benchmark summary run: | - python scripts/benchmarks_core.py --profile "${{ steps.profile.outputs.value }}" --summary >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - python scripts/benchmarks_cli.py --profile "${{ steps.profile.outputs.value }}" --summary >> "$GITHUB_STEP_SUMMARY" + cat benchmark.md >> "$GITHUB_STEP_SUMMARY" - name: Upload benchmark artifacts uses: actions/upload-artifact@v4 with: name: benchmark-results-${{ steps.profile.outputs.value }} path: | - benchmark-core.json - benchmark-cli.json + benchmark.json + benchmark.md + tmp/benchmarks/profiles diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b811271..aeaf1f0 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -37,19 +37,16 @@ jobs: python -m pip install --upgrade pip setuptools wheel python -m pip install -e ".[dev]" - - name: Generate benchmark reports + - name: Generate benchmark report run: | - python scripts/benchmarks_core.py --profile smoke --json benchmark-core.json --summary > benchmark-core.md - python scripts/benchmarks_cli.py --profile smoke --json benchmark-cli.json --summary > benchmark-cli.md + python scripts/benchmark.py --json benchmark.json > benchmark.md - name: Build Jekyll source tree run: | python scripts/build_pages_site.py \ --output _site_src \ - --core-summary benchmark-core.md \ - --cli-summary benchmark-cli.md \ - --core-json benchmark-core.json \ - --cli-json benchmark-cli.json + --benchmark-summary benchmark.md \ + --benchmark-json benchmark.json - name: Build site with Jekyll uses: actions/jekyll-build-pages@v1 diff --git a/docs/performance.md b/docs/performance.md index e580540..daf2713 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,158 +1,147 @@ # Performance -PySeq's performance matters most in sequence discovery, range formatting, and -CLI workflows over large file sets. +PySeq's performance matters most in sequence discovery and `lss` over large +file sets. This project uses two complementary approaches: - coarse regression tests in `tests/test_performance.py` -- repeatable benchmark scripts under `scripts/` +- one repeatable benchmark runner in `scripts/benchmark.py` The regression tests should stay stable and fast enough for normal CI. The -benchmark scripts are for collecting timing data over time, not for enforcing -fragile micro-optimizations in every pull request. +benchmark runner is for collecting timing and profiling data over time, not +for enforcing fragile micro-optimizations in every pull request. -## Benchmark Scripts +## Benchmark Runner -Current benchmark entry points: +Current benchmark entry point: -- `scripts/benchmarks_core.py` -- `scripts/benchmarks_cli.py` +- `scripts/benchmark.py` -Both scripts support: - -- `--profile smoke` -- `--profile full` -- `--json ` -- `--summary` - -Examples: +Simple examples: ```bash -python scripts/benchmarks_core.py --profile smoke --summary -python scripts/benchmarks_core.py --profile full --json core-benchmarks.json -python scripts/benchmarks_cli.py --profile smoke --summary +python scripts/benchmark.py +python scripts/benchmark.py --full +python scripts/benchmark.py --json /tmp/pyseq-main.json +python scripts/benchmark.py --compare /tmp/pyseq-main.json /tmp/pyseq-feature.json ``` -## Result Format +What it measures: -Both benchmark scripts can emit machine-readable JSON for CI artifacts, -historical snapshots, or local comparison. +- `get_sequences(...)` from an in-memory file list +- `get_sequences(...)` from a directory path +- `resolve_sequence(...)` against a padded on-disk sequence +- `lss` end-to-end subprocess runtime on the same synthetic dataset -Example core benchmark payload: +Default synthetic dataset sizes: -```json -{ - "benchmarks": { - "sequence_construct_1000": { - "iterations": 5, - "max_s": 0.043952, - "median_s": 0.041054, - "min_s": 0.037981 - }, - "uncompress_stepped_10k": { - "iterations": 5, - "max_s": 0.153435, - "median_s": 0.132808, - "min_s": 0.130189 - } - }, - "kind": "core", - "platform": "Linux-6.x-x86_64", - "profile": "smoke", - "python": "3.11.x", - "timestamp_utc": "2026-07-21T00:00:00+00:00" -} -``` +- default: `100`, `1000`, `10000` +- `--full`: `100`, `1000`, `10000`, `50000` -Example CLI benchmark payload: +You can override dataset sizes or iteration count: -```json -{ - "benchmarks": { - "lss_recursive": { - "iterations": 3, - "max_s": 0.110399, - "median_s": 0.103545, - "min_s": 0.101037 - }, - "sfind_png": { - "iterations": 3, - "max_s": 0.095798, - "median_s": 0.093574, - "min_s": 0.093541 - } - }, - "kind": "cli", - "platform": "Linux-6.x-x86_64", - "profile": "smoke", - "python": "3.11.x", - "timestamp_utc": "2026-07-21T00:00:00+00:00" -} +```bash +python scripts/benchmark.py --sizes 100,1000,10000,50000 --iterations 7 ``` -Field notes: +## Local A/B Comparison -- `kind` distinguishes library benchmarks from CLI benchmarks. -- `profile` indicates whether the run used the `smoke` or `full` dataset. -- `median_s` is the primary comparison value. -- `min_s` and `max_s` help show runner noise and spread. -- `timestamp_utc`, `python`, and `platform` are useful when comparing - artifacts across runs. +Use the same machine and same interpreter for both runs. -## What We Measure +Example workflow: -### Core library benchmarks +```bash +# on baseline branch +python scripts/benchmark.py --full --json /tmp/pyseq-main.json -- `Sequence(...)` construction at fixed scales -- `get_sequences(...)` on mixed file lists -- `uncompress(...)` for contiguous and stepped ranges -- `%M` formatting on sparse huge ranges -- stepped range parsing and formatting -- `resolve_sequence_reference(...)` for stepped serialized references +# switch branches +python scripts/benchmark.py --full --json /tmp/pyseq-feature.json -### CLI benchmarks +# compare +python scripts/benchmark.py --compare /tmp/pyseq-main.json /tmp/pyseq-feature.json +``` -- `lss` -- `stree` -- `sfind` -- `sdiff` -- `sstat` +## Profiling -CLI benchmarks intentionally include subprocess overhead because that is part -of the real user-facing cost. +Profiling is enabled by default, but it runs in a separate pass after the +timed measurements. That means the timing samples stay clean while we still +capture where time is going. -## Profiles +Generated artifacts: -### `smoke` +- one `.pstats` file per benchmark case +- one text summary per benchmark case, sorted by cumulative time -Use this in normal CI and pull-request workflows. +Default output location: -Goals: +- `tmp/benchmarks/profiles/` -- catch catastrophic regressions -- keep runtime short -- publish comparable results +Useful options: -### `full` +- `--no-profile` to skip profiling entirely +- `--profiles-dir ` to choose a different output directory -Use this for scheduled runs or manual benchmarking. +## Result Format -Goals: +The benchmark runner can emit machine-readable JSON for local comparison, CI +artifacts, or published reports. -- collect more stable medians -- exercise larger synthetic datasets -- provide release-readiness data +Example run payload: + +```json +{ + "benchmarks": { + "get_sequences_dir_1000": { + "iterations": 5, + "max_s": 0.043512, + "mean_s": 0.041932, + "median_s": 0.041704, + "min_s": 0.040161, + "samples_s": [0.040161, 0.041085, 0.041704, 0.043198, 0.043512] + }, + "lss_1000": { + "iterations": 5, + "max_s": 0.139424, + "mean_s": 0.133529, + "median_s": 0.132611, + "min_s": 0.129400, + "samples_s": [0.129400, 0.131818, 0.132611, 0.134390, 0.139424] + } + }, + "branch": "feat/example", + "commit": "abc1234", + "iterations": 5, + "kind": "benchmark", + "platform": "Linux-6.x-x86_64", + "profiled": true, + "profiles_dir": "tmp/benchmarks/profiles/20260721T000000Z-feat-example-abc1234", + "python": "3.11.x", + "sizes": [100, 1000, 10000], + "timestamp_utc": "2026-07-21T00:00:00+00:00" +} +``` + +Field notes: + +- `kind` distinguishes normal benchmark runs from comparison payloads. +- `median_s` is the primary comparison value. +- `mean_s` is useful when the run-to-run spread is small. +- `samples_s` helps show spread directly. +- `branch` and `commit` make branch-to-branch comparisons easier to track. +- `profiles_dir` points to the profile output location for that run. +- `timestamp_utc`, `python`, and `platform` help when comparing results. ## Methodology -The benchmark scripts: +The benchmark runner: -- generate synthetic datasets in memory or temporary directories -- use `time.perf_counter()` -- warm up once before measuring -- collect several samples -- report median, min, and max +- generates synthetic datasets in temporary directories +- uses `time.perf_counter()` +- warms up once before measuring +- collects several samples +- reports median, mean, min, and max +- profiles each case in a separate pass by default This keeps the results useful without pretending that a shared CI runner is a perfect benchmarking machine. @@ -164,13 +153,13 @@ absolute truth. Good uses: -- spotting order-of-magnitude regressions -- comparing broad trends on the same workflow +- spotting real regressions on the same machine +- comparing branch-to-branch trends on the same interpreter - attaching timing evidence to performance-focused pull requests Less reliable uses: -- failing builds on small percentage differences +- failing builds on tiny percentage differences - comparing results across different operating systems or Python versions - drawing conclusions from a single run diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100644 index 0000000..8d04cca --- /dev/null +++ b/scripts/benchmark.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# + +"""Simple local benchmark runner for pyseq hotspots.""" + +import argparse +import cProfile +import io +import json +import os +import platform +import pstats +import statistics +import subprocess +import sys +import tempfile +import time +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +LIB_ROOT = REPO_ROOT / "lib" +if str(LIB_ROOT) not in sys.path: + sys.path.insert(0, str(LIB_ROOT)) + +from pyseq import get_sequences # noqa: E402 +from pyseq.util import resolve_sequence # noqa: E402 + + +DEFAULT_SIZES = [100, 1000, 10000] +FULL_SIZES = [100, 1000, 10000, 50000] +DEFAULT_ITERATIONS = 5 +FULL_ITERATIONS = 7 +EXTRA_FILES_FACTOR = 0.1 +PROFILE_TOP_FUNCTIONS = 25 + + +def median(values): + return statistics.median(values) if values else 0.0 + + +def mean(values): + return statistics.mean(values) if values else 0.0 + + +def pct_delta(new_value, old_value): + if old_value == 0: + return 0.0 + return ((new_value / old_value) - 1.0) * 100.0 + + +def parse_sizes(value): + return [int(part.strip()) for part in value.split(",") if part.strip()] + + +def detect_git_branch(): + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + cwd=REPO_ROOT, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return result.stdout.strip() or "detached" + except Exception: + return "unknown" + + +def detect_git_sha(): + try: + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=REPO_ROOT, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return result.stdout.strip() + except Exception: + return "unknown" + + +def safe_path_token(value): + return value.replace(os.sep, "-").replace("/", "-") + + +def make_default_profiles_dir(branch, commit): + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return ( + REPO_ROOT + / "tmp" + / "benchmarks" + / "profiles" + / f"{timestamp}-{safe_path_token(branch)}-{safe_path_token(commit)}" + ) + + +def benchmark_call(func, iterations, warmups=1): + for _ in range(warmups): + func() + + samples = [] + for _ in range(iterations): + start = time.perf_counter() + func() + samples.append(time.perf_counter() - start) + + return { + "iterations": iterations, + "samples_s": samples, + "median_s": median(samples), + "mean_s": mean(samples), + "min_s": min(samples), + "max_s": max(samples), + } + + +def create_dataset(root, size, extra_files_factor=EXTRA_FILES_FACTOR): + dataset = root / f"size_{size}" + dataset.mkdir(parents=True, exist_ok=True) + + for frame in range(1, size + 1): + (dataset / f"renderA.{frame:08d}.exr").touch() + + extra_count = max(1, int(size * extra_files_factor)) + for index in range(extra_count): + (dataset / f"note_{index:08d}.txt").touch() + + return { + "path": dataset, + "sequence_pattern": str(dataset / "renderA.%08d.exr"), + "file_names": sorted(os.listdir(dataset)), + } + + +def run_lss(path): + env = os.environ.copy() + env["PYTHONPATH"] = ( + str(LIB_ROOT) + if not env.get("PYTHONPATH") + else str(LIB_ROOT) + os.pathsep + env["PYTHONPATH"] + ) + subprocess.run( + [sys.executable, "-m", "pyseq.lss", str(path)], + cwd=REPO_ROOT, + env=env, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def profile_python_callable(func, stem, profiles_dir): + profiler = cProfile.Profile() + profiler.enable() + func() + profiler.disable() + + pstats_path = profiles_dir / f"{stem}.pstats" + text_path = profiles_dir / f"{stem}.txt" + profiler.dump_stats(str(pstats_path)) + + stream = io.StringIO() + stats = pstats.Stats(profiler, stream=stream).sort_stats("cumulative") + stats.print_stats(PROFILE_TOP_FUNCTIONS) + text_path.write_text(stream.getvalue(), encoding="utf-8") + + +def profile_lss_subprocess(path, stem, profiles_dir): + env = os.environ.copy() + env["PYTHONPATH"] = ( + str(LIB_ROOT) + if not env.get("PYTHONPATH") + else str(LIB_ROOT) + os.pathsep + env["PYTHONPATH"] + ) + pstats_path = profiles_dir / f"{stem}.pstats" + text_path = profiles_dir / f"{stem}.txt" + + subprocess.run( + [ + sys.executable, + "-m", + "cProfile", + "-o", + str(pstats_path), + "-m", + "pyseq.lss", + str(path), + ], + cwd=REPO_ROOT, + env=env, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + stream = io.StringIO() + stats = pstats.Stats(str(pstats_path), stream=stream).sort_stats("cumulative") + stats.print_stats(PROFILE_TOP_FUNCTIONS) + text_path.write_text(stream.getvalue(), encoding="utf-8") + + +@contextmanager +def build_benchmarks(sizes): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + cases = [] + + for size in sizes: + fixture = create_dataset(root, size) + dataset_path = fixture["path"] + file_names = fixture["file_names"] + sequence_pattern = fixture["sequence_pattern"] + + cases.extend( + [ + { + "name": f"get_sequences_list_{size}", + "callable": lambda file_names=file_names: get_sequences( + file_names + ), + "profile_kind": "python", + }, + { + "name": f"get_sequences_dir_{size}", + "callable": lambda dataset_path=dataset_path: get_sequences( + str(dataset_path) + ), + "profile_kind": "python", + }, + { + "name": f"resolve_sequence_{size}", + "callable": lambda sequence_pattern=sequence_pattern: resolve_sequence( + sequence_pattern + ), + "profile_kind": "python", + }, + { + "name": f"lss_{size}", + "callable": lambda dataset_path=dataset_path: run_lss( + dataset_path + ), + "profile_kind": "lss", + "path": dataset_path, + }, + ] + ) + + yield cases + + +def run_benchmarks(sizes, iterations, enable_profile, profiles_dir): + with build_benchmarks(sizes) as cases: + results = {} + for case in cases: + results[case["name"]] = benchmark_call(case["callable"], iterations) + + if enable_profile: + profiles_dir.mkdir(parents=True, exist_ok=True) + for case in cases: + if case["profile_kind"] == "python": + profile_python_callable( + case["callable"], case["name"], profiles_dir + ) + else: + profile_lss_subprocess(case["path"], case["name"], profiles_dir) + + return results + + +def build_run_payload(sizes, iterations, enable_profile, profiles_dir): + branch = detect_git_branch() + commit = detect_git_sha() + actual_profiles_dir = profiles_dir + if enable_profile and actual_profiles_dir is None: + actual_profiles_dir = make_default_profiles_dir(branch, commit) + + benchmarks = run_benchmarks( + sizes=sizes, + iterations=iterations, + enable_profile=enable_profile, + profiles_dir=actual_profiles_dir, + ) + + return { + "kind": "benchmark", + "branch": branch, + "commit": commit, + "python": platform.python_version(), + "platform": platform.platform(), + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "sizes": sizes, + "iterations": iterations, + "profiled": enable_profile, + "profiles_dir": str(actual_profiles_dir) if actual_profiles_dir else None, + "benchmarks": benchmarks, + } + + +def build_comparison(baseline, candidate): + names = sorted(set(baseline["benchmarks"]) & set(candidate["benchmarks"])) + comparisons = [] + for name in names: + before = baseline["benchmarks"][name] + after = candidate["benchmarks"][name] + comparisons.append( + { + "benchmark": name, + "baseline_median_s": before["median_s"], + "candidate_median_s": after["median_s"], + "delta_s": after["median_s"] - before["median_s"], + "delta_pct": pct_delta(after["median_s"], before["median_s"]), + } + ) + + deltas = [row["delta_pct"] for row in comparisons] + return { + "kind": "benchmark-compare", + "baseline": { + "branch": baseline.get("branch"), + "commit": baseline.get("commit"), + }, + "candidate": { + "branch": candidate.get("branch"), + "commit": candidate.get("commit"), + }, + "benchmarks": comparisons, + "summary": { + "median_delta_pct": median(deltas), + "max_regression_pct": max(deltas), + "max_improvement_pct": min(deltas), + }, + } + + +def write_run_summary(payload, stream): + print("# pyseq benchmarks", file=stream) + print("", file=stream) + print( + f"Branch: `{payload['branch']}` Commit: `{payload['commit']}` Sizes: `{','.join(str(size) for size in payload['sizes'])}` Iterations: `{payload['iterations']}`", + file=stream, + ) + if payload["profiled"] and payload["profiles_dir"]: + print(f"Profiles: `{payload['profiles_dir']}`", file=stream) + print("", file=stream) + print("| Benchmark | Median (s) | Mean (s) | Min (s) | Max (s) |", file=stream) + print("| --- | ---: | ---: | ---: | ---: |", file=stream) + for name, result in payload["benchmarks"].items(): + print( + f"| `{name}` | {result['median_s']:.6f} | {result['mean_s']:.6f} | {result['min_s']:.6f} | {result['max_s']:.6f} |", + file=stream, + ) + + +def write_compare_summary(payload, stream): + print("# pyseq benchmark comparison", file=stream) + print("", file=stream) + print( + f"Baseline: `{payload['baseline']['branch']}` `{payload['baseline']['commit']}` Candidate: `{payload['candidate']['branch']}` `{payload['candidate']['commit']}`", + file=stream, + ) + print("", file=stream) + print( + "| Benchmark | Baseline Median (s) | Candidate Median (s) | Delta (s) | Delta (%) |", + file=stream, + ) + print("| --- | ---: | ---: | ---: | ---: |", file=stream) + for row in payload["benchmarks"]: + print( + f"| `{row['benchmark']}` | {row['baseline_median_s']:.6f} | {row['candidate_median_s']:.6f} | {row['delta_s']:.6f} | {row['delta_pct']:+.2f}% |", + file=stream, + ) + + +def dump_json(payload, path): + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def load_json(path): + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--compare", + nargs=2, + metavar=("BASELINE_JSON", "CANDIDATE_JSON"), + help="Compare two benchmark JSON files instead of running benchmarks", + ) + parser.add_argument("--full", action="store_true", help="Run larger default sizes") + parser.add_argument( + "--sizes", + type=parse_sizes, + help="Comma-separated dataset sizes, for example: 100,1000,10000,50000", + ) + parser.add_argument( + "--iterations", type=int, help="Override timing iteration count" + ) + parser.add_argument("--json", dest="json_path", help="Write JSON output to a file") + parser.add_argument( + "--no-profile", + action="store_true", + help="Skip the separate profiling pass", + ) + parser.add_argument( + "--profiles-dir", + help="Directory for generated .pstats and text profile summaries", + ) + args = parser.parse_args() + + if args.compare: + payload = build_comparison( + load_json(args.compare[0]), + load_json(args.compare[1]), + ) + if args.json_path: + dump_json(payload, args.json_path) + write_compare_summary(payload, sys.stdout) + return + + sizes = args.sizes or (FULL_SIZES if args.full else DEFAULT_SIZES) + iterations = args.iterations or ( + FULL_ITERATIONS if args.full else DEFAULT_ITERATIONS + ) + profiles_dir = Path(args.profiles_dir) if args.profiles_dir else None + payload = build_run_payload( + sizes=sizes, + iterations=iterations, + enable_profile=not args.no_profile, + profiles_dir=profiles_dir, + ) + + if args.json_path: + dump_json(payload, args.json_path) + write_run_summary(payload, sys.stdout) + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmarks_cli.py b/scripts/benchmarks_cli.py deleted file mode 100644 index 7691c7c..0000000 --- a/scripts/benchmarks_cli.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) -# - -"""CLI benchmarks for pyseq console tools.""" - -import argparse -import json -import os -import platform -import shutil -import statistics -import subprocess -import sys -import sysconfig -import tempfile -import time -from datetime import datetime, timezone -from pathlib import Path - - -PROFILE_CONFIG = { - "smoke": {"iterations": 3, "file_count": 200}, - "full": {"iterations": 5, "file_count": 1000}, -} - - -CLI_MODULES = { - "lss": "pyseq.lss", - "stree": "pyseq.stree", - "sfind": "pyseq.sfind", - "sdiff": "pyseq.sdiff", - "sstat": "pyseq.sstat", -} - - -def resolve_command(name): - scripts_dir = sysconfig.get_path("scripts") - candidates = [name] - if os.name == "nt": - candidates = [f"{name}.exe", f"{name}.cmd", f"{name}.bat", name] - - for candidate in candidates: - path = os.path.join(scripts_dir, candidate) - if os.path.exists(path): - return [path] - - path = shutil.which(name) - if path: - return [path] - - return [sys.executable, "-m", CLI_MODULES[name]] - - -def measure(func, iterations, warmups=1): - for _ in range(warmups): - func() - - samples = [] - for _ in range(iterations): - start = time.perf_counter() - func() - samples.append(time.perf_counter() - start) - - return { - "median_s": statistics.median(samples), - "min_s": min(samples), - "max_s": max(samples), - "iterations": iterations, - } - - -def run_cli(command, *args): - cmd = resolve_command(command) + list(args) - subprocess.run( - cmd, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - -def create_fixture_tree(root, file_count): - nested = root / "nested" / "plates" - nested.mkdir(parents=True, exist_ok=True) - - for frame in range(1, file_count + 1): - (root / f"renderA.{frame:04d}.exr").write_text("frame\n") - (root / f"renderB.{frame:04d}.exr").write_text("frame\n") - - for frame in range(1, max(10, file_count // 10) + 1): - (nested / f"plate.{frame:04d}.png").write_text("frame\n") - - (root / "notes.txt").write_text("not a sequence\n") - - -def run_profile(profile): - config = PROFILE_CONFIG[profile] - results = {} - - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - create_fixture_tree(root, config["file_count"]) - - seq_a = str(root / "renderA.%04d.exr") - seq_b = str(root / "renderB.%04d.exr") - - results["lss_recursive"] = measure( - lambda: run_cli("lss", "-r", str(root)), - config["iterations"], - ) - results["lss_format_x"] = measure( - lambda: run_cli("lss", str(root), "-f", "%h%x%t"), - config["iterations"], - ) - results["stree"] = measure( - lambda: run_cli("stree", str(root)), - config["iterations"], - ) - results["sfind_png"] = measure( - lambda: run_cli("sfind", str(root), "-name", "*.png"), - config["iterations"], - ) - results["sdiff"] = measure( - lambda: run_cli("sdiff", seq_a, seq_b), - config["iterations"], - ) - results["sstat"] = measure( - lambda: run_cli("sstat", seq_a), - config["iterations"], - ) - - return results - - -def build_payload(profile): - return { - "kind": "cli", - "profile": profile, - "python": platform.python_version(), - "platform": platform.platform(), - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - "benchmarks": run_profile(profile), - } - - -def write_summary(payload, stream): - print(f"# pyseq CLI benchmarks ({payload['profile']})", file=stream) - print("", file=stream) - print("| Benchmark | Median (s) | Min (s) | Max (s) |", file=stream) - print("| --- | ---: | ---: | ---: |", file=stream) - for name, result in payload["benchmarks"].items(): - print( - f"| `{name}` | {result['median_s']:.6f} | {result['min_s']:.6f} | {result['max_s']:.6f} |", - file=stream, - ) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--profile", choices=sorted(PROFILE_CONFIG), default="smoke") - parser.add_argument("--json", dest="json_path") - parser.add_argument("--summary", action="store_true") - args = parser.parse_args() - - payload = build_payload(args.profile) - - if args.json_path: - with open(args.json_path, "w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, sort_keys=True) - handle.write("\n") - - if args.summary: - write_summary(payload, sys.stdout) - - if not args.json_path and not args.summary: - json.dump(payload, sys.stdout, indent=2, sort_keys=True) - sys.stdout.write("\n") - - -if __name__ == "__main__": - main() diff --git a/scripts/benchmarks_core.py b/scripts/benchmarks_core.py deleted file mode 100644 index a816dc0..0000000 --- a/scripts/benchmarks_core.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) -# - -"""Core library benchmarks for pyseq.""" - -import argparse -import json -import platform -import statistics -import sys -import tempfile -import time -from datetime import datetime, timezone -from pathlib import Path - -from pyseq import Sequence, get_sequences, uncompress -from pyseq import seq as pyseq -from pyseq.util import resolve_sequence_reference - - -PROFILE_CONFIG = { - "smoke": { - "iterations": 5, - "sizes": [100, 1000, 10000], - "mixed_factor": 2, - }, - "full": { - "iterations": 9, - "sizes": [100, 1000, 10000], - "mixed_factor": 3, - }, -} - - -def measure(func, iterations, warmups=1): - """Return timing statistics for a benchmark callable.""" - for _ in range(warmups): - func() - - samples = [] - for _ in range(iterations): - start = time.perf_counter() - func() - samples.append(time.perf_counter() - start) - - return { - "median_s": statistics.median(samples), - "min_s": min(samples), - "max_s": max(samples), - "iterations": iterations, - } - - -def contiguous_files(count): - return [f"render.{frame:04d}.exr" for frame in range(1, count + 1)] - - -def mixed_files(count, factor): - items = contiguous_files(count) - extras = [f"note_{index:05d}.txt" for index in range(count * (factor - 1))] - return sorted(items + extras) - - -def stepped_range_string(start, stop, step): - return f"render.%04d.exr {start}-{stop}x{step}" - - -def run_profile(profile): - config = PROFILE_CONFIG[profile] - pyseq.strict_pad = False - results = {} - - for size in config["sizes"]: - results[f"sequence_construct_{size}"] = measure( - lambda size=size: Sequence(contiguous_files(size)), - config["iterations"], - ) - - results[f"get_sequences_mixed_{size}"] = measure( - lambda size=size: get_sequences(mixed_files(size, config["mixed_factor"])), - config["iterations"], - ) - - results[f"uncompress_range_{size}"] = measure( - lambda size=size: uncompress( - f"render.%04d.exr 1001-{1000 + size}", fmt="%h%p%t %r" - ), - config["iterations"], - ) - - results["format_missing_sparse_large"] = measure( - lambda: get_sequences(["image-1.jpg", "image-1000.jpg", "image-50000000.jpg"])[ - 0 - ].format("%M"), - config["iterations"], - ) - - results["uncompress_stepped_10k"] = measure( - lambda: uncompress(stepped_range_string(1001, 10000, 3), fmt="%h%p%t %x"), - config["iterations"], - ) - - results["format_stepped_10k"] = measure( - lambda: uncompress( - stepped_range_string(1001, 10000, 3), fmt="%h%p%t %x" - ).format("%x"), - config["iterations"], - ) - - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - for frame in (1001, 1004, 1007, 1010): - (tmp / f"stepA.{frame:04d}.exr").write_text("frame\n") - - results["resolve_sequence_reference_stepped"] = measure( - lambda: resolve_sequence_reference( - str(tmp / "stepA.%04d.exr") + " 1001-1010x3" - ), - config["iterations"], - ) - - return results - - -def build_payload(profile): - return { - "kind": "core", - "profile": profile, - "python": platform.python_version(), - "platform": platform.platform(), - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - "benchmarks": run_profile(profile), - } - - -def write_summary(payload, stream): - print( - f"# pyseq core benchmarks ({payload['profile']})", - file=stream, - ) - print("", file=stream) - print("| Benchmark | Median (s) | Min (s) | Max (s) |", file=stream) - print("| --- | ---: | ---: | ---: |", file=stream) - for name, result in payload["benchmarks"].items(): - print( - f"| `{name}` | {result['median_s']:.6f} | {result['min_s']:.6f} | {result['max_s']:.6f} |", - file=stream, - ) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--profile", choices=sorted(PROFILE_CONFIG), default="smoke") - parser.add_argument("--json", dest="json_path") - parser.add_argument("--summary", action="store_true") - args = parser.parse_args() - - payload = build_payload(args.profile) - - if args.json_path: - with open(args.json_path, "w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, sort_keys=True) - handle.write("\n") - - if args.summary: - write_summary(payload, sys.stdout) - - if not args.json_path and not args.summary: - json.dump(payload, sys.stdout, indent=2, sort_keys=True) - sys.stdout.write("\n") - - -if __name__ == "__main__": - main() diff --git a/scripts/build_pages_site.py b/scripts/build_pages_site.py index 01e56ae..f21102d 100644 --- a/scripts/build_pages_site.py +++ b/scripts/build_pages_site.py @@ -62,10 +62,8 @@ def write_site_config(output_dir: Path): def write_benchmarks_page( output_dir: Path, - core_summary: Path = None, - cli_summary: Path = None, - core_json: Path = None, - cli_json: Path = None, + benchmark_summary: Path = None, + benchmark_json: Path = None, ): """Create a latest benchmark report page and copy JSON artifacts.""" benchmarks_dir = output_dir / "benchmarks" @@ -78,19 +76,13 @@ def write_benchmarks_page( "", ] - if core_summary and core_summary.exists(): - sections.extend([core_summary.read_text(encoding="utf-8").strip(), ""]) - - if cli_summary and cli_summary.exists(): - sections.extend([cli_summary.read_text(encoding="utf-8").strip(), ""]) + if benchmark_summary and benchmark_summary.exists(): + sections.extend([benchmark_summary.read_text(encoding="utf-8").strip(), ""]) downloads = [] - if core_json and core_json.exists(): - shutil.copy2(core_json, benchmarks_dir / "benchmark-core.json") - downloads.append("- [Core benchmark JSON](benchmark-core.json)") - if cli_json and cli_json.exists(): - shutil.copy2(cli_json, benchmarks_dir / "benchmark-cli.json") - downloads.append("- [CLI benchmark JSON](benchmark-cli.json)") + if benchmark_json and benchmark_json.exists(): + shutil.copy2(benchmark_json, benchmarks_dir / "benchmark.json") + downloads.append("- [Benchmark JSON](benchmark.json)") if downloads: sections.extend(["## Downloads", "", *downloads, ""]) @@ -132,10 +124,10 @@ def build_site(args): write_benchmarks_page( output_dir, - core_summary=Path(args.core_summary) if args.core_summary else None, - cli_summary=Path(args.cli_summary) if args.cli_summary else None, - core_json=Path(args.core_json) if args.core_json else None, - cli_json=Path(args.cli_json) if args.cli_json else None, + benchmark_summary=Path(args.benchmark_summary) + if args.benchmark_summary + else None, + benchmark_json=Path(args.benchmark_json) if args.benchmark_json else None, ) @@ -143,10 +135,8 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo-root", default=".") parser.add_argument("--output", required=True) - parser.add_argument("--core-summary") - parser.add_argument("--cli-summary") - parser.add_argument("--core-json") - parser.add_argument("--cli-json") + parser.add_argument("--benchmark-summary") + parser.add_argument("--benchmark-json") args = parser.parse_args() build_site(args) From 969ded6351fa165bd9f6bb0afa0e152941777de0 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 04:30:08 -0700 Subject: [PATCH 11/26] Make negative frame support opt-in and improve benchmark provenance --- README.md | 9 +- docs/examples.md | 8 +- docs/formatting.md | 6 +- docs/frame-patterns.md | 9 ++ docs/performance.md | 48 +++++++++ lib/pyseq/cli.py | 23 +++++ lib/pyseq/config.py | 44 ++++++++- lib/pyseq/frange.py | 34 +++---- lib/pyseq/lss.py | 2 +- lib/pyseq/scopy.py | 2 +- lib/pyseq/sdiff.py | 2 +- lib/pyseq/seq.py | 96 +++++++++++++----- lib/pyseq/sfind.py | 2 +- lib/pyseq/smove.py | 2 +- lib/pyseq/sremove.py | 3 +- lib/pyseq/sstat.py | 2 +- lib/pyseq/stree.py | 2 +- lib/pyseq/util.py | 99 +++++++------------ pyseq.env | 3 + scripts/benchmark.py | 216 +++++++++++++++++++++++++++++++++++------ tests/test_pyseq.py | 97 ++++++++++-------- tests/test_scopy.py | 9 ++ tests/test_smove.py | 9 ++ tests/test_srm.py | 8 ++ 24 files changed, 541 insertions(+), 194 deletions(-) create mode 100644 lib/pyseq/cli.py diff --git a/README.md b/README.md index 6b99300..1741687 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,14 @@ Supported serialized range forms include: - `1001-1100x2` - `1001-1100x10, 1200, 1300-1320x5` - `10-1x3` -- `-10--1x3` +- `-10--1x3` with `PYSEQ_ALLOW_NEGATIVE_FRAMES=1` + +Negative frame ranges are opt-in so the default filename discovery path stays +fast and unopinionated. Enable them with: + +```bash +export PYSEQ_ALLOW_NEGATIVE_FRAMES=1 +``` ## Production Usage diff --git a/docs/examples.md b/docs/examples.md index 3850670..98fd80a 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -119,7 +119,13 @@ render.%04d.exr 1001-1010x3 ## Parse Signed Serialized Frame Ranges -Serialized range strings may include signed frame numbers: +Signed frame ranges are opt-in. Enable them first: + +```bash +export PYSEQ_ALLOW_NEGATIVE_FRAMES=1 +``` + +Then serialized range strings may include signed frame numbers: ```python from pyseq import uncompress diff --git a/docs/formatting.md b/docs/formatting.md index da32182..ffb1914 100644 --- a/docs/formatting.md +++ b/docs/formatting.md @@ -43,8 +43,10 @@ Notes: - Descending ranges are accepted when parsing, but `Sequence.frames()` and `%x` formatting normalize frames into ascending order. - `%x` uses compact canonical formatting with no spaces after commas. -- Signed frame numbers are supported in serialized range strings and in - on-disk filename patterns such as `render.-0010.exr`. +- Negative frame ranges are opt-in. Enable them with + `PYSEQ_ALLOW_NEGATIVE_FRAMES=1`. +- With that flag enabled, signed frame numbers are supported in serialized + range strings and in on-disk filename patterns such as `render.-0010.exr`. ## CLI Examples diff --git a/docs/frame-patterns.md b/docs/frame-patterns.md index 3fd333a..90be590 100644 --- a/docs/frame-patterns.md +++ b/docs/frame-patterns.md @@ -3,6 +3,12 @@ Use `${PYSEQ_FRAME_PATTERN}` to define custom regular expressions for identifying frame numbers. +Signed frame numbers such as `render.-0010.exr` are opt-in. Enable them with: + +```bash +export PYSEQ_ALLOW_NEGATIVE_FRAMES=1 +``` + ## Example If frames are always preceded by an underscore: @@ -35,4 +41,7 @@ PYSEQ_FRAME_PATTERN: \.\d+\. # frame numbers start with an underscore, e.g. file_v1_1001.exr PYSEQ_FRAME_PATTERN: _\d+ + +# allow signed frame numbers in explicit ranges and filename discovery +PYSEQ_ALLOW_NEGATIVE_FRAMES: ${PYSEQ_ALLOW_NEGATIVE_FRAMES:=0} ``` diff --git a/docs/performance.md b/docs/performance.md index daf2713..bc1eb84 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -24,6 +24,8 @@ Simple examples: python scripts/benchmark.py python scripts/benchmark.py --full python scripts/benchmark.py --json /tmp/pyseq-main.json +python scripts/benchmark.py --path /project/shot/frames --iterations 5 +python scripts/benchmark.py --path /project/shot/frames --glob "*.exr" --iterations 5 python scripts/benchmark.py --compare /tmp/pyseq-main.json /tmp/pyseq-feature.json ``` @@ -34,6 +36,10 @@ What it measures: - `resolve_sequence(...)` against a padded on-disk sequence - `lss` end-to-end subprocess runtime on the same synthetic dataset +When using `--path`, the runner benchmarks an existing directory instead of a +synthetic dataset. This is useful for validating results against real-world +sequence layouts that may not be modeled well by generated fixtures. + Default synthetic dataset sizes: - default: `100`, `1000`, `10000` @@ -62,6 +68,38 @@ python scripts/benchmark.py --full --json /tmp/pyseq-feature.json python scripts/benchmark.py --compare /tmp/pyseq-main.json /tmp/pyseq-feature.json ``` +For real-world validation, use the same workflow against an existing directory: + +```bash +# on baseline branch +python scripts/benchmark.py --path /project/shot/frames --iterations 5 --json /tmp/pyseq-main.json + +# switch branches +python scripts/benchmark.py --path /project/shot/frames --iterations 5 --json /tmp/pyseq-feature.json + +# compare +python scripts/benchmark.py --compare /tmp/pyseq-main.json /tmp/pyseq-feature.json +``` + +## Benchmark Hygiene + +Before trusting an A/B result, make sure both runs use: + +- the same terminal/session state +- the same Python interpreter or virtualenv +- the same benchmark script revision +- the same target directory or synthetic dataset shape + +The benchmark summary and JSON now record: + +- `python_executable` +- `lib_root` +- `pyseq_file` +- `pyseq_seq_file` + +Check those fields before drawing conclusions from a comparison. If they do +not match your expectations, fix the environment first and rerun. + ## Profiling Profiling is enabled by default, but it runs in a separate pass after the @@ -113,10 +151,16 @@ Example run payload: "commit": "abc1234", "iterations": 5, "kind": "benchmark", + "lib_root": "/path/to/pyseq/lib", "platform": "Linux-6.x-x86_64", "profiled": true, + "pyseq_file": "/path/to/pyseq/lib/pyseq/__init__.py", + "pyseq_seq_file": "/path/to/pyseq/lib/pyseq/seq.py", + "python_executable": "/path/to/venv/bin/python", "profiles_dir": "tmp/benchmarks/profiles/20260721T000000Z-feat-example-abc1234", "python": "3.11.x", + "repo_root": "/path/to/pyseq", + "script_path": "/path/to/pyseq/scripts/benchmark.py", "sizes": [100, 1000, 10000], "timestamp_utc": "2026-07-21T00:00:00+00:00" } @@ -129,6 +173,10 @@ Field notes: - `mean_s` is useful when the run-to-run spread is small. - `samples_s` helps show spread directly. - `branch` and `commit` make branch-to-branch comparisons easier to track. +- `repo_root`, `lib_root`, `script_path`, and `python_executable` show + exactly which source tree and interpreter were used. +- `pyseq_file` and `pyseq_seq_file` confirm which imported package files were + actually exercised. - `profiles_dir` points to the profile output location for that run. - `timestamp_utc`, `python`, and `platform` help when comparing results. diff --git a/lib/pyseq/cli.py b/lib/pyseq/cli.py new file mode 100644 index 0000000..3d61fff --- /dev/null +++ b/lib/pyseq/cli.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# +# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# + +"""Lightweight helpers shared by CLI entry points.""" + +import functools +import sys + + +def cli_catch_keyboard_interrupt(func): + """Return exit code 1 instead of a traceback on Ctrl-C.""" + + @functools.wraps(func) + def inner(*args, **kwargs): + try: + return func(*args, **kwargs) + except KeyboardInterrupt: + print("stopping...", file=sys.stderr) + return 1 + + return inner diff --git a/lib/pyseq/config.py b/lib/pyseq/config.py index 8ac8b82..0396adf 100644 --- a/lib/pyseq/config.py +++ b/lib/pyseq/config.py @@ -50,13 +50,42 @@ strict_pad = int(PYSEQ_STRICT_PAD) == 1 or int(PYSEQ_NOT_STRICT) == 0 # regex pattern for matching frame numbers only -# the default preserves compatibility with common positive filenames while -# also allowing signed frame tokens like render.-0010.exr -DEFAULT_FRAME_PATTERN = r"(?:(?<=^)|(?<=[._]))-\d+|\d+" +# pyseq intentionally stays permissive here and lets sibling/diff logic decide +# whether matching numeric tokens represent the sequence frame component. +DEFAULT_FRAME_PATTERN = r"\d+" +DEFAULT_SIGNED_FRAME_PATTERN = r"(?:(?<=^)|(?<=[._]))-\d+|\d+" # regex pattern for matching all numeric sequence tokens in a filename digits_re = re.compile(DEFAULT_FRAME_PATTERN) PYSEQ_FRAME_PATTERN = os.getenv("PYSEQ_FRAME_PATTERN", DEFAULT_FRAME_PATTERN) +PYSEQ_ALLOW_NEGATIVE_FRAMES = os.getenv("PYSEQ_ALLOW_NEGATIVE_FRAMES", "0") + +# regex patterns for explicit frame-range syntax parsing/formatting +DEFAULT_FRAME_RANGE_SEGMENT_PATTERN = ( + r"^\s*(?P-?\d+)(?:\s*-\s*(?P-?\d+)(?:\s*x\s*(?P\d+))?)?\s*$" +) +DEFAULT_FRAME_RANGE_TEXT_PATTERN = r"-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?)*" +DEFAULT_SERIALIZED_RANGE_PATTERN = ( + rf"\[[^\]]+\]|\s+(?:{DEFAULT_FRAME_RANGE_TEXT_PATTERN})\s*$" +) +DEFAULT_EMBEDDED_RANGE_PATTERN = rf"^(?P.+?)(?P\[(?:[^\]]+)\]|{DEFAULT_FRAME_RANGE_TEXT_PATTERN})(?P\.[^/\s]+)$" + +frame_range_segment_re = re.compile(DEFAULT_FRAME_RANGE_SEGMENT_PATTERN) +frame_range_text_re = re.compile(DEFAULT_FRAME_RANGE_TEXT_PATTERN) +serialized_range_re = re.compile(DEFAULT_SERIALIZED_RANGE_PATTERN) +embedded_range_re = re.compile(DEFAULT_EMBEDDED_RANGE_PATTERN) + + +def allow_negative_frames() -> bool: + """Return True when explicit negative frame syntax is enabled.""" + return os.getenv("PYSEQ_ALLOW_NEGATIVE_FRAMES", PYSEQ_ALLOW_NEGATIVE_FRAMES) == "1" + + +def get_effective_frame_pattern(pattern: str = DEFAULT_FRAME_PATTERN) -> str: + """Return the configured frame pattern, promoting the default when needed.""" + if allow_negative_frames() and pattern == DEFAULT_FRAME_PATTERN: + return DEFAULT_SIGNED_FRAME_PATTERN + return pattern def set_frame_pattern(pattern: str = DEFAULT_FRAME_PATTERN): @@ -66,13 +95,18 @@ def set_frame_pattern(pattern: str = DEFAULT_FRAME_PATTERN): :param pattern: The regex pattern to use for matching frame numbers. """ global frames_re + global digits_re global PYSEQ_FRAME_PATTERN PYSEQ_FRAME_PATTERN = pattern try: - frames_re = re.compile(pattern) + compiled = re.compile(get_effective_frame_pattern(pattern)) + frames_re = compiled + digits_re = compiled except Exception as e: print("Error: Invalid regex pattern: %s" % e) - frames_re = re.compile(DEFAULT_FRAME_PATTERN) + fallback = re.compile(DEFAULT_FRAME_PATTERN) + frames_re = fallback + digits_re = fallback # set the default frame pattern diff --git a/lib/pyseq/frange.py b/lib/pyseq/frange.py index 6d1e13f..a675294 100644 --- a/lib/pyseq/frange.py +++ b/lib/pyseq/frange.py @@ -5,34 +5,19 @@ """Frame range parsing and formatting helpers.""" -import re from typing import List, Optional, Tuple -from pyseq.config import range_join - - -FRAME_RANGE_SEGMENT_RE = re.compile( - r"^\s*(?P-?\d+)(?:\s*-\s*(?P-?\d+)(?:\s*x\s*(?P\d+))?)?\s*$" -) -FRAME_RANGE_TEXT_RE = re.compile( - r"-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?)*" -) -SERIALIZED_RANGE_RE = re.compile( - rf"\[[^\]]+\]|\s+(?:{FRAME_RANGE_TEXT_RE.pattern})\s*$" -) -EMBEDDED_RANGE_RE = re.compile( - rf"^(?P.+?)(?P\[(?:[^\]]+)\]|{FRAME_RANGE_TEXT_RE.pattern})(?P\.[^/\s]+)$" -) +from pyseq import config def has_serialized_range(text: str) -> bool: """Return True when text includes explicit serialized frame range syntax.""" - return bool(SERIALIZED_RANGE_RE.search(text)) + return bool(config.serialized_range_re.search(text)) def split_embedded_frame_range(text: str) -> Optional[Tuple[str, str, str]]: """Split `headtail` strings like `plate.2-4.exr` into components.""" - match = EMBEDDED_RANGE_RE.match(text) + match = config.embedded_range_re.match(text) if not match: return None return match.group("head"), match.group("range"), match.group("tail") @@ -53,7 +38,7 @@ def parse_frame_range(range_text: str) -> List[int]: segment = segment.strip() if not segment: raise ValueError(f"Invalid frame range syntax: {range_text}") - match = FRAME_RANGE_SEGMENT_RE.match(segment) + match = config.frame_range_segment_re.match(segment) if not match: raise ValueError(f"Invalid frame range syntax: {segment}") @@ -61,11 +46,20 @@ def parse_frame_range(range_text: str) -> List[int]: end = match.group("end") step = match.group("step") + if start < 0 and not config.allow_negative_frames(): + raise ValueError( + "Negative frame ranges require PYSEQ_ALLOW_NEGATIVE_FRAMES=1" + ) + if end is None: frames.append(start) continue end = int(end) + if end < 0 and not config.allow_negative_frames(): + raise ValueError( + "Negative frame ranges require PYSEQ_ALLOW_NEGATIVE_FRAMES=1" + ) step = int(step) if step is not None else 1 if step <= 0: raise ValueError(f"Frame step must be positive: {segment}") @@ -139,7 +133,7 @@ def format_frame_range_explicit( else: frange.append(f"{start}-{end}") - body = range_join.join(frange) + body = config.range_join.join(frange) return f"[{body}]" if pad_with_brackets else body diff --git a/lib/pyseq/lss.py b/lib/pyseq/lss.py index 89a8333..1bcab89 100755 --- a/lib/pyseq/lss.py +++ b/lib/pyseq/lss.py @@ -41,7 +41,7 @@ from pyseq import __version__, get_sequences from pyseq import seq as pyseq -from pyseq.util import cli_catch_keyboard_interrupt +from pyseq.cli import cli_catch_keyboard_interrupt from pyseq import walk diff --git a/lib/pyseq/scopy.py b/lib/pyseq/scopy.py index 4e3a8bd..5a16185 100644 --- a/lib/pyseq/scopy.py +++ b/lib/pyseq/scopy.py @@ -40,8 +40,8 @@ from typing import Optional import pyseq +from pyseq.cli import cli_catch_keyboard_interrupt from pyseq.util import ( - cli_catch_keyboard_interrupt, parse_destination_reference, resolve_sequence_reference, ) diff --git a/lib/pyseq/sdiff.py b/lib/pyseq/sdiff.py index db534bd..2f274c8 100644 --- a/lib/pyseq/sdiff.py +++ b/lib/pyseq/sdiff.py @@ -38,7 +38,7 @@ import sys import pyseq -from pyseq.util import cli_catch_keyboard_interrupt +from pyseq.cli import cli_catch_keyboard_interrupt from pyseq.util import resolve_sequence diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index a007af8..548c775 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -44,7 +44,6 @@ from typing import List, Callable, Union from pyseq import config -from pyseq.util import _ext_key from pyseq.config import ( default_format, format_re, @@ -52,11 +51,6 @@ range_join, strict_pad, ) -from pyseq.frange import ( - format_frame_range_explicit, - format_frame_range_stepped, - parse_frame_range, -) class SequenceError(Exception): @@ -76,6 +70,37 @@ def _frame_width(frame: str) -> int: return len(frame[1:] if frame.startswith("-") else frame) +def _natural_key(x: str): + """Split a string into characters and digits for natural sorting.""" + return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", x)] + + +def _ext_key(x: str): + """Sort by extension first, then natural order of the basename.""" + name, ext = os.path.splitext(x) + return [ext] + _natural_key(name) + + +def _format_frame_range_explicit( + frames: List[int], pad_with_brackets: bool = True +) -> str: + from pyseq.frange import format_frame_range_explicit + + return format_frame_range_explicit(frames, pad_with_brackets=pad_with_brackets) + + +def _format_frame_range_stepped(frames: List[int]) -> str: + from pyseq.frange import format_frame_range_stepped + + return format_frame_range_stepped(frames) + + +def _parse_frame_range(range_text: str) -> List[int]: + from pyseq.frange import parse_frame_range + + return parse_frame_range(range_text) + + def padsize(item, frame): """ Determines the pad size for a given Item. Return value may depend on @@ -116,19 +141,20 @@ def __init__(self, item: Union[str, os.PathLike]): """ super(Item, self).__init__() self.item = item - self.__path = getattr(item, "path", None) - if self.__path is None: + if isinstance(item, Item): + self.__path = item.path + else: self.__path = str(item) self.__filename = os.path.basename(self.__path) self.__number_matches = [] - self.__parts = config.frames_re.split(self.name) + self.__parts = config.frames_re.split(self.__filename) self.__stat = None # modified by self.is_sibling() - self.frame = getattr(item, "frame", None) - self.head = getattr(item, "head", self.name) - self.tail = getattr(item, "tail", "") - self.pad = getattr(item, "pad", None) + self.frame = None + self.head = self.__filename + self.tail = "" + self.pad = None def __eq__(self, other): """ @@ -346,6 +372,11 @@ def is_sibling(self, item: str): return is_sibling +def _ensure_item(item: Union[Item, str, os.PathLike]) -> Item: + """Return the original Item or wrap path-like input in an Item.""" + return item if isinstance(item, Item) else Item(item) + + class Sequence(list): """Extends list class with methods that handle item sequentialness. @@ -376,13 +407,13 @@ def __init__(self, items: List[str]): """ # otherwise Sequence consumes the list items = deque(items[::]) - super(Sequence, self).__init__([Item(items.popleft())]) + super(Sequence, self).__init__([_ensure_item(items.popleft())]) self.__missing = [] self.__dirty = False self.__frames = None while items: - f = Item(items.popleft()) + f = _ensure_item(items.popleft()) try: self.append(f) except SequenceError: @@ -406,7 +437,7 @@ def __attrs__(self): "p": self._get_padding, "r": functools.partial(self._get_framerange, self.frames(), missing=False), "R": functools.partial(self._get_framerange, self.frames(), missing=True), - "x": functools.partial(format_frame_range_stepped, self.frames()), + "x": functools.partial(_format_frame_range_stepped, self.frames()), "h": self.head, "t": self.tail, } @@ -890,7 +921,7 @@ def _get_framerange(self, frames: List[int], missing: bool = True): continue expanded.append(frame) - explicit = format_frame_range_explicit(expanded, pad_with_brackets=False) + explicit = _format_frame_range_explicit(expanded, pad_with_brackets=False) if explicit: frange.extend(explicit.split(range_join)) if saw_ranges: @@ -1009,15 +1040,27 @@ def uncompress(seq_string: str, fmt: str = global_format): name = os.path.basename(seq_string) # map of directives to regex + allow_negative = config.allow_negative_frames() + range_pattern = ( + r"-?\d+\s*-\s*-?\d+(?:\s*x\s*\d+)?" + if allow_negative + else r"\d+\s*-\s*\d+(?:\s*x\s*\d+)?" + ) + explicit_pattern = ( + r"-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?)*" + if allow_negative + else r"\d+(?:\s*-\s*\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*\d+(?:\s*-\s*\d+(?:\s*x\s*\d+)?)?)*" + ) + remap = { "s": r"\d+", "e": r"\d+", "l": r"\d+", "h": r"(.+)?", "t": r"(\S+)?", - "r": r"-?\d+\s*-\s*-?\d+(?:\s*x\s*\d+)?", + "r": range_pattern, "R": r"\[[^\]]+\]", - "x": r"-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?)*", + "x": explicit_pattern, "p": r"%\d+d", "m": r"\[.*\]", "f": r"\[.*\]", @@ -1054,7 +1097,7 @@ def uncompress(seq_string: str, fmt: str = global_format): try: R = match.group("R") - frame_values = parse_frame_range(R) + frame_values = _parse_frame_range(R) pad_len = max((len(str(abs(frame))) for frame in frame_values), default=0) if pad == "%d" and pad_len != 0: pad = "%0" + str(pad_len) + "d" @@ -1062,7 +1105,7 @@ def uncompress(seq_string: str, fmt: str = global_format): except IndexError: try: r = match.group("r") - frame_values = parse_frame_range(r) + frame_values = _parse_frame_range(r) if frame_values: s = frame_values[0] e = frame_values[-1] @@ -1070,12 +1113,12 @@ def uncompress(seq_string: str, fmt: str = global_format): except IndexError: try: x = match.group("x") - frame_values = parse_frame_range(x) + frame_values = _parse_frame_range(x) except IndexError: s = match.group("s") e = match.group("e") if s is not None and e is not None: - frame_values = parse_frame_range(f"{s}-{e}") + frame_values = _parse_frame_range(f"{s}-{e}") try: frame_values = literal_eval(match.group("f")) @@ -1095,7 +1138,7 @@ def uncompress(seq_string: str, fmt: str = global_format): tail = match.groupdict().get("t", "") item_pad = 0 if pad == "%d" else int(re.search(r"\d+", pad).group()) if missing: - for i in parse_frame_range(f"{s}-{e}"): + for i in _parse_frame_range(f"{s}-{e}"): if i in missing: continue f = ("-" if i < 0 else "") + (pad % abs(i)) @@ -1177,6 +1220,9 @@ def get_sequences(source: str, frame_pattern: str = config.PYSEQ_FRAME_PATTERN): if isinstance(source, list): items = sorted(source, key=lambda x: str(x)) + if items and isinstance(items[0], Item): + for item in items: + item._Item__parts = config.frames_re.split(item.name) elif isinstance(source, str): if os.path.isdir(source): @@ -1191,7 +1237,7 @@ def get_sequences(source: str, frame_pattern: str = config.PYSEQ_FRAME_PATTERN): # organize the items into sequences while items: - item = Item(items.popleft()) + item = _ensure_item(items.popleft()) found = False for seq in reversed(seqs): if seq.includes(item): diff --git a/lib/pyseq/sfind.py b/lib/pyseq/sfind.py index 655445c..3fa937b 100644 --- a/lib/pyseq/sfind.py +++ b/lib/pyseq/sfind.py @@ -39,7 +39,7 @@ import sys import pyseq -from pyseq.util import cli_catch_keyboard_interrupt +from pyseq.cli import cli_catch_keyboard_interrupt def walk_and_collect_sequences( diff --git a/lib/pyseq/smove.py b/lib/pyseq/smove.py index cdbe79a..86a99bc 100644 --- a/lib/pyseq/smove.py +++ b/lib/pyseq/smove.py @@ -40,8 +40,8 @@ from typing import Optional import pyseq +from pyseq.cli import cli_catch_keyboard_interrupt from pyseq.util import ( - cli_catch_keyboard_interrupt, parse_destination_reference, resolve_sequence_reference, ) diff --git a/lib/pyseq/sremove.py b/lib/pyseq/sremove.py index 20fe685..1ab1189 100644 --- a/lib/pyseq/sremove.py +++ b/lib/pyseq/sremove.py @@ -38,7 +38,8 @@ import sys import pyseq -from pyseq.util import cli_catch_keyboard_interrupt, resolve_sequence_reference +from pyseq.cli import cli_catch_keyboard_interrupt +from pyseq.util import resolve_sequence_reference def remove_sequence( diff --git a/lib/pyseq/sstat.py b/lib/pyseq/sstat.py index f25540b..4f084c8 100644 --- a/lib/pyseq/sstat.py +++ b/lib/pyseq/sstat.py @@ -40,7 +40,7 @@ import sys import pyseq -from pyseq.util import cli_catch_keyboard_interrupt +from pyseq.cli import cli_catch_keyboard_interrupt from pyseq.util import resolve_sequence diff --git a/lib/pyseq/stree.py b/lib/pyseq/stree.py index da81506..6aae7d5 100644 --- a/lib/pyseq/stree.py +++ b/lib/pyseq/stree.py @@ -39,7 +39,7 @@ import pyseq from pyseq import config -from pyseq.util import cli_catch_keyboard_interrupt +from pyseq.cli import cli_catch_keyboard_interrupt def get_tree_tokens(): diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index cdbfae0..322de21 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -39,12 +39,9 @@ from typing import Optional import pyseq +from pyseq.cli import cli_catch_keyboard_interrupt +from pyseq import config from pyseq.config import range_join -from pyseq.frange import ( - has_serialized_range, - parse_frame_range, - split_embedded_frame_range, -) def deprecated(func): @@ -64,20 +61,6 @@ def inner(*args, **kwargs): return inner -def cli_catch_keyboard_interrupt(func): - """Return exit code 1 instead of a traceback on Ctrl-C.""" - - @functools.wraps(func) - def inner(*args, **kwargs): - try: - return func(*args, **kwargs) - except KeyboardInterrupt: - print("stopping...", file=sys.stderr) - return 1 - - return inner - - def _natural_key(x: str): """Splits a string into characters and digits. @@ -87,29 +70,6 @@ def _natural_key(x: str): return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", x)] -def _ext_key(x: str): - """Similar to `_natural_key` except this one uses the file extension at - the head of split string. This fixes issues with files that are named - similar but with different file extensions: - - This example: - - file.001.jpg - file.001.tiff - file.002.jpg - file.002.tiff - - Would get properly sorted into: - - file.001.jpg - file.002.jpg - file.001.tiff - file.002.tiff - """ - name, ext = os.path.splitext(x) - return [ext] + _natural_key(name) - - def is_compressed_format_string(s: str) -> bool: """Check if the string is a compressed format string. A compressed format string is a string that contains a format specifier for integers, such as @@ -132,12 +92,13 @@ def natural_sort(items: list): return sorted(items, key=_natural_key) -def resolve_sequence(sequence_string: str): +def resolve_sequence(sequence_string: str, include_negative: bool = False): """Given a compressed sequence string like 'file.%04d.png' or '/path/to/file.%04d.png', return a `Sequence` object of matching files on disk. :param sequence_string: The compressed sequence string to be uncompressed. + :param include_negative: When True, also scan for signed padded frames. :return: A pyseq.Sequence object of matching files. """ @@ -153,28 +114,30 @@ def resolve_sequence(sequence_string: str): padding = match.group(1) if padding: pad = int(padding) - positive_glob = filename.replace(f"%0{pad}d", "?" * pad) - negative_glob = filename.replace(f"%0{pad}d", "-" + ("?" * pad)) - regex_pattern = re.escape(filename).replace( - f"%0{pad}d", r"-?\d{" + str(pad) + r"}" + frame_token = f"%0{pad}d" + positive_glob = filename.replace(frame_token, "?" * pad) + negative_glob = filename.replace(frame_token, "-" + ("?" * pad)) + frame_pattern = ( + r"-?\d{" + str(pad) + r"}" if include_negative else r"\d{" + str(pad) + r"}" ) + regex_pattern = re.escape(filename).replace(frame_token, frame_pattern) else: positive_glob = filename.replace("%d", "*") negative_glob = None - regex_pattern = re.escape(filename).replace("%d", r"-?\d+") - - # Keep globbing narrow for padded sequences, including signed variants. - glob_patterns = [os.path.join(directory, positive_glob)] - if negative_glob and negative_glob != positive_glob: - glob_patterns.append(os.path.join(directory, negative_glob)) - - candidate_files = [] - seen = set() - for glob_pattern in glob_patterns: - for candidate in glob.glob(glob_pattern): - if candidate not in seen: - seen.add(candidate) - candidate_files.append(candidate) + frame_pattern = r"-?\d+" if include_negative else r"\d+" + regex_pattern = re.escape(filename).replace("%d", frame_pattern) + + candidate_files = glob.glob(os.path.join(directory, positive_glob)) + if include_negative and negative_glob and negative_glob != positive_glob: + negative_files = glob.glob(os.path.join(directory, negative_glob)) + if candidate_files: + candidate_files.extend( + candidate + for candidate in negative_files + if candidate not in candidate_files + ) + else: + candidate_files = negative_files # filter using regex (because glob pattern is wide) regex = re.compile(f"^{regex_pattern}$") @@ -228,6 +191,12 @@ def subset_sequence(seq, frames): def parse_explicit_sequence_string(reference: str): """Parse a serialized sequence string, including embedded range syntax.""" + from pyseq.frange import ( + has_serialized_range, + parse_frame_range, + split_embedded_frame_range, + ) + dirname = os.path.dirname(reference) or "." basename = os.path.basename(reference) has_explicit_range = has_serialized_range(basename) @@ -304,7 +273,13 @@ def resolve_sequence_reference(reference: str): requested_seq.tail(), ), ) - full_seq = resolve_sequence(pattern) + full_seq = resolve_sequence( + pattern, + include_negative=( + config.allow_negative_frames() + and any(frame < 0 for frame in requested_seq.frames()) + ), + ) else: sequences = pyseq.get_sequences(os.listdir(dirname)) candidates = [ diff --git a/pyseq.env b/pyseq.env index 6a592ae..d580408 100755 --- a/pyseq.env +++ b/pyseq.env @@ -13,6 +13,9 @@ all: &all # frame numbers start with an underscore, e.g. file_v1_1001.exr # PYSEQ_FRAME_PATTERN: _\d+ + # allow signed frame numbers in explicit ranges and filename discovery + # PYSEQ_ALLOW_NEGATIVE_FRAMES: 1 + # sequence string format: 4 file01_%04d.exr [40-43] (default) PYSEQ_GLOBAL_FORMAT: "%4l %h%p%t %R" diff --git a/scripts/benchmark.py b/scripts/benchmark.py index 8d04cca..fd07bb9 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -24,9 +24,12 @@ REPO_ROOT = Path(__file__).resolve().parent.parent LIB_ROOT = REPO_ROOT / "lib" +SCRIPT_PATH = Path(__file__).resolve() if str(LIB_ROOT) not in sys.path: sys.path.insert(0, str(LIB_ROOT)) +import pyseq # noqa: E402 +import pyseq.seq as pyseq_seq # noqa: E402 from pyseq import get_sequences # noqa: E402 from pyseq.util import resolve_sequence # noqa: E402 @@ -140,15 +143,35 @@ def create_dataset(root, size, extra_files_factor=EXTRA_FILES_FACTOR): } -def run_lss(path): +def create_existing_dataset(path, sequence_pattern=None, glob_pattern=None): + dataset = Path(path).resolve() + file_names = sorted(os.listdir(dataset)) + if glob_pattern: + import fnmatch + + file_names = [ + name for name in file_names if fnmatch.fnmatch(name, glob_pattern) + ] + + return { + "path": dataset, + "sequence_pattern": sequence_pattern, + "file_names": file_names, + } + + +def run_lss(path, glob_pattern=None): env = os.environ.copy() env["PYTHONPATH"] = ( str(LIB_ROOT) if not env.get("PYTHONPATH") else str(LIB_ROOT) + os.pathsep + env["PYTHONPATH"] ) + target = str(path) + if glob_pattern: + target = str(Path(path) / glob_pattern) subprocess.run( - [sys.executable, "-m", "pyseq.lss", str(path)], + [sys.executable, "-m", "pyseq.lss", target], cwd=REPO_ROOT, env=env, check=True, @@ -174,7 +197,7 @@ def profile_python_callable(func, stem, profiles_dir): text_path.write_text(stream.getvalue(), encoding="utf-8") -def profile_lss_subprocess(path, stem, profiles_dir): +def profile_lss_subprocess(path, stem, profiles_dir, glob_pattern=None): env = os.environ.copy() env["PYTHONPATH"] = ( str(LIB_ROOT) @@ -183,6 +206,9 @@ def profile_lss_subprocess(path, stem, profiles_dir): ) pstats_path = profiles_dir / f"{stem}.pstats" text_path = profiles_dir / f"{stem}.txt" + target = str(path) + if glob_pattern: + target = str(Path(path) / glob_pattern) subprocess.run( [ @@ -193,7 +219,7 @@ def profile_lss_subprocess(path, stem, profiles_dir): str(pstats_path), "-m", "pyseq.lss", - str(path), + target, ], cwd=REPO_ROOT, env=env, @@ -209,57 +235,97 @@ def profile_lss_subprocess(path, stem, profiles_dir): text_path.write_text(stream.getvalue(), encoding="utf-8") +def _path_stem(path): + return safe_path_token(str(Path(path).resolve())) + + @contextmanager -def build_benchmarks(sizes): +def build_benchmarks(sizes=None, path=None, sequence_pattern=None, glob_pattern=None): with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) cases = [] + fixtures = [] + + if path: + fixtures.append( + { + "label": _path_stem(path), + **create_existing_dataset( + path, + sequence_pattern=sequence_pattern, + glob_pattern=glob_pattern, + ), + } + ) + else: + root = Path(tmpdir) + for size in sizes: + fixture = create_dataset(root, size) + fixtures.append({"label": str(size), **fixture}) - for size in sizes: - fixture = create_dataset(root, size) + for fixture in fixtures: dataset_path = fixture["path"] file_names = fixture["file_names"] sequence_pattern = fixture["sequence_pattern"] + label = fixture["label"] cases.extend( [ { - "name": f"get_sequences_list_{size}", + "name": f"get_sequences_list_{label}", "callable": lambda file_names=file_names: get_sequences( file_names ), "profile_kind": "python", }, { - "name": f"get_sequences_dir_{size}", - "callable": lambda dataset_path=dataset_path: get_sequences( - str(dataset_path) + "name": f"get_sequences_dir_{label}", + "callable": lambda dataset_path=dataset_path, glob_pattern=glob_pattern: get_sequences( + str(Path(dataset_path) / glob_pattern) + if glob_pattern + else str(dataset_path) ), "profile_kind": "python", }, { - "name": f"resolve_sequence_{size}", - "callable": lambda sequence_pattern=sequence_pattern: resolve_sequence( - sequence_pattern - ), - "profile_kind": "python", - }, - { - "name": f"lss_{size}", - "callable": lambda dataset_path=dataset_path: run_lss( - dataset_path + "name": f"lss_{label}", + "callable": lambda dataset_path=dataset_path, glob_pattern=glob_pattern: run_lss( + dataset_path, glob_pattern=glob_pattern ), "profile_kind": "lss", "path": dataset_path, + "glob_pattern": glob_pattern, }, ] ) + if sequence_pattern: + cases.append( + { + "name": f"resolve_sequence_{label}", + "callable": lambda sequence_pattern=sequence_pattern: resolve_sequence( + sequence_pattern + ), + "profile_kind": "python", + } + ) yield cases -def run_benchmarks(sizes, iterations, enable_profile, profiles_dir): - with build_benchmarks(sizes) as cases: +def run_benchmarks( + sizes, + iterations, + enable_profile, + profiles_dir, + path=None, + sequence_pattern=None, + glob_pattern=None, +): + with build_benchmarks( + sizes=sizes, + path=path, + sequence_pattern=sequence_pattern, + glob_pattern=glob_pattern, + ) as cases: results = {} for case in cases: results[case["name"]] = benchmark_call(case["callable"], iterations) @@ -272,12 +338,25 @@ def run_benchmarks(sizes, iterations, enable_profile, profiles_dir): case["callable"], case["name"], profiles_dir ) else: - profile_lss_subprocess(case["path"], case["name"], profiles_dir) + profile_lss_subprocess( + case["path"], + case["name"], + profiles_dir, + glob_pattern=case.get("glob_pattern"), + ) return results -def build_run_payload(sizes, iterations, enable_profile, profiles_dir): +def build_run_payload( + sizes, + iterations, + enable_profile, + profiles_dir, + path=None, + sequence_pattern=None, + glob_pattern=None, +): branch = detect_git_branch() commit = detect_git_sha() actual_profiles_dir = profiles_dir @@ -289,17 +368,29 @@ def build_run_payload(sizes, iterations, enable_profile, profiles_dir): iterations=iterations, enable_profile=enable_profile, profiles_dir=actual_profiles_dir, + path=path, + sequence_pattern=sequence_pattern, + glob_pattern=glob_pattern, ) return { "kind": "benchmark", "branch": branch, "commit": commit, + "repo_root": str(REPO_ROOT), + "lib_root": str(LIB_ROOT), + "script_path": str(SCRIPT_PATH), + "python_executable": sys.executable, + "pyseq_file": getattr(pyseq, "__file__", None), + "pyseq_seq_file": getattr(pyseq_seq, "__file__", None), "python": platform.python_version(), "platform": platform.platform(), "timestamp_utc": datetime.now(timezone.utc).isoformat(), "sizes": sizes, "iterations": iterations, + "path": str(Path(path).resolve()) if path else None, + "sequence_pattern": sequence_pattern, + "glob_pattern": glob_pattern, "profiled": enable_profile, "profiles_dir": str(actual_profiles_dir) if actual_profiles_dir else None, "benchmarks": benchmarks, @@ -328,10 +419,22 @@ def build_comparison(baseline, candidate): "baseline": { "branch": baseline.get("branch"), "commit": baseline.get("commit"), + "repo_root": baseline.get("repo_root"), + "lib_root": baseline.get("lib_root"), + "script_path": baseline.get("script_path"), + "python_executable": baseline.get("python_executable"), + "pyseq_file": baseline.get("pyseq_file"), + "pyseq_seq_file": baseline.get("pyseq_seq_file"), }, "candidate": { "branch": candidate.get("branch"), "commit": candidate.get("commit"), + "repo_root": candidate.get("repo_root"), + "lib_root": candidate.get("lib_root"), + "script_path": candidate.get("script_path"), + "python_executable": candidate.get("python_executable"), + "pyseq_file": candidate.get("pyseq_file"), + "pyseq_seq_file": candidate.get("pyseq_seq_file"), }, "benchmarks": comparisons, "summary": { @@ -346,9 +449,22 @@ def write_run_summary(payload, stream): print("# pyseq benchmarks", file=stream) print("", file=stream) print( - f"Branch: `{payload['branch']}` Commit: `{payload['commit']}` Sizes: `{','.join(str(size) for size in payload['sizes'])}` Iterations: `{payload['iterations']}`", + f"Branch: `{payload['branch']}` Commit: `{payload['commit']}` Sizes: `{','.join(str(size) for size in payload['sizes']) if payload['sizes'] else 'custom'}` Iterations: `{payload['iterations']}`", file=stream, ) + print(f"Repo: `{payload['repo_root']}`", file=stream) + print(f"Lib: `{payload['lib_root']}`", file=stream) + print(f"Python: `{payload['python_executable']}`", file=stream) + if payload.get("pyseq_file"): + print(f"PySeq: `{payload['pyseq_file']}`", file=stream) + if payload.get("pyseq_seq_file"): + print(f"PySeq seq: `{payload['pyseq_seq_file']}`", file=stream) + if payload.get("path"): + print(f"Path: `{payload['path']}`", file=stream) + if payload.get("glob_pattern"): + print(f"Glob: `{payload['glob_pattern']}`", file=stream) + if payload.get("sequence_pattern"): + print(f"Sequence pattern: `{payload['sequence_pattern']}`", file=stream) if payload["profiled"] and payload["profiles_dir"]: print(f"Profiles: `{payload['profiles_dir']}`", file=stream) print("", file=stream) @@ -368,6 +484,30 @@ def write_compare_summary(payload, stream): f"Baseline: `{payload['baseline']['branch']}` `{payload['baseline']['commit']}` Candidate: `{payload['candidate']['branch']}` `{payload['candidate']['commit']}`", file=stream, ) + if payload["baseline"].get("lib_root") or payload["candidate"].get("lib_root"): + print( + f"Baseline lib: `{payload['baseline'].get('lib_root')}` Candidate lib: `{payload['candidate'].get('lib_root')}`", + file=stream, + ) + if payload["baseline"].get("python_executable") or payload["candidate"].get( + "python_executable" + ): + print( + f"Baseline python: `{payload['baseline'].get('python_executable')}` Candidate python: `{payload['candidate'].get('python_executable')}`", + file=stream, + ) + if payload["baseline"].get("pyseq_file") or payload["candidate"].get("pyseq_file"): + print( + f"Baseline pyseq: `{payload['baseline'].get('pyseq_file')}` Candidate pyseq: `{payload['candidate'].get('pyseq_file')}`", + file=stream, + ) + if payload["baseline"].get("pyseq_seq_file") or payload["candidate"].get( + "pyseq_seq_file" + ): + print( + f"Baseline pyseq.seq: `{payload['baseline'].get('pyseq_seq_file')}` Candidate pyseq.seq: `{payload['candidate'].get('pyseq_seq_file')}`", + file=stream, + ) print("", file=stream) print( "| Benchmark | Baseline Median (s) | Candidate Median (s) | Delta (s) | Delta (%) |", @@ -419,6 +559,19 @@ def main(): "--profiles-dir", help="Directory for generated .pstats and text profile summaries", ) + parser.add_argument( + "--path", + help="Benchmark an existing directory instead of synthetic datasets", + ) + parser.add_argument( + "--glob", + dest="glob_pattern", + help="Optional glob pattern for lss and file list filtering when using --path", + ) + parser.add_argument( + "--sequence-pattern", + help="Optional compressed sequence pattern for resolve_sequence when using --path", + ) args = parser.parse_args() if args.compare: @@ -431,7 +584,11 @@ def main(): write_compare_summary(payload, sys.stdout) return - sizes = args.sizes or (FULL_SIZES if args.full else DEFAULT_SIZES) + sizes = ( + None + if args.path + else (args.sizes or (FULL_SIZES if args.full else DEFAULT_SIZES)) + ) iterations = args.iterations or ( FULL_ITERATIONS if args.full else DEFAULT_ITERATIONS ) @@ -441,6 +598,9 @@ def main(): iterations=iterations, enable_profile=not args.no_profile, profiles_dir=profiles_dir, + path=args.path, + sequence_pattern=args.sequence_pattern, + glob_pattern=args.glob_pattern, ) if args.json_path: diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index afa7a23..ad20b18 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -40,6 +40,7 @@ import unittest import subprocess import sys +from unittest import mock sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from conftest import get_installed_command @@ -56,6 +57,10 @@ def assert_read_only_attribute_error(message): assert any(snippet in message for snippet in valid_snippets), message +def enable_negative_frames(): + return mock.patch.dict(os.environ, {"PYSEQ_ALLOW_NEGATIVE_FRAMES": "1"}) + + class ItemTestCase(unittest.TestCase): """tests the Item class""" @@ -526,9 +531,10 @@ def test_uncompress_extended_range_with_step(self): self.assertEqual(seq.format("%x"), "1001-1010x3") def test_uncompress_extended_range_negative_frames(self): - seq = uncompress("render.%04d.exr -10--1x3", fmt="%h%p%t %x") - self.assertEqual(seq.frames(), [-10, -7, -4, -1]) - self.assertEqual(seq.format("%h%p%t %x"), "render.%04d.exr -10--1x3") + with enable_negative_frames(): + seq = uncompress("render.%04d.exr -10--1x3", fmt="%h%p%t %x") + self.assertEqual(seq.frames(), [-10, -7, -4, -1]) + self.assertEqual(seq.format("%h%p%t %x"), "render.%04d.exr -10--1x3") def test_uncompress_extended_range_descending(self): seq = uncompress("render.%04d.exr 10-1x3", fmt="%h%p%t %x") @@ -651,55 +657,61 @@ def test_resolve_sequence_reference_with_descending_embedded_range(self): ) def test_get_sequences_with_negative_filenames(self): - pyseq.strict_pad = True - seq = get_sequences( - [ - "render.-0002.exr", - "render.-0001.exr", - "render.0000.exr", - "render.0001.exr", - ] - )[0] + with enable_negative_frames(): + pyseq.strict_pad = True + seq = get_sequences( + [ + "render.-0002.exr", + "render.-0001.exr", + "render.0000.exr", + "render.0001.exr", + ] + )[0] - self.assertEqual(seq.frames(), [-2, -1, 0, 1]) - self.assertEqual(seq.format("%h%p%t %x"), "render.%04d.exr -2-1") + self.assertEqual(seq.frames(), [-2, -1, 0, 1]) + self.assertEqual(seq.format("%h%p%t %x"), "render.%04d.exr -2-1") def test_get_sequences_with_negative_fixture_files(self): - pyseq.strict_pad = True - seq = get_sequences("./tests/files/negA*")[0] + with enable_negative_frames(): + pyseq.strict_pad = True + seq = get_sequences("./tests/files/negA*")[0] - self.assertEqual(str(seq), "negA.-2-1.exr") - self.assertEqual(seq.frames(), [-2, -1, 0, 1]) - self.assertEqual(seq.format("%h%p%t %x"), "negA.%04d.exr -2-1") + self.assertEqual(str(seq), "negA.-2-1.exr") + self.assertEqual(seq.frames(), [-2, -1, 0, 1]) + self.assertEqual(seq.format("%h%p%t %x"), "negA.%04d.exr -2-1") def test_resolve_sequence_reference_with_negative_filenames(self): - pyseq.strict_pad = True - with tempfile.TemporaryDirectory() as tmpdir: - for frame in (-2, -1, 0, 1): - if frame < 0: - frame_text = f"-{abs(frame):04d}" - else: - frame_text = f"{frame:04d}" - with open( - os.path.join(tmpdir, f"render.{frame_text}.exr"), "w" - ) as handle: - handle.write("dummy frame") + with enable_negative_frames(): + pyseq.strict_pad = True + with tempfile.TemporaryDirectory() as tmpdir: + for frame in (-2, -1, 0, 1): + if frame < 0: + frame_text = f"-{abs(frame):04d}" + else: + frame_text = f"{frame:04d}" + with open( + os.path.join(tmpdir, f"render.{frame_text}.exr"), "w" + ) as handle: + handle.write("dummy frame") + + seq, dirname = resolve_sequence_reference( + os.path.join(tmpdir, "render.%04d.exr -2-1") + ) + + self.assertEqual(dirname, tmpdir) + self.assertEqual(seq.frames(), [-2, -1, 0, 1]) + self.assertEqual(seq.format("%h%p%t %x"), "render.%04d.exr -2-1") + def test_resolve_sequence_reference_with_negative_fixture_files(self): + with enable_negative_frames(): + pyseq.strict_pad = True seq, dirname = resolve_sequence_reference( - os.path.join(tmpdir, "render.%04d.exr -2-1") + "./tests/files/negA.%04d.exr -2-1" ) - self.assertEqual(dirname, tmpdir) + self.assertEqual(dirname, "./tests/files") self.assertEqual(seq.frames(), [-2, -1, 0, 1]) - self.assertEqual(seq.format("%h%p%t %x"), "render.%04d.exr -2-1") - - def test_resolve_sequence_reference_with_negative_fixture_files(self): - pyseq.strict_pad = True - seq, dirname = resolve_sequence_reference("./tests/files/negA.%04d.exr -2-1") - - self.assertEqual(dirname, "./tests/files") - self.assertEqual(seq.frames(), [-2, -1, 0, 1]) - self.assertEqual(seq.format("%h%p%t %x"), "negA.%04d.exr -2-1") + self.assertEqual(seq.format("%h%p%t %x"), "negA.%04d.exr -2-1") class LSSTestCase(unittest.TestCase): @@ -743,7 +755,8 @@ def test_lss_is_working_properly_1(self): 3 fileA.%04d.jpg [1-3] 3 fileA.%04d.png [1-3] 1 file_02.tif - 4 negA.%04d.exr [-2-1] + 2 negA.-%04d.exr [1-2] + 2 negA.%04d.exr [0-1] 4 stepA.%d.exr [1001, 1004, 1007, 1010] 4 z1_001_v1.%d.png [1-4] 4 z1_002_v1.%d.png [1-4] diff --git a/tests/test_scopy.py b/tests/test_scopy.py index f36c548..d6bf8df 100644 --- a/tests/test_scopy.py +++ b/tests/test_scopy.py @@ -49,6 +49,12 @@ def _signed_frame_name(frame): return f"{frame:05d}" if frame < 0 else f"{frame:04d}" +def _negative_env(): + env = os.environ.copy() + env["PYSEQ_ALLOW_NEGATIVE_FRAMES"] = "1" + return env + + @pytest.fixture def sample_sequence(tmp_path): """Create a dummy sequence: test.0001.exr through test.0003.exr""" @@ -115,6 +121,7 @@ def test_scopy_cli_explicit_sequence_string_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=_negative_env(), ) assert result.returncode == 0, result.stderr @@ -137,6 +144,7 @@ def test_scopy_cli_embedded_range_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=_negative_env(), ) assert result.returncode == 0, result.stderr @@ -159,6 +167,7 @@ def test_scopy_cli_negative_sequence_string_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=_negative_env(), ) assert result.returncode == 0, result.stderr diff --git a/tests/test_smove.py b/tests/test_smove.py index 0baf4c3..b72db99 100644 --- a/tests/test_smove.py +++ b/tests/test_smove.py @@ -49,6 +49,12 @@ def _signed_frame_name(frame): return f"{frame:05d}" if frame < 0 else f"{frame:04d}" +def _negative_env(): + env = os.environ.copy() + env["PYSEQ_ALLOW_NEGATIVE_FRAMES"] = "1" + return env + + @pytest.fixture def sample_sequence(): """Fixture to create a temporary directory with a sequence of files.""" @@ -216,6 +222,7 @@ def test_smv_cli_explicit_sequence_string_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=_negative_env(), ) assert result.returncode == 0, result.stderr @@ -240,6 +247,7 @@ def test_smv_cli_embedded_range_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=_negative_env(), ) assert result.returncode == 0, result.stderr @@ -264,6 +272,7 @@ def test_smv_cli_negative_sequence_string_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=_negative_env(), ) assert result.returncode == 0, result.stderr diff --git a/tests/test_srm.py b/tests/test_srm.py index 6e1c572..c99e35a 100644 --- a/tests/test_srm.py +++ b/tests/test_srm.py @@ -49,6 +49,12 @@ def _signed_frame_name(frame): return f"{frame:05d}" if frame < 0 else f"{frame:04d}" +def _negative_env(): + env = os.environ.copy() + env["PYSEQ_ALLOW_NEGATIVE_FRAMES"] = "1" + return env + + @pytest.fixture def sample_sequence(): """Fixture to create a temporary directory with a sequence of files.""" @@ -125,6 +131,7 @@ def test_srm_cli_explicit_sequence_string(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=_negative_env(), ) assert result.returncode == 0, result.stderr @@ -165,6 +172,7 @@ def test_srm_cli_negative_sequence_string(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=_negative_env(), ) assert result.returncode == 0, result.stderr From e18035bc49dd82993cd2fe57c2a86943b967f0d2 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 04:34:10 -0700 Subject: [PATCH 12/26] Fixed the benchmark comparison edge case --- scripts/benchmark.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/benchmark.py b/scripts/benchmark.py index fd07bb9..aaefebd 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -439,8 +439,8 @@ def build_comparison(baseline, candidate): "benchmarks": comparisons, "summary": { "median_delta_pct": median(deltas), - "max_regression_pct": max(deltas), - "max_improvement_pct": min(deltas), + "max_regression_pct": max(deltas) if deltas else 0.0, + "max_improvement_pct": min(deltas) if deltas else 0.0, }, } From a8c812e63f06988fe53d48f0053f45107c055f0b Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 05:19:14 -0700 Subject: [PATCH 13/26] Add PR benchmark regression checks with mixed synthetic datasets --- .github/workflows/benchmarks.yml | 149 ++++++++++++++++++++++++++++++- docs/performance.md | 36 ++++++++ scripts/benchmark.py | 131 +++++++++++++++++++++++++-- scripts/time_lss.sh | 50 +++++++++++ 4 files changed, 360 insertions(+), 6 deletions(-) create mode 100755 scripts/time_lss.sh diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 68a9f6a..4d81598 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -21,8 +21,155 @@ on: schedule: - cron: "0 9 * * 1" +permissions: + contents: read + pull-requests: write + jobs: - benchmark: + benchmark-pr: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + + steps: + - name: Check out candidate branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install -e ".[dev]" + + - name: Prepare baseline worktree + shell: bash + run: | + git worktree add ../pyseq-baseline "${{ github.event.pull_request.base.sha }}" + cp scripts/benchmark.py ../pyseq-baseline/scripts/benchmark.py + + - name: Run baseline benchmarks + working-directory: ../pyseq-baseline + run: | + python scripts/benchmark.py --scenarios contiguous,mixed --json baseline-benchmark.json > baseline-benchmark.md + + - name: Run candidate benchmarks + run: | + python scripts/benchmark.py --scenarios contiguous,mixed --json candidate-benchmark.json > candidate-benchmark.md + + - name: Compare benchmark runs + id: compare + continue-on-error: true + run: | + python scripts/benchmark.py \ + --compare ../pyseq-baseline/baseline-benchmark.json candidate-benchmark.json \ + --json benchmark-compare.json \ + --fail-on-regression-pct 5 > benchmark-compare.md + + - name: Write benchmark summaries + if: always() + shell: bash + run: | + { + echo "## Candidate benchmark" + cat candidate-benchmark.md + echo + echo "## Baseline benchmark" + cat ../pyseq-baseline/baseline-benchmark.md + echo + echo "## Comparison" + cat benchmark-compare.md + } >> "$GITHUB_STEP_SUMMARY" + + - name: Post pull request benchmark comment + if: always() + uses: actions/github-script@v7 + env: + BENCHMARK_COMPARE_PATH: benchmark-compare.md + BENCHMARK_THRESHOLD_STATUS: ${{ steps.compare.outcome }} + with: + script: | + const fs = require("fs"); + const marker = ""; + const compare = fs.readFileSync(process.env.BENCHMARK_COMPARE_PATH, "utf8"); + const failed = process.env.BENCHMARK_THRESHOLD_STATUS !== "success"; + const header = failed + ? "## Benchmark Regression Alert" + : "## Benchmark Comparison"; + const status = failed + ? "_Regression guard exceeded the +5.00% threshold._" + : "_No benchmark exceeded the +5.00% regression threshold._"; + const body = `${marker}\n${header}\n\n${status}\n\n${compare}`; + + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const existing = comments.find((comment) => comment.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + + - name: Upload benchmark artifacts + if: always() + shell: bash + run: | + mkdir -p benchmark-artifacts/baseline + cp ../pyseq-baseline/baseline-benchmark.json benchmark-artifacts/baseline/ + cp ../pyseq-baseline/baseline-benchmark.md benchmark-artifacts/baseline/ + if [ -d ../pyseq-baseline/tmp/benchmarks/profiles ]; then + mkdir -p benchmark-artifacts/baseline/tmp/benchmarks + cp -R ../pyseq-baseline/tmp/benchmarks/profiles benchmark-artifacts/baseline/tmp/benchmarks/ + fi + + - name: Publish benchmark artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmark-results-pr-${{ github.event.pull_request.number }} + path: | + candidate-benchmark.json + candidate-benchmark.md + benchmark-compare.json + benchmark-compare.md + tmp/benchmarks/profiles + benchmark-artifacts/baseline + + - name: Clean up baseline worktree + if: always() + shell: bash + run: | + git worktree remove ../pyseq-baseline --force + + - name: Enforce regression threshold + if: always() && steps.compare.outcome != 'success' + run: | + echo "Benchmark regression exceeded the +5.00% threshold." + exit 1 + + benchmark-branch: + if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 steps: diff --git a/docs/performance.md b/docs/performance.md index bc1eb84..baddf33 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -24,6 +24,7 @@ Simple examples: python scripts/benchmark.py python scripts/benchmark.py --full python scripts/benchmark.py --json /tmp/pyseq-main.json +python scripts/benchmark.py --scenarios contiguous,mixed python scripts/benchmark.py --path /project/shot/frames --iterations 5 python scripts/benchmark.py --path /project/shot/frames --glob "*.exr" --iterations 5 python scripts/benchmark.py --compare /tmp/pyseq-main.json /tmp/pyseq-feature.json @@ -36,6 +37,12 @@ What it measures: - `resolve_sequence(...)` against a padded on-disk sequence - `lss` end-to-end subprocess runtime on the same synthetic dataset +Synthetic scenarios: + +- `contiguous`: one large primary sequence with light non-sequence noise +- `mixed`: a more realistic directory with a dominant gapped sequence, + several neighboring sequences, and unrelated files + When using `--path`, the runner benchmarks an existing directory instead of a synthetic dataset. This is useful for validating results against real-world sequence layouts that may not be modeled well by generated fixtures. @@ -45,10 +52,16 @@ Default synthetic dataset sizes: - default: `100`, `1000`, `10000` - `--full`: `100`, `1000`, `10000`, `50000` +Default synthetic scenarios: + +- `contiguous` +- `mixed` + You can override dataset sizes or iteration count: ```bash python scripts/benchmark.py --sizes 100,1000,10000,50000 --iterations 7 +python scripts/benchmark.py --sizes 1000,10000 --scenarios mixed --iterations 7 ``` ## Local A/B Comparison @@ -119,6 +132,27 @@ Useful options: - `--no-profile` to skip profiling entirely - `--profiles-dir ` to choose a different output directory +- `--scenarios ` to choose synthetic directory shapes +- `--fail-on-regression-pct ` to turn a compare run into a CI gate + +## CI Automation + +The GitHub Actions benchmark workflow runs pull request comparisons in one job +on one GitHub-hosted runner. + +For pull requests it: + +- checks out the candidate branch +- checks out the pull request base commit in a sibling worktree +- runs the same benchmark script revision against both trees +- compares the two JSON result sets +- uploads both result sets and profile artifacts +- posts a sticky pull request comment with the comparison summary +- fails the workflow if any benchmark regresses by more than `5%` + +The pull request suite uses synthetic datasets only, so it is best treated as +a regression guardrail. For more realistic validation, keep using local +`--path` runs against representative production-style directories. ## Result Format @@ -160,6 +194,7 @@ Example run payload: "profiles_dir": "tmp/benchmarks/profiles/20260721T000000Z-feat-example-abc1234", "python": "3.11.x", "repo_root": "/path/to/pyseq", + "scenarios": ["contiguous", "mixed"], "script_path": "/path/to/pyseq/scripts/benchmark.py", "sizes": [100, 1000, 10000], "timestamp_utc": "2026-07-21T00:00:00+00:00" @@ -175,6 +210,7 @@ Field notes: - `branch` and `commit` make branch-to-branch comparisons easier to track. - `repo_root`, `lib_root`, `script_path`, and `python_executable` show exactly which source tree and interpreter were used. +- `scenarios` records which synthetic directory shapes were exercised. - `pyseq_file` and `pyseq_seq_file` confirm which imported package files were actually exercised. - `profiles_dir` points to the profile output location for that run. diff --git a/scripts/benchmark.py b/scripts/benchmark.py index aaefebd..5eba9e2 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -40,6 +40,7 @@ FULL_ITERATIONS = 7 EXTRA_FILES_FACTOR = 0.1 PROFILE_TOP_FUNCTIONS = 25 +DEFAULT_SCENARIOS = ["contiguous", "mixed"] def median(values): @@ -60,6 +61,10 @@ def parse_sizes(value): return [int(part.strip()) for part in value.split(",") if part.strip()] +def parse_csv_list(value): + return [part.strip() for part in value.split(",") if part.strip()] + + def detect_git_branch(): try: result = subprocess.run( @@ -125,8 +130,8 @@ def benchmark_call(func, iterations, warmups=1): } -def create_dataset(root, size, extra_files_factor=EXTRA_FILES_FACTOR): - dataset = root / f"size_{size}" +def create_contiguous_dataset(root, size, extra_files_factor=EXTRA_FILES_FACTOR): + dataset = root / f"contiguous_{size}" dataset.mkdir(parents=True, exist_ok=True) for frame in range(1, size + 1): @@ -143,6 +148,61 @@ def create_dataset(root, size, extra_files_factor=EXTRA_FILES_FACTOR): } +def _partition_count(total, parts): + if parts <= 0: + return [] + base, remainder = divmod(total, parts) + return [base + (1 if index < remainder else 0) for index in range(parts)] + + +def create_mixed_dataset(root, size, extra_files_factor=EXTRA_FILES_FACTOR): + dataset = root / f"mixed_{size}" + dataset.mkdir(parents=True, exist_ok=True) + + primary_count = max(1, int(size * 0.75)) + secondary_count = max(1, int(size * 0.08)) + tertiary_count = max(1, int(size * 0.04)) + quaternary_count = max(1, int(size * 0.04)) + + gap_stride = max(50, size // 12 or 1) + segment_lengths = _partition_count(primary_count, 9) + frame = 1 + for segment_length in segment_lengths: + for _ in range(segment_length): + (dataset / f"base.{frame:08d}.png").touch() + frame += 1 + frame += gap_stride + + secondary_start = 1001 + tertiary_start = 1001 + quaternary_start = 1001 + for offset in range(secondary_count): + ( + dataset / f"big_buck_bunny_1080p_h264.{secondary_start + offset:08d}.png" + ).touch() + for offset in range(tertiary_count): + ( + dataset + / f"big_buck_bunny_1080p_h264_test.{tertiary_start + offset:08d}.png" + ).touch() + for offset in range(quaternary_count): + (dataset / f"test.{quaternary_start + offset:08d}.png").touch() + + extra_count = max(1, int(size * extra_files_factor)) + for index in range(extra_count): + (dataset / f"notes_{index:08d}.txt").touch() + + for tile in range(max(2, size // 2000 + 1)): + for frame in range(101, 111): + (dataset / f"bnc01_TinkSO_tx_{tile}_ty_{tile}.{frame:04d}.tif").touch() + + return { + "path": dataset, + "sequence_pattern": str(dataset / "base.%08d.png"), + "file_names": sorted(os.listdir(dataset)), + } + + def create_existing_dataset(path, sequence_pattern=None, glob_pattern=None): dataset = Path(path).resolve() file_names = sorted(os.listdir(dataset)) @@ -160,6 +220,14 @@ def create_existing_dataset(path, sequence_pattern=None, glob_pattern=None): } +def create_dataset(root, size, scenario): + if scenario == "contiguous": + return create_contiguous_dataset(root, size) + if scenario == "mixed": + return create_mixed_dataset(root, size) + raise ValueError(f"Unknown benchmark scenario: {scenario}") + + def run_lss(path, glob_pattern=None): env = os.environ.copy() env["PYTHONPATH"] = ( @@ -240,7 +308,13 @@ def _path_stem(path): @contextmanager -def build_benchmarks(sizes=None, path=None, sequence_pattern=None, glob_pattern=None): +def build_benchmarks( + sizes=None, + scenarios=None, + path=None, + sequence_pattern=None, + glob_pattern=None, +): with tempfile.TemporaryDirectory() as tmpdir: cases = [] fixtures = [] @@ -258,9 +332,11 @@ def build_benchmarks(sizes=None, path=None, sequence_pattern=None, glob_pattern= ) else: root = Path(tmpdir) + selected_scenarios = scenarios or DEFAULT_SCENARIOS for size in sizes: - fixture = create_dataset(root, size) - fixtures.append({"label": str(size), **fixture}) + for scenario in selected_scenarios: + fixture = create_dataset(root, size, scenario) + fixtures.append({"label": f"{scenario}_{size}", **fixture}) for fixture in fixtures: dataset_path = fixture["path"] @@ -313,6 +389,7 @@ def build_benchmarks(sizes=None, path=None, sequence_pattern=None, glob_pattern= def run_benchmarks( sizes, + scenarios, iterations, enable_profile, profiles_dir, @@ -322,6 +399,7 @@ def run_benchmarks( ): with build_benchmarks( sizes=sizes, + scenarios=scenarios, path=path, sequence_pattern=sequence_pattern, glob_pattern=glob_pattern, @@ -350,6 +428,7 @@ def run_benchmarks( def build_run_payload( sizes, + scenarios, iterations, enable_profile, profiles_dir, @@ -365,6 +444,7 @@ def build_run_payload( benchmarks = run_benchmarks( sizes=sizes, + scenarios=scenarios, iterations=iterations, enable_profile=enable_profile, profiles_dir=actual_profiles_dir, @@ -387,6 +467,7 @@ def build_run_payload( "platform": platform.platform(), "timestamp_utc": datetime.now(timezone.utc).isoformat(), "sizes": sizes, + "scenarios": scenarios, "iterations": iterations, "path": str(Path(path).resolve()) if path else None, "sequence_pattern": sequence_pattern, @@ -441,10 +522,24 @@ def build_comparison(baseline, candidate): "median_delta_pct": median(deltas), "max_regression_pct": max(deltas) if deltas else 0.0, "max_improvement_pct": min(deltas) if deltas else 0.0, + "benchmark_count": len(comparisons), }, } +def enforce_regression_threshold(payload, threshold_pct): + regressions = [ + row for row in payload["benchmarks"] if row["delta_pct"] > threshold_pct + ] + if regressions: + worst = max(regressions, key=lambda row: row["delta_pct"]) + raise SystemExit( + "Regression threshold exceeded: " + f"{len(regressions)} benchmark(s) slower than +{threshold_pct:.2f}% " + f"(worst: {worst['benchmark']} at {worst['delta_pct']:+.2f}%)" + ) + + def write_run_summary(payload, stream): print("# pyseq benchmarks", file=stream) print("", file=stream) @@ -455,6 +550,11 @@ def write_run_summary(payload, stream): print(f"Repo: `{payload['repo_root']}`", file=stream) print(f"Lib: `{payload['lib_root']}`", file=stream) print(f"Python: `{payload['python_executable']}`", file=stream) + if payload.get("scenarios"): + print( + f"Scenarios: `{','.join(payload['scenarios'])}`", + file=stream, + ) if payload.get("pyseq_file"): print(f"PySeq: `{payload['pyseq_file']}`", file=stream) if payload.get("pyseq_seq_file"): @@ -508,6 +608,13 @@ def write_compare_summary(payload, stream): f"Baseline pyseq.seq: `{payload['baseline'].get('pyseq_seq_file')}` Candidate pyseq.seq: `{payload['candidate'].get('pyseq_seq_file')}`", file=stream, ) + print( + "Summary: " + f"`{payload['summary']['benchmark_count']}` benchmarks " + f"median delta `{payload['summary']['median_delta_pct']:+.2f}%` " + f"max regression `{payload['summary']['max_regression_pct']:+.2f}%`", + file=stream, + ) print("", file=stream) print( "| Benchmark | Baseline Median (s) | Candidate Median (s) | Delta (s) | Delta (%) |", @@ -546,6 +653,11 @@ def main(): type=parse_sizes, help="Comma-separated dataset sizes, for example: 100,1000,10000,50000", ) + parser.add_argument( + "--scenarios", + type=parse_csv_list, + help="Comma-separated synthetic scenarios, for example: contiguous,mixed", + ) parser.add_argument( "--iterations", type=int, help="Override timing iteration count" ) @@ -572,6 +684,11 @@ def main(): "--sequence-pattern", help="Optional compressed sequence pattern for resolve_sequence when using --path", ) + parser.add_argument( + "--fail-on-regression-pct", + type=float, + help="Exit non-zero when any compared benchmark regresses by more than this percentage", + ) args = parser.parse_args() if args.compare: @@ -582,6 +699,8 @@ def main(): if args.json_path: dump_json(payload, args.json_path) write_compare_summary(payload, sys.stdout) + if args.fail_on_regression_pct is not None: + enforce_regression_threshold(payload, args.fail_on_regression_pct) return sizes = ( @@ -589,12 +708,14 @@ def main(): if args.path else (args.sizes or (FULL_SIZES if args.full else DEFAULT_SIZES)) ) + scenarios = None if args.path else (args.scenarios or DEFAULT_SCENARIOS) iterations = args.iterations or ( FULL_ITERATIONS if args.full else DEFAULT_ITERATIONS ) profiles_dir = Path(args.profiles_dir) if args.profiles_dir else None payload = build_run_payload( sizes=sizes, + scenarios=scenarios, iterations=iterations, enable_profile=not args.no_profile, profiles_dir=profiles_dir, diff --git a/scripts/time_lss.sh b/scripts/time_lss.sh new file mode 100755 index 0000000..b17aa6f --- /dev/null +++ b/scripts/time_lss.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: scripts/time_lss.sh [runs]" >&2 + exit 1 +fi + +target_path=$1 +runs=${2:-20} + +if [[ ! -d .venv ]]; then + echo "Error: expected .venv in repo root: $(pwd)/.venv" >&2 + exit 1 +fi + +if [[ ! -d lib/pyseq ]]; then + echo "Error: expected to run from repo root containing lib/pyseq" >&2 + exit 1 +fi + +if ! [[ $runs =~ ^[0-9]+$ ]] || [[ $runs -lt 1 ]]; then + echo "Error: runs must be a positive integer" >&2 + exit 1 +fi + +echo "# repo: $(pwd)" +echo "# target: $target_path" +echo "# runs: $runs" +echo "# python: $(realpath .venv/bin/python3)" + +PYTHONPATH=lib .venv/bin/python3 - <<'PY' +import sys +import pyseq +import pyseq.seq +print(f"# pyseq: {pyseq.__file__}") +print(f"# pyseq.seq: {pyseq.seq.__file__}") +print(f"# sys.executable: {sys.executable}") +PY + +for run in $(seq 1 "$runs"); do + seconds=$( + { + /usr/bin/time -f '%e' \ + env PYTHONPATH=lib .venv/bin/python3 -m pyseq.lss "$target_path" \ + >/dev/null + } 2>&1 + ) + echo "$run $seconds" +done From ea80a507e48d4d8e7b4f50a18a58aaa64028cf90 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 05:25:36 -0700 Subject: [PATCH 14/26] Fix PR benchmark workflow baseline checkout and failure handling --- .github/workflows/benchmarks.yml | 64 ++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 4d81598..ee7c80a 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -46,15 +46,16 @@ jobs: python -m pip install --upgrade pip setuptools wheel python -m pip install -e ".[dev]" - - name: Prepare baseline worktree - shell: bash - run: | - git worktree add ../pyseq-baseline "${{ github.event.pull_request.base.sha }}" - cp scripts/benchmark.py ../pyseq-baseline/scripts/benchmark.py + - name: Check out baseline branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: baseline - name: Run baseline benchmarks - working-directory: ../pyseq-baseline + working-directory: baseline run: | + cp "$GITHUB_WORKSPACE/scripts/benchmark.py" scripts/benchmark.py python scripts/benchmark.py --scenarios contiguous,mixed --json baseline-benchmark.json > baseline-benchmark.md - name: Run candidate benchmarks @@ -64,9 +65,10 @@ jobs: - name: Compare benchmark runs id: compare continue-on-error: true + if: success() || hashFiles('baseline/baseline-benchmark.json', 'candidate-benchmark.json') != '' run: | python scripts/benchmark.py \ - --compare ../pyseq-baseline/baseline-benchmark.json candidate-benchmark.json \ + --compare baseline/baseline-benchmark.json candidate-benchmark.json \ --json benchmark-compare.json \ --fail-on-regression-pct 5 > benchmark-compare.md @@ -75,18 +77,27 @@ jobs: shell: bash run: | { - echo "## Candidate benchmark" - cat candidate-benchmark.md - echo - echo "## Baseline benchmark" - cat ../pyseq-baseline/baseline-benchmark.md - echo - echo "## Comparison" - cat benchmark-compare.md + if [ -f candidate-benchmark.md ]; then + echo "## Candidate benchmark" + cat candidate-benchmark.md + echo + fi + if [ -f baseline/baseline-benchmark.md ]; then + echo "## Baseline benchmark" + cat baseline/baseline-benchmark.md + echo + fi + if [ -f benchmark-compare.md ]; then + echo "## Comparison" + cat benchmark-compare.md + else + echo "## Comparison" + echo "Benchmark comparison did not run to completion." + fi } >> "$GITHUB_STEP_SUMMARY" - name: Post pull request benchmark comment - if: always() + if: always() && hashFiles('benchmark-compare.md') != '' uses: actions/github-script@v7 env: BENCHMARK_COMPARE_PATH: benchmark-compare.md @@ -136,11 +147,15 @@ jobs: shell: bash run: | mkdir -p benchmark-artifacts/baseline - cp ../pyseq-baseline/baseline-benchmark.json benchmark-artifacts/baseline/ - cp ../pyseq-baseline/baseline-benchmark.md benchmark-artifacts/baseline/ - if [ -d ../pyseq-baseline/tmp/benchmarks/profiles ]; then + if [ -f baseline/baseline-benchmark.json ]; then + cp baseline/baseline-benchmark.json benchmark-artifacts/baseline/ + fi + if [ -f baseline/baseline-benchmark.md ]; then + cp baseline/baseline-benchmark.md benchmark-artifacts/baseline/ + fi + if [ -d baseline/tmp/benchmarks/profiles ]; then mkdir -p benchmark-artifacts/baseline/tmp/benchmarks - cp -R ../pyseq-baseline/tmp/benchmarks/profiles benchmark-artifacts/baseline/tmp/benchmarks/ + cp -R baseline/tmp/benchmarks/profiles benchmark-artifacts/baseline/tmp/benchmarks/ fi - name: Publish benchmark artifacts @@ -155,15 +170,10 @@ jobs: benchmark-compare.md tmp/benchmarks/profiles benchmark-artifacts/baseline - - - name: Clean up baseline worktree - if: always() - shell: bash - run: | - git worktree remove ../pyseq-baseline --force + if-no-files-found: warn - name: Enforce regression threshold - if: always() && steps.compare.outcome != 'success' + if: always() && steps.compare.conclusion == 'failure' run: | echo "Benchmark regression exceeded the +5.00% threshold." exit 1 From 47692193f4a6586f6f35d8f07d471719cf16b54c Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 05:28:13 -0700 Subject: [PATCH 15/26] Create baseline scripts dir before copying benchmark runner --- .github/workflows/benchmarks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index ee7c80a..98cd690 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -55,6 +55,7 @@ jobs: - name: Run baseline benchmarks working-directory: baseline run: | + mkdir -p scripts cp "$GITHUB_WORKSPACE/scripts/benchmark.py" scripts/benchmark.py python scripts/benchmark.py --scenarios contiguous,mixed --json baseline-benchmark.json > baseline-benchmark.md From 1a1fda48246ab32dae70fa70f355f0da3201d36a Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 05:37:45 -0700 Subject: [PATCH 16/26] Use percentage and absolute thresholds for PR benchmark regressions --- .github/workflows/benchmarks.yml | 9 +++++---- scripts/benchmark.py | 22 ++++++++++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 98cd690..e10bbcc 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -71,7 +71,8 @@ jobs: python scripts/benchmark.py \ --compare baseline/baseline-benchmark.json candidate-benchmark.json \ --json benchmark-compare.json \ - --fail-on-regression-pct 5 > benchmark-compare.md + --fail-on-regression-pct 5 \ + --fail-on-regression-s 0.01 > benchmark-compare.md - name: Write benchmark summaries if: always() @@ -113,8 +114,8 @@ jobs: ? "## Benchmark Regression Alert" : "## Benchmark Comparison"; const status = failed - ? "_Regression guard exceeded the +5.00% threshold._" - : "_No benchmark exceeded the +5.00% regression threshold._"; + ? "_Regression guard exceeded the +5.00% and +0.010s thresholds._" + : "_No benchmark exceeded the +5.00% and +0.010s regression thresholds._"; const body = `${marker}\n${header}\n\n${status}\n\n${compare}`; const { owner, repo } = context.repo; @@ -176,7 +177,7 @@ jobs: - name: Enforce regression threshold if: always() && steps.compare.conclusion == 'failure' run: | - echo "Benchmark regression exceeded the +5.00% threshold." + echo "Benchmark regression exceeded the +5.00% and +0.010s thresholds." exit 1 benchmark-branch: diff --git a/scripts/benchmark.py b/scripts/benchmark.py index 5eba9e2..0e3525d 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -527,16 +527,20 @@ def build_comparison(baseline, candidate): } -def enforce_regression_threshold(payload, threshold_pct): +def enforce_regression_threshold(payload, threshold_pct, threshold_seconds=0.0): regressions = [ - row for row in payload["benchmarks"] if row["delta_pct"] > threshold_pct + row + for row in payload["benchmarks"] + if row["delta_pct"] > threshold_pct and row["delta_s"] > threshold_seconds ] if regressions: worst = max(regressions, key=lambda row: row["delta_pct"]) raise SystemExit( "Regression threshold exceeded: " f"{len(regressions)} benchmark(s) slower than +{threshold_pct:.2f}% " - f"(worst: {worst['benchmark']} at {worst['delta_pct']:+.2f}%)" + f"and +{threshold_seconds:.3f}s " + f"(worst: {worst['benchmark']} at {worst['delta_pct']:+.2f}%, " + f"{worst['delta_s']:+.6f}s)" ) @@ -689,6 +693,12 @@ def main(): type=float, help="Exit non-zero when any compared benchmark regresses by more than this percentage", ) + parser.add_argument( + "--fail-on-regression-s", + type=float, + default=0.0, + help="Require this minimum absolute regression in seconds when using --fail-on-regression-pct", + ) args = parser.parse_args() if args.compare: @@ -700,7 +710,11 @@ def main(): dump_json(payload, args.json_path) write_compare_summary(payload, sys.stdout) if args.fail_on_regression_pct is not None: - enforce_regression_threshold(payload, args.fail_on_regression_pct) + enforce_regression_threshold( + payload, + args.fail_on_regression_pct, + args.fail_on_regression_s, + ) return sizes = ( From fa8793dbd2b22df656664a41a14d5ad707e63ddc Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 05:46:27 -0700 Subject: [PATCH 17/26] Limit PR benchmark runs to pyseq and benchmark workflow changes --- .github/workflows/benchmarks.yml | 4 ++++ docs/performance.md | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index e10bbcc..d0aaff7 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -8,6 +8,10 @@ on: - reopened - ready_for_review - converted_to_draft + paths: + - "lib/pyseq/**" + - "scripts/benchmark.py" + - ".github/workflows/benchmarks.yml" workflow_dispatch: inputs: profile: diff --git a/docs/performance.md b/docs/performance.md index baddf33..015d2be 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -143,12 +143,16 @@ on one GitHub-hosted runner. For pull requests it: - checks out the candidate branch -- checks out the pull request base commit in a sibling worktree +- checks out the pull request base commit in a separate `baseline/` tree - runs the same benchmark script revision against both trees - compares the two JSON result sets - uploads both result sets and profile artifacts - posts a sticky pull request comment with the comparison summary -- fails the workflow if any benchmark regresses by more than `5%` +- fails the workflow if any benchmark regresses by more than `5%` and `0.010s` + +On pull requests, `benchmark-pr` is the authoritative check. The separate +`benchmark-branch` job is only for non-PR runs such as scheduled or manual +benchmark collection, so seeing it skipped on a pull request is expected. The pull request suite uses synthetic datasets only, so it is best treated as a regression guardrail. For more realistic validation, keep using local From dbde8ecefa5b2b50108c2233b4cc6f19488798c7 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 05:47:26 -0700 Subject: [PATCH 18/26] Limit unit test runs to code, tests, and packaging changes --- .github/workflows/tests.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6567195..f2bb306 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,17 @@ on: push: branches: - main + paths: + - "lib/pyseq/**" + - "tests/**" + - "pyproject.toml" + - ".github/workflows/tests.yml" pull_request: + paths: + - "lib/pyseq/**" + - "tests/**" + - "pyproject.toml" + - ".github/workflows/tests.yml" workflow_dispatch: jobs: From 514dcd0b2172d8b87bed335ce10ebdfc6e5a4383 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 05:52:16 -0700 Subject: [PATCH 19/26] Defer unused sequence format calculations in lss output path --- lib/pyseq/seq.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index 548c775..fcd68f0 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -430,14 +430,14 @@ def __attrs__(self): "e": self.end, "f": self.frames, "m": self.missing, - "M": functools.partial(self._get_framerange, self.missing(), missing=True), + "M": lambda: self._get_framerange(self.missing(), missing=True), "d": lambda *x: self.size, "H": lambda *x: self.human, "D": self.directory, "p": self._get_padding, - "r": functools.partial(self._get_framerange, self.frames(), missing=False), - "R": functools.partial(self._get_framerange, self.frames(), missing=True), - "x": functools.partial(_format_frame_range_stepped, self.frames()), + "r": lambda: self._get_framerange(self.frames(), missing=False), + "R": lambda: self._get_framerange(self.frames(), missing=True), + "x": lambda: _format_frame_range_stepped(self.frames()), "h": self.head, "t": self.tail, } From 135084d9d141f559978371e069c2cfab1b58e4ad Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 06:09:12 -0700 Subject: [PATCH 20/26] Tighten benchmark regression gates and defer non-lss import overhead --- .github/workflows/benchmarks.yml | 10 ++++---- CONTRIBUTING.md | 39 ++++++++++++++++++++++++++++++++ README.md | 5 ++++ docs/performance.md | 9 +++++++- lib/pyseq/config.py | 5 ---- lib/pyseq/frange.py | 12 +++++++--- lib/pyseq/seq.py | 8 ++++--- 7 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index d0aaff7..21615eb 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -75,8 +75,8 @@ jobs: python scripts/benchmark.py \ --compare baseline/baseline-benchmark.json candidate-benchmark.json \ --json benchmark-compare.json \ - --fail-on-regression-pct 5 \ - --fail-on-regression-s 0.01 > benchmark-compare.md + --fail-on-regression-pct 3 \ + --fail-on-regression-s 0.005 > benchmark-compare.md - name: Write benchmark summaries if: always() @@ -118,8 +118,8 @@ jobs: ? "## Benchmark Regression Alert" : "## Benchmark Comparison"; const status = failed - ? "_Regression guard exceeded the +5.00% and +0.010s thresholds._" - : "_No benchmark exceeded the +5.00% and +0.010s regression thresholds._"; + ? "_Regression guard exceeded the +3.00% and +0.005s thresholds._" + : "_No benchmark exceeded the +3.00% and +0.005s regression thresholds._"; const body = `${marker}\n${header}\n\n${status}\n\n${compare}`; const { owner, repo } = context.repo; @@ -181,7 +181,7 @@ jobs: - name: Enforce regression threshold if: always() && steps.compare.conclusion == 'failure' run: | - echo "Benchmark regression exceeded the +5.00% and +0.010s thresholds." + echo "Benchmark regression exceeded the +3.00% and +0.005s thresholds." exit 1 benchmark-branch: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c6077bc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing + +PySeq changes should stay small, clear, and performance-aware. + +## Development Basics + +- Run the unit tests for code changes: + `pytest tests -q` +- Prefer focused changes over broad refactors unless the refactor is the task. +- Keep the default filename discovery path permissive and fast. + +## Performance + +Performance is a project priority, especially for: + +- `lss` +- `get_sequences(...)` +- `resolve_sequence(...)` + +When changing code under `lib/pyseq/`, contributors should treat performance +as part of correctness. + +Current benchmark policy: + +- pull requests run synthetic benchmark comparisons against the base branch +- benchmark deltas stay visible in the pull request comment +- the benchmark workflow fails when a regression exceeds both `3%` and `0.005s` +- repeated small regressions in the same hotspot should be investigated before + they accumulate across releases + +Recommended workflow for performance-sensitive changes: + +1. Run the unit tests. +2. Run `python scripts/benchmark.py --json /tmp/pyseq-feature.json`. +3. Compare against a baseline branch or commit with + `python scripts/benchmark.py --compare /tmp/pyseq-main.json /tmp/pyseq-feature.json`. +4. If a hotspot regresses, collect profiles and inspect them before merging. + +For more detail, see [docs/performance.md](docs/performance.md). diff --git a/README.md b/README.md index 1741687..c50a255 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Used in visual effects, animation, and post-production pipelines. [Production Usage](#production-usage) | [Command-Line Tools](#command-line-tools) | [Docs](#docs) | +[Contributing](#contributing) | [Testing](#testing) ## Installation @@ -145,6 +146,10 @@ Additional documentation is available in the [docs](docs/) folder: - [Formatting Reference](docs/formatting.md) - [Frame Patterns](docs/frame-patterns.md) +## Contributing + +Contributor guidance lives in [CONTRIBUTING.md](CONTRIBUTING.md). + ## Testing To run the unit tests, simply run `pytest` in a shell: diff --git a/docs/performance.md b/docs/performance.md index 015d2be..524f1a9 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -148,7 +148,7 @@ For pull requests it: - compares the two JSON result sets - uploads both result sets and profile artifacts - posts a sticky pull request comment with the comparison summary -- fails the workflow if any benchmark regresses by more than `5%` and `0.010s` +- fails the workflow if any benchmark regresses by more than `3%` and `0.005s` On pull requests, `benchmark-pr` is the authoritative check. The separate `benchmark-branch` job is only for non-PR runs such as scheduled or manual @@ -158,6 +158,13 @@ The pull request suite uses synthetic datasets only, so it is best treated as a regression guardrail. For more realistic validation, keep using local `--path` runs against representative production-style directories. +Current benchmark policy: + +- keep every benchmark delta visible in the PR comment +- fail CI only when a regression exceeds both `3%` and `0.005s` +- treat repeated small regressions in the same hotspot as a reason to profile + and optimize before they accumulate over time + ## Result Format The benchmark runner can emit machine-readable JSON for local comparison, CI diff --git a/lib/pyseq/config.py b/lib/pyseq/config.py index 0396adf..3d7d322 100644 --- a/lib/pyseq/config.py +++ b/lib/pyseq/config.py @@ -70,11 +70,6 @@ ) DEFAULT_EMBEDDED_RANGE_PATTERN = rf"^(?P.+?)(?P\[(?:[^\]]+)\]|{DEFAULT_FRAME_RANGE_TEXT_PATTERN})(?P\.[^/\s]+)$" -frame_range_segment_re = re.compile(DEFAULT_FRAME_RANGE_SEGMENT_PATTERN) -frame_range_text_re = re.compile(DEFAULT_FRAME_RANGE_TEXT_PATTERN) -serialized_range_re = re.compile(DEFAULT_SERIALIZED_RANGE_PATTERN) -embedded_range_re = re.compile(DEFAULT_EMBEDDED_RANGE_PATTERN) - def allow_negative_frames() -> bool: """Return True when explicit negative frame syntax is enabled.""" diff --git a/lib/pyseq/frange.py b/lib/pyseq/frange.py index a675294..e54ef21 100644 --- a/lib/pyseq/frange.py +++ b/lib/pyseq/frange.py @@ -5,19 +5,25 @@ """Frame range parsing and formatting helpers.""" +import re from typing import List, Optional, Tuple from pyseq import config +frame_range_segment_re = re.compile(config.DEFAULT_FRAME_RANGE_SEGMENT_PATTERN) +frame_range_text_re = re.compile(config.DEFAULT_FRAME_RANGE_TEXT_PATTERN) +serialized_range_re = re.compile(config.DEFAULT_SERIALIZED_RANGE_PATTERN) +embedded_range_re = re.compile(config.DEFAULT_EMBEDDED_RANGE_PATTERN) + def has_serialized_range(text: str) -> bool: """Return True when text includes explicit serialized frame range syntax.""" - return bool(config.serialized_range_re.search(text)) + return bool(serialized_range_re.search(text)) def split_embedded_frame_range(text: str) -> Optional[Tuple[str, str, str]]: """Split `headtail` strings like `plate.2-4.exr` into components.""" - match = config.embedded_range_re.match(text) + match = embedded_range_re.match(text) if not match: return None return match.group("head"), match.group("range"), match.group("tail") @@ -38,7 +44,7 @@ def parse_frame_range(range_text: str) -> List[int]: segment = segment.strip() if not segment: raise ValueError(f"Invalid frame range syntax: {range_text}") - match = config.frame_range_segment_re.match(segment) + match = frame_range_segment_re.match(segment) if not match: raise ValueError(f"Invalid frame range syntax: {segment}") diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index fcd68f0..5d7269b 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -36,9 +36,6 @@ import functools import os import re -import traceback -import warnings -from ast import literal_eval from collections import deque from glob import glob, iglob from typing import List, Callable, Union @@ -865,6 +862,9 @@ def reIndex(self, offset: int, padding: int = None): shutil.move(oldName, newName) except Exception as err: + import traceback + import warnings + warnings.warn( "%s during reIndex %s -> %s: \n%s" % ( @@ -1089,6 +1089,8 @@ def uncompress(seq_string: str, fmt: str = global_format): if not match: return + from ast import literal_eval + try: pad = match.group("p") From ce3e5b6bb77bc620211be577d2b5cb91929d9542 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 06:26:33 -0700 Subject: [PATCH 21/26] Limit negative-frame test env usage and remove unused util import --- CHANGELOG.md | 5 ++++- lib/pyseq/util.py | 1 - tests/test_scopy.py | 2 -- tests/test_smove.py | 2 -- tests/test_srm.py | 1 - 5 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73867c0..3c02cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ CHANGELOG * Adds support for signed frame numbers in serialized ranges and on-disk filenames such as `render.-0002.exr` * Refactors explicit range parsing and formatting onto shared helpers to keep the syntax rules consistent * Expands regression coverage for stepped ranges, negative frame handling, CLI tools, and performance-sensitive paths -* Adds benchmark tooling, benchmark documentation, and GitHub Pages publishing workflows for docs and reports +* Adds benchmark tooling, benchmark documentation, and pull request regression checks for performance-sensitive changes +* Tightens CI benchmark regression thresholds to `3%` and `0.005s`, while still publishing full benchmark deltas in pull request comments +* Reduces `lss` startup overhead by deferring non-critical imports and moving extended frame-range regex compilation off the default import path +* Documents the performance policy and contributor workflow in the README, performance guide, and contributing guide ## 0.9.2 diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index 322de21..86d1d15 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -39,7 +39,6 @@ from typing import Optional import pyseq -from pyseq.cli import cli_catch_keyboard_interrupt from pyseq import config from pyseq.config import range_join diff --git a/tests/test_scopy.py b/tests/test_scopy.py index d6bf8df..678dfe7 100644 --- a/tests/test_scopy.py +++ b/tests/test_scopy.py @@ -121,7 +121,6 @@ def test_scopy_cli_explicit_sequence_string_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=_negative_env(), ) assert result.returncode == 0, result.stderr @@ -144,7 +143,6 @@ def test_scopy_cli_embedded_range_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=_negative_env(), ) assert result.returncode == 0, result.stderr diff --git a/tests/test_smove.py b/tests/test_smove.py index b72db99..d790136 100644 --- a/tests/test_smove.py +++ b/tests/test_smove.py @@ -222,7 +222,6 @@ def test_smv_cli_explicit_sequence_string_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=_negative_env(), ) assert result.returncode == 0, result.stderr @@ -247,7 +246,6 @@ def test_smv_cli_embedded_range_source_and_dest(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=_negative_env(), ) assert result.returncode == 0, result.stderr diff --git a/tests/test_srm.py b/tests/test_srm.py index c99e35a..29b0135 100644 --- a/tests/test_srm.py +++ b/tests/test_srm.py @@ -131,7 +131,6 @@ def test_srm_cli_explicit_sequence_string(tmp_path): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=_negative_env(), ) assert result.returncode == 0, result.stderr From 47c6003d2ec6c0bc0aa665571238dc2254c432a0 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 06:31:47 -0700 Subject: [PATCH 22/26] Refresh seq docstrings and update library copyright headers --- CHANGELOG.md | 3 +-- lib/pyseq/__init__.py | 2 +- lib/pyseq/cli.py | 2 +- lib/pyseq/config.py | 2 +- lib/pyseq/frange.py | 2 +- lib/pyseq/lss.py | 2 +- lib/pyseq/scopy.py | 2 +- lib/pyseq/sdiff.py | 2 +- lib/pyseq/seq.py | 25 +++++++++++++++++-------- lib/pyseq/sfind.py | 2 +- lib/pyseq/smove.py | 2 +- lib/pyseq/sremove.py | 2 +- lib/pyseq/sstat.py | 2 +- lib/pyseq/stree.py | 2 +- lib/pyseq/util.py | 2 +- 15 files changed, 31 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c02cfe..1cde449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,8 @@ CHANGELOG * Refactors explicit range parsing and formatting onto shared helpers to keep the syntax rules consistent * Expands regression coverage for stepped ranges, negative frame handling, CLI tools, and performance-sensitive paths * Adds benchmark tooling, benchmark documentation, and pull request regression checks for performance-sensitive changes -* Tightens CI benchmark regression thresholds to `3%` and `0.005s`, while still publishing full benchmark deltas in pull request comments +* Sets CI benchmark regression thresholds to `3%` and `0.005s`, while still publishing full benchmark deltas in pull request comments * Reduces `lss` startup overhead by deferring non-critical imports and moving extended frame-range regex compilation off the default import path -* Documents the performance policy and contributor workflow in the README, performance guide, and contributing guide ## 0.9.2 diff --git a/lib/pyseq/__init__.py b/lib/pyseq/__init__.py index d174cc4..c5aec67 100644 --- a/lib/pyseq/__init__.py +++ b/lib/pyseq/__init__.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/cli.py b/lib/pyseq/cli.py index 3d61fff..85389f0 100644 --- a/lib/pyseq/cli.py +++ b/lib/pyseq/cli.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # """Lightweight helpers shared by CLI entry points.""" diff --git a/lib/pyseq/config.py b/lib/pyseq/config.py index 3d7d322..a339922 100644 --- a/lib/pyseq/config.py +++ b/lib/pyseq/config.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/frange.py b/lib/pyseq/frange.py index e54ef21..9f64504 100644 --- a/lib/pyseq/frange.py +++ b/lib/pyseq/frange.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # """Frame range parsing and formatting helpers.""" diff --git a/lib/pyseq/lss.py b/lib/pyseq/lss.py index 1bcab89..a352939 100755 --- a/lib/pyseq/lss.py +++ b/lib/pyseq/lss.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/scopy.py b/lib/pyseq/scopy.py index 5a16185..6e2a1b5 100644 --- a/lib/pyseq/scopy.py +++ b/lib/pyseq/scopy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/sdiff.py b/lib/pyseq/sdiff.py index 2f274c8..502f500 100644 --- a/lib/pyseq/sdiff.py +++ b/lib/pyseq/sdiff.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index 5d7269b..325681b 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -30,7 +30,7 @@ # ----------------------------------------------------------------------------- __doc__ = """ -Contains the main pyseq classes and functions. +Core sequence types and parsing/formatting helpers. """ import functools @@ -100,15 +100,19 @@ def _parse_frame_range(range_text: str) -> List[int]: def padsize(item, frame): """ - Determines the pad size for a given Item. Return value may depend on - whether strict padding is enabled or not. + Determine the pad size for a given Item. + + The return value may depend on whether strict padding is enabled. For example: the file item.001.exr will have a pad size of 3, and the file test.001001.exr will have a pad size of 6. :param item: Item object. - :param frame: the frame number as a string. - :returns: the size of the frame pad as an int. + Signed frames use the digit width only; the leading ``-`` does not + contribute to the padding width. + + :param frame: The frame number token as a string. + :returns: The size of the frame pad as an int. """ # strict: frame size (%d) must match between frames (default) @@ -1002,7 +1006,7 @@ def diff(f1: Union[str, Item], f2: Union[str, Item]): def uncompress(seq_string: str, fmt: str = global_format): - """Basic uncompression or deserialization of a compressed sequence string. + """Deserialize a compressed sequence string into a Sequence. For example: @@ -1027,9 +1031,14 @@ def uncompress(seq_string: str, fmt: str = global_format): >>> len(seq) 100 + >>> seq = pyseq.uncompress('render.%04d.exr 1001-1010x3', fmt='%h%p%t %x') + >>> print(seq.frames()) + [1001, 1004, 1007, 1010] + :param seq_string: Compressed sequence string. :param fmt: Format of sequence string. - :return: :class:`.Sequence` instance. + :return: :class:`.Sequence` instance, or ``None`` when the string does not + match ``fmt``. """ dirname = os.path.dirname(seq_string) diff --git a/lib/pyseq/sfind.py b/lib/pyseq/sfind.py index 3fa937b..2afb17a 100644 --- a/lib/pyseq/sfind.py +++ b/lib/pyseq/sfind.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/smove.py b/lib/pyseq/smove.py index 86a99bc..27760ea 100644 --- a/lib/pyseq/smove.py +++ b/lib/pyseq/smove.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/sremove.py b/lib/pyseq/sremove.py index 1ab1189..7140856 100644 --- a/lib/pyseq/sremove.py +++ b/lib/pyseq/sremove.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/sstat.py b/lib/pyseq/sstat.py index 4f084c8..dfe8999 100644 --- a/lib/pyseq/sstat.py +++ b/lib/pyseq/sstat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/stree.py b/lib/pyseq/stree.py index 6aae7d5..8bba376 100644 --- a/lib/pyseq/stree.py +++ b/lib/pyseq/stree.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index 86d1d15..56f62d3 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: From 26427a91bb039bf4219016ab3591e84ac61008b8 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 06:49:06 -0700 Subject: [PATCH 23/26] Add black, flake8, isort, and mccabe quality checks with optional pre-commit hook --- .flake8 | 18 +++++++ .github/workflows/tests.yml | 30 ++++++++++++ CONTRIBUTING.md | 11 +++++ lib/pyseq/config.py | 6 +-- lib/pyseq/frange.py | 12 ++--- lib/pyseq/lss.py | 2 +- lib/pyseq/scopy.py | 19 ++------ lib/pyseq/sdiff.py | 2 +- lib/pyseq/seq.py | 30 +++--------- lib/pyseq/sfind.py | 4 +- lib/pyseq/smove.py | 19 ++------ lib/pyseq/sstat.py | 4 +- lib/pyseq/util.py | 41 ++++------------ pyproject.toml | 11 ++++- scripts/benchmark.py | 41 ++++------------ scripts/build_pages_site.py | 5 +- scripts/install_precommit_hook.py | 49 +++++++++++++++++++ tests/test_cli_interrupts.py | 4 +- tests/test_lss.py | 2 +- tests/test_performance.py | 3 +- tests/test_pyseq.py | 80 ++++++++++++++----------------- tests/test_scopy.py | 3 +- tests/test_sdiff.py | 3 +- tests/test_sfind.py | 2 +- tests/test_smove.py | 3 +- tests/test_srm.py | 3 +- tests/test_sstat.py | 5 +- tests/test_stree.py | 2 +- 28 files changed, 218 insertions(+), 196 deletions(-) create mode 100644 .flake8 create mode 100644 scripts/install_precommit_hook.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..0aa4c34 --- /dev/null +++ b/.flake8 @@ -0,0 +1,18 @@ +[flake8] +max-line-length = 100 +max-complexity = 35 +exclude = + .git, + __pycache__, + .venv, + venv, + build, + dist, + baseline +extend-ignore = + E203, + E501, + W503 +per-file-ignores = + lib/pyseq/__init__.py:F401,F403 + tests/test_pyseq.py:E402 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f2bb306..015f6c3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,17 +7,47 @@ on: paths: - "lib/pyseq/**" - "tests/**" + - "scripts/**" - "pyproject.toml" + - ".flake8" - ".github/workflows/tests.yml" pull_request: paths: - "lib/pyseq/**" - "tests/**" + - "scripts/**" - "pyproject.toml" + - ".flake8" - ".github/workflows/tests.yml" workflow_dispatch: jobs: + quality: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package and quality dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install -e ".[dev]" + + - name: Check import ordering + run: python -m isort --check-only --diff lib/pyseq tests scripts + + - name: Check black formatting + run: python -m black --check lib/pyseq tests scripts + + - name: Run flake8 and mccabe checks + run: python -m flake8 lib/pyseq tests scripts + test: runs-on: ${{ matrix.os }} strategy: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6077bc..ea61f14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,20 @@ PySeq changes should stay small, clear, and performance-aware. - Run the unit tests for code changes: `pytest tests -q` +- Run local quality checks for Python changes: + `python -m black --check lib/pyseq tests scripts` + `python -m isort --check-only --diff lib/pyseq tests scripts` + `python -m flake8 lib/pyseq tests scripts` - Prefer focused changes over broad refactors unless the refactor is the task. - Keep the default filename discovery path permissive and fast. +Optional local hook installation: + +- `python scripts/install_precommit_hook.py` + +That hook runs `black`, `isort`, `flake8`/`mccabe`, and `pytest` before each +commit. + ## Performance Performance is a project priority, especially for: diff --git a/lib/pyseq/config.py b/lib/pyseq/config.py index a339922..d1ab583 100644 --- a/lib/pyseq/config.py +++ b/lib/pyseq/config.py @@ -64,10 +64,10 @@ DEFAULT_FRAME_RANGE_SEGMENT_PATTERN = ( r"^\s*(?P-?\d+)(?:\s*-\s*(?P-?\d+)(?:\s*x\s*(?P\d+))?)?\s*$" ) -DEFAULT_FRAME_RANGE_TEXT_PATTERN = r"-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?)*" -DEFAULT_SERIALIZED_RANGE_PATTERN = ( - rf"\[[^\]]+\]|\s+(?:{DEFAULT_FRAME_RANGE_TEXT_PATTERN})\s*$" +DEFAULT_FRAME_RANGE_TEXT_PATTERN = ( + r"-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?)*" ) +DEFAULT_SERIALIZED_RANGE_PATTERN = rf"\[[^\]]+\]|\s+(?:{DEFAULT_FRAME_RANGE_TEXT_PATTERN})\s*$" DEFAULT_EMBEDDED_RANGE_PATTERN = rf"^(?P.+?)(?P\[(?:[^\]]+)\]|{DEFAULT_FRAME_RANGE_TEXT_PATTERN})(?P\.[^/\s]+)$" diff --git a/lib/pyseq/frange.py b/lib/pyseq/frange.py index 9f64504..c348573 100644 --- a/lib/pyseq/frange.py +++ b/lib/pyseq/frange.py @@ -53,9 +53,7 @@ def parse_frame_range(range_text: str) -> List[int]: step = match.group("step") if start < 0 and not config.allow_negative_frames(): - raise ValueError( - "Negative frame ranges require PYSEQ_ALLOW_NEGATIVE_FRAMES=1" - ) + raise ValueError("Negative frame ranges require PYSEQ_ALLOW_NEGATIVE_FRAMES=1") if end is None: frames.append(start) @@ -63,9 +61,7 @@ def parse_frame_range(range_text: str) -> List[int]: end = int(end) if end < 0 and not config.allow_negative_frames(): - raise ValueError( - "Negative frame ranges require PYSEQ_ALLOW_NEGATIVE_FRAMES=1" - ) + raise ValueError("Negative frame ranges require PYSEQ_ALLOW_NEGATIVE_FRAMES=1") step = int(step) if step is not None else 1 if step <= 0: raise ValueError(f"Frame step must be positive: {segment}") @@ -111,9 +107,7 @@ def _frame_groups(frames: List[int]): return groups -def format_frame_range_explicit( - frames: List[int], pad_with_brackets: bool = True -) -> str: +def format_frame_range_explicit(frames: List[int], pad_with_brackets: bool = True) -> str: """Format frames as explicit contiguous segments.""" if not frames: return "" diff --git a/lib/pyseq/lss.py b/lib/pyseq/lss.py index a352939..abd811a 100755 --- a/lib/pyseq/lss.py +++ b/lib/pyseq/lss.py @@ -41,8 +41,8 @@ from pyseq import __version__, get_sequences from pyseq import seq as pyseq -from pyseq.cli import cli_catch_keyboard_interrupt from pyseq import walk +from pyseq.cli import cli_catch_keyboard_interrupt def tree(source: str, level: Optional[int], seq_format: str): diff --git a/lib/pyseq/scopy.py b/lib/pyseq/scopy.py index 6e2a1b5..da74c22 100644 --- a/lib/pyseq/scopy.py +++ b/lib/pyseq/scopy.py @@ -33,18 +33,15 @@ Contains the main scopy functions for the pyseq module. """ -import sys -import os import argparse +import os import shutil +import sys from typing import Optional import pyseq from pyseq.cli import cli_catch_keyboard_interrupt -from pyseq.util import ( - parse_destination_reference, - resolve_sequence_reference, -) +from pyseq.util import parse_destination_reference, resolve_sequence_reference def copy_sequence( @@ -149,17 +146,11 @@ def main(): dest_spec = parse_destination_reference(dest, seq) if len(sources) > 1 and dest_spec["kind"] != "directory": - raise ValueError( - "destination must be a directory when copying multiple sources" - ) + raise ValueError("destination must be a directory when copying multiple sources") rename = dest_spec["rename"] pad = args.pad if dest_spec["kind"] == "directory" else dest_spec["pad"] - renumber = ( - args.renumber - if dest_spec["kind"] == "directory" - else dest_spec["renumber"] - ) + renumber = args.renumber if dest_spec["kind"] == "directory" else dest_spec["renumber"] copy_sequence( seq, diff --git a/lib/pyseq/sdiff.py b/lib/pyseq/sdiff.py index 502f500..e5688ce 100644 --- a/lib/pyseq/sdiff.py +++ b/lib/pyseq/sdiff.py @@ -58,7 +58,7 @@ def diff_sequences( def intval(val): try: return int(val) - except: + except (TypeError, ValueError): return None diff = { diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index 325681b..17b38d4 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -38,16 +38,10 @@ import re from collections import deque from glob import glob, iglob -from typing import List, Callable, Union +from typing import Callable, List, Union from pyseq import config -from pyseq.config import ( - default_format, - format_re, - global_format, - range_join, - strict_pad, -) +from pyseq.config import default_format, format_re, global_format, range_join, strict_pad class SequenceError(Exception): @@ -78,9 +72,7 @@ def _ext_key(x: str): return [ext] + _natural_key(name) -def _format_frame_range_explicit( - frames: List[int], pad_with_brackets: bool = True -) -> str: +def _format_frame_range_explicit(frames: List[int], pad_with_brackets: bool = True) -> str: from pyseq.frange import format_frame_range_explicit return format_frame_range_explicit(frames, pad_with_brackets=pad_with_brackets) @@ -748,9 +740,7 @@ def includes(self, item: Union[str, Item]): for anchor in anchors: if anchor.is_sibling(item): - return item.name.startswith(canonical_head) and item.name.endswith( - canonical_tail - ) + return item.name.startswith(canonical_head) and item.name.endswith(canonical_tail) return False @@ -849,8 +839,7 @@ def reIndex(self, offset: int, padding: int = None): if offset > 0: gen = ( - (image, frame) - for (image, frame) in zip(reversed(self), reversed(self.frames())) + (image, frame) for (image, frame) in zip(reversed(self), reversed(self.frames())) ) else: gen = ((image, frame) for (image, frame) in zip(self, self.frames())) @@ -990,9 +979,7 @@ def diff(f1: Union[str, Item], f2: Union[str, Item]): if len(f1.number_matches) == len(f2.number_matches): for m1, m2 in zip(f1.number_matches, f2.number_matches): if (m1.start() == m2.start()) and (m1.group() != m2.group()): - if strict_pad is True and ( - _frame_width(m1.group()) != _frame_width(m2.group()) - ): + if strict_pad is True and (_frame_width(m1.group()) != _frame_width(m2.group())): continue d.append( { @@ -1051,9 +1038,7 @@ def uncompress(seq_string: str, fmt: str = global_format): # map of directives to regex allow_negative = config.allow_negative_frames() range_pattern = ( - r"-?\d+\s*-\s*-?\d+(?:\s*x\s*\d+)?" - if allow_negative - else r"\d+\s*-\s*\d+(?:\s*x\s*\d+)?" + r"-?\d+\s*-\s*-?\d+(?:\s*x\s*\d+)?" if allow_negative else r"\d+\s*-\s*\d+(?:\s*x\s*\d+)?" ) explicit_pattern = ( r"-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?(?:\s*,\s*-?\d+(?:\s*-\s*-?\d+(?:\s*x\s*\d+)?)?)*" @@ -1089,7 +1074,6 @@ def uncompress(seq_string: str, fmt: str = global_format): regex = re.compile(fmt) match = regex.match(name) - frames = [] frame_values = [] missing = [] s = None diff --git a/lib/pyseq/sfind.py b/lib/pyseq/sfind.py index 2afb17a..1343598 100644 --- a/lib/pyseq/sfind.py +++ b/lib/pyseq/sfind.py @@ -87,9 +87,7 @@ def main(): if not os.path.isdir(path): print(f"sfind: {path} is not a directory", file=sys.stderr) continue - for seq in walk_and_collect_sequences( - path, include_hidden=args.all, pattern=args.name - ): + for seq in walk_and_collect_sequences(path, include_hidden=args.all, pattern=args.name): print(seq) return 0 diff --git a/lib/pyseq/smove.py b/lib/pyseq/smove.py index 27760ea..be2a1ef 100644 --- a/lib/pyseq/smove.py +++ b/lib/pyseq/smove.py @@ -33,18 +33,15 @@ Contains the main smove functions for the pyseq module. """ -import sys -import os import argparse +import os import shutil +import sys from typing import Optional import pyseq from pyseq.cli import cli_catch_keyboard_interrupt -from pyseq.util import ( - parse_destination_reference, - resolve_sequence_reference, -) +from pyseq.util import parse_destination_reference, resolve_sequence_reference def move_sequence( @@ -149,17 +146,11 @@ def main(): dest_spec = parse_destination_reference(dest, seq) if len(sources) > 1 and dest_spec["kind"] != "directory": - raise ValueError( - "destination must be a directory when moving multiple sources" - ) + raise ValueError("destination must be a directory when moving multiple sources") rename = dest_spec["rename"] pad = args.pad if dest_spec["kind"] == "directory" else dest_spec["pad"] - renumber = ( - args.renumber - if dest_spec["kind"] == "directory" - else dest_spec["renumber"] - ) + renumber = args.renumber if dest_spec["kind"] == "directory" else dest_spec["renumber"] dest_dir = dest_spec["dest_dir"] move_sequence( diff --git a/lib/pyseq/sstat.py b/lib/pyseq/sstat.py index dfe8999..02f0486 100644 --- a/lib/pyseq/sstat.py +++ b/lib/pyseq/sstat.py @@ -79,9 +79,7 @@ def format_time_range(t1, t2): return f"{format_time(t1)}.. {format_time(t2)}" print(f"Sequence: {str(seq)}") - print( - f"Size: {seq.format('%H'):>8} Frames: {seq.format('%l'):>5} Padding: {seq.pad}" - ) + print(f"Size: {seq.format('%H'):>8} Frames: {seq.format('%l'):>5} Padding: {seq.pad}") missing = seq.format("%M") print(f"Missing: {missing if missing else 'none'}") print(f"Head: {seq.head()}") diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index 56f62d3..1276de6 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -29,18 +29,16 @@ # POSSIBILITY OF SUCH DAMAGE. # ----------------------------------------------------------------------------- -import functools import fnmatch +import functools import glob import os import re -import sys import warnings from typing import Optional import pyseq from pyseq import config -from pyseq.config import range_join def deprecated(func): @@ -116,9 +114,7 @@ def resolve_sequence(sequence_string: str, include_negative: bool = False): frame_token = f"%0{pad}d" positive_glob = filename.replace(frame_token, "?" * pad) negative_glob = filename.replace(frame_token, "-" + ("?" * pad)) - frame_pattern = ( - r"-?\d{" + str(pad) + r"}" if include_negative else r"\d{" + str(pad) + r"}" - ) + frame_pattern = r"-?\d{" + str(pad) + r"}" if include_negative else r"\d{" + str(pad) + r"}" regex_pattern = re.escape(filename).replace(frame_token, frame_pattern) else: positive_glob = filename.replace("%d", "*") @@ -131,9 +127,7 @@ def resolve_sequence(sequence_string: str, include_negative: bool = False): negative_files = glob.glob(os.path.join(directory, negative_glob)) if candidate_files: candidate_files.extend( - candidate - for candidate in negative_files - if candidate not in candidate_files + candidate for candidate in negative_files if candidate not in candidate_files ) else: candidate_files = negative_files @@ -190,11 +184,7 @@ def subset_sequence(seq, frames): def parse_explicit_sequence_string(reference: str): """Parse a serialized sequence string, including embedded range syntax.""" - from pyseq.frange import ( - has_serialized_range, - parse_frame_range, - split_embedded_frame_range, - ) + from pyseq.frange import has_serialized_range, parse_frame_range, split_embedded_frame_range dirname = os.path.dirname(reference) or "." basename = os.path.basename(reference) @@ -206,9 +196,7 @@ def parse_explicit_sequence_string(reference: str): return None embedded = ( - None - if is_compressed_format_string(basename) - else split_embedded_frame_range(basename) + None if is_compressed_format_string(basename) else split_embedded_frame_range(basename) ) if embedded: head, range_text, tail = embedded @@ -284,20 +272,15 @@ def resolve_sequence_reference(reference: str): candidates = [ seq for seq in sequences - if seq.head() == requested_seq.head() - and seq.tail() == requested_seq.tail() + if seq.head() == requested_seq.head() and seq.tail() == requested_seq.tail() ] candidates = [ - seq - for seq in candidates - if set(requested_seq.frames()).issubset(set(seq.frames())) + seq for seq in candidates if set(requested_seq.frames()).issubset(set(seq.frames())) ] if not candidates: raise FileNotFoundError(f"No sequence found matching {reference}") if len(candidates) > 1: - raise ValueError( - f"Multiple sequences found matching {reference}: {candidates}" - ) + raise ValueError(f"Multiple sequences found matching {reference}: {candidates}") full_seq = candidates[0] return subset_sequence(full_seq, requested_seq.frames()), dirname @@ -327,9 +310,7 @@ def parse_destination_reference(destination: str, source_seq): expected_frames = list(range(dest_frames[0], dest_frames[0] + len(source_seq))) if dest_seq.tail() != source_seq.tail(): - raise ValueError( - "Destination sequence pattern must preserve the source extension" - ) + raise ValueError("Destination sequence pattern must preserve the source extension") if dest_frames != expected_frames: raise ValueError("Destination explicit range must be contiguous") if len(dest_seq) != len(source_seq): @@ -359,9 +340,7 @@ def parse_destination_reference(destination: str, source_seq): tail = filename[match.end() :] if tail != source_seq.tail(): - raise ValueError( - "Destination sequence pattern must preserve the source extension" - ) + raise ValueError("Destination sequence pattern must preserve the source extension") return { "kind": "sequence", diff --git a/pyproject.toml b/pyproject.toml index 17edf1c..bc3aa06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,16 @@ classifiers = [ authors = [ { name = "Ryan Galloway", email = "ryan@rsgalloway.com" }, ] -optional-dependencies = { dev = ["pytest"], test = ["pytest"] } +optional-dependencies = { dev = ["pytest", "flake8", "mccabe", "isort", "black"], test = ["pytest"] } + +[tool.isort] +profile = "black" +line_length = 100 +skip_gitignore = true + +[tool.black] +line-length = 100 +target-version = ["py38"] [project.scripts] lss = "pyseq.lss:main" diff --git a/scripts/benchmark.py b/scripts/benchmark.py index 0e3525d..bdef478 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -21,7 +21,6 @@ from datetime import datetime, timezone from pathlib import Path - REPO_ROOT = Path(__file__).resolve().parent.parent LIB_ROOT = REPO_ROOT / "lib" SCRIPT_PATH = Path(__file__).resolve() @@ -33,7 +32,6 @@ from pyseq import get_sequences # noqa: E402 from pyseq.util import resolve_sequence # noqa: E402 - DEFAULT_SIZES = [100, 1000, 10000] FULL_SIZES = [100, 1000, 10000, 50000] DEFAULT_ITERATIONS = 5 @@ -177,14 +175,9 @@ def create_mixed_dataset(root, size, extra_files_factor=EXTRA_FILES_FACTOR): tertiary_start = 1001 quaternary_start = 1001 for offset in range(secondary_count): - ( - dataset / f"big_buck_bunny_1080p_h264.{secondary_start + offset:08d}.png" - ).touch() + (dataset / f"big_buck_bunny_1080p_h264.{secondary_start + offset:08d}.png").touch() for offset in range(tertiary_count): - ( - dataset - / f"big_buck_bunny_1080p_h264_test.{tertiary_start + offset:08d}.png" - ).touch() + (dataset / f"big_buck_bunny_1080p_h264_test.{tertiary_start + offset:08d}.png").touch() for offset in range(quaternary_count): (dataset / f"test.{quaternary_start + offset:08d}.png").touch() @@ -209,9 +202,7 @@ def create_existing_dataset(path, sequence_pattern=None, glob_pattern=None): if glob_pattern: import fnmatch - file_names = [ - name for name in file_names if fnmatch.fnmatch(name, glob_pattern) - ] + file_names = [name for name in file_names if fnmatch.fnmatch(name, glob_pattern)] return { "path": dataset, @@ -348,9 +339,7 @@ def build_benchmarks( [ { "name": f"get_sequences_list_{label}", - "callable": lambda file_names=file_names: get_sequences( - file_names - ), + "callable": lambda file_names=file_names: get_sequences(file_names), "profile_kind": "python", }, { @@ -412,9 +401,7 @@ def run_benchmarks( profiles_dir.mkdir(parents=True, exist_ok=True) for case in cases: if case["profile_kind"] == "python": - profile_python_callable( - case["callable"], case["name"], profiles_dir - ) + profile_python_callable(case["callable"], case["name"], profiles_dir) else: profile_lss_subprocess( case["path"], @@ -605,9 +592,7 @@ def write_compare_summary(payload, stream): f"Baseline pyseq: `{payload['baseline'].get('pyseq_file')}` Candidate pyseq: `{payload['candidate'].get('pyseq_file')}`", file=stream, ) - if payload["baseline"].get("pyseq_seq_file") or payload["candidate"].get( - "pyseq_seq_file" - ): + if payload["baseline"].get("pyseq_seq_file") or payload["candidate"].get("pyseq_seq_file"): print( f"Baseline pyseq.seq: `{payload['baseline'].get('pyseq_seq_file')}` Candidate pyseq.seq: `{payload['candidate'].get('pyseq_seq_file')}`", file=stream, @@ -662,9 +647,7 @@ def main(): type=parse_csv_list, help="Comma-separated synthetic scenarios, for example: contiguous,mixed", ) - parser.add_argument( - "--iterations", type=int, help="Override timing iteration count" - ) + parser.add_argument("--iterations", type=int, help="Override timing iteration count") parser.add_argument("--json", dest="json_path", help="Write JSON output to a file") parser.add_argument( "--no-profile", @@ -717,15 +700,9 @@ def main(): ) return - sizes = ( - None - if args.path - else (args.sizes or (FULL_SIZES if args.full else DEFAULT_SIZES)) - ) + sizes = None if args.path else (args.sizes or (FULL_SIZES if args.full else DEFAULT_SIZES)) scenarios = None if args.path else (args.scenarios or DEFAULT_SCENARIOS) - iterations = args.iterations or ( - FULL_ITERATIONS if args.full else DEFAULT_ITERATIONS - ) + iterations = args.iterations or (FULL_ITERATIONS if args.full else DEFAULT_ITERATIONS) profiles_dir = Path(args.profiles_dir) if args.profiles_dir else None payload = build_run_payload( sizes=sizes, diff --git a/scripts/build_pages_site.py b/scripts/build_pages_site.py index f21102d..a9fdee4 100644 --- a/scripts/build_pages_site.py +++ b/scripts/build_pages_site.py @@ -10,7 +10,6 @@ import shutil from pathlib import Path - LINK_PATTERNS = ( (r"\(README\.md\)", "(index.html)"), (r"\(docs/README\.md\)", "(docs/index.html)"), @@ -124,9 +123,7 @@ def build_site(args): write_benchmarks_page( output_dir, - benchmark_summary=Path(args.benchmark_summary) - if args.benchmark_summary - else None, + benchmark_summary=Path(args.benchmark_summary) if args.benchmark_summary else None, benchmark_json=Path(args.benchmark_json) if args.benchmark_json else None, ) diff --git a/scripts/install_precommit_hook.py b/scripts/install_precommit_hook.py new file mode 100644 index 0000000..bc5a63b --- /dev/null +++ b/scripts/install_precommit_hook.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2011-2026, Ryan Galloway (ryan@rsgalloway.com) +# + +"""Install an optional git pre-commit hook for local quality checks.""" + +import stat +import sys +from pathlib import Path + +HOOK_TEMPLATE = """#!/usr/bin/env bash +set -euo pipefail + +ROOT={root} +PYTHON={python} + +cd "$ROOT" + +"$PYTHON" -m isort --check-only --diff lib/pyseq tests scripts +"$PYTHON" -m black --check lib/pyseq tests scripts +"$PYTHON" -m flake8 lib/pyseq tests scripts +"$PYTHON" -m pytest tests -q +""" + + +def main(): + repo_root = Path(__file__).resolve().parent.parent + git_dir = repo_root / ".git" + hooks_dir = git_dir / "hooks" + hook_path = hooks_dir / "pre-commit" + + if not git_dir.exists(): + raise SystemExit("No .git directory found; run this from a git checkout.") + + hooks_dir.mkdir(parents=True, exist_ok=True) + hook_path.write_text( + HOOK_TEMPLATE.format( + root=repr(str(repo_root)), + python=repr(sys.executable), + ), + encoding="utf-8", + ) + hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + print(f"Installed pre-commit hook at {hook_path}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_cli_interrupts.py b/tests/test_cli_interrupts.py index c8207e3..6db691e 100644 --- a/tests/test_cli_interrupts.py +++ b/tests/test_cli_interrupts.py @@ -48,9 +48,7 @@ def test_copy_move_cli_main_handles_keyboard_interrupt( def test_srm_cli_main_handles_keyboard_interrupt(monkeypatch, capsys, tmp_path): monkeypatch.chdir(tmp_path) - monkeypatch.setattr( - sremove, "resolve_sequence_reference", _raise_keyboard_interrupt - ) + monkeypatch.setattr(sremove, "resolve_sequence_reference", _raise_keyboard_interrupt) monkeypatch.setattr("sys.argv", ["srm", "a.%04d.exr"]) assert sremove.main() == 1 diff --git a/tests/test_lss.py b/tests/test_lss.py index 083ec18..c65ddbe 100644 --- a/tests/test_lss.py +++ b/tests/test_lss.py @@ -36,8 +36,8 @@ import os import subprocess import tempfile -import pytest +import pytest from conftest import get_installed_command lss_bin = get_installed_command("lss") diff --git a/tests/test_performance.py b/tests/test_performance.py index 32a4216..8d11e69 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -8,8 +8,9 @@ import time import unittest -from pyseq import Sequence, get_sequences, uncompress +from pyseq import Sequence, get_sequences from pyseq import seq as pyseq +from pyseq import uncompress class PerformanceTests(unittest.TestCase): diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index ad20b18..4ba65df 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -34,19 +34,20 @@ """ import os -import re import random -import tempfile -import unittest +import re import subprocess import sys +import tempfile +import unittest from unittest import mock sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from conftest import get_installed_command -from pyseq import Item, Sequence, diff, uncompress, get_sequences -from pyseq import SequenceError + +from pyseq import Item, Sequence, SequenceError, diff, get_sequences from pyseq import seq as pyseq +from pyseq import uncompress from pyseq.util import resolve_sequence_reference pyseq.default_format = "%h%r%t" @@ -689,9 +690,7 @@ def test_resolve_sequence_reference_with_negative_filenames(self): frame_text = f"-{abs(frame):04d}" else: frame_text = f"{frame:04d}" - with open( - os.path.join(tmpdir, f"render.{frame_text}.exr"), "w" - ) as handle: + with open(os.path.join(tmpdir, f"render.{frame_text}.exr"), "w") as handle: handle.write("dummy frame") seq, dirname = resolve_sequence_reference( @@ -705,9 +704,7 @@ def test_resolve_sequence_reference_with_negative_filenames(self): def test_resolve_sequence_reference_with_negative_fixture_files(self): with enable_negative_frames(): pyseq.strict_pad = True - seq, dirname = resolve_sequence_reference( - "./tests/files/negA.%04d.exr -2-1" - ) + seq, dirname = resolve_sequence_reference("./tests/files/negA.%04d.exr -2-1") self.assertEqual(dirname, "./tests/files") self.assertEqual(seq.frames(), [-2, -1, 0, 1]) @@ -719,9 +716,7 @@ class LSSTestCase(unittest.TestCase): def run_command(self, *args): """a simple wrapper for subprocess.Popen""" - process = subprocess.Popen( - args, stdout=subprocess.PIPE, universal_newlines=True - ) + process = subprocess.Popen(args, stdout=subprocess.PIPE, universal_newlines=True) stdout, _ = process.communicate() return stdout @@ -738,30 +733,31 @@ def test_lss_is_working_properly_1(self): result = self.run_command(self.lss, test_files) self.assertEqual( - """ 10 012_vb_110_v001.%04d.png [1-10] - 10 012_vb_110_v002.%04d.png [1-10] - 7 a.%03d.tga [1-3, 10, 12-14] - 1 alpha.txt - 5 bnc01_TinkSO_tx_0_ty_0.%04d.tif [101-105] - 5 bnc01_TinkSO_tx_0_ty_1.%04d.tif [101-105] - 5 bnc01_TinkSO_tx_1_ty_0.%04d.tif [101-105] - 5 bnc01_TinkSO_tx_1_ty_1.%04d.tif [101-105] - 4 file.%02d.tif [1-2, 98-99] - 1 file.info.03.rgb - 3 file01.%03d.j2k [1-2, 4] - 4 file01_%04d.rgb [40-43] - 4 file02_%04d.rgb [44-47] - 4 file%d.03.rgb [1-4] - 3 fileA.%04d.jpg [1-3] - 3 fileA.%04d.png [1-3] - 1 file_02.tif - 2 negA.-%04d.exr [1-2] - 2 negA.%04d.exr [0-1] - 4 stepA.%d.exr [1001, 1004, 1007, 1010] - 4 z1_001_v1.%d.png [1-4] - 4 z1_002_v1.%d.png [1-4] - 4 z1_002_v2.%d.png [9-12] -""", + ( + " 10 012_vb_110_v001.%04d.png [1-10]\n" + " 10 012_vb_110_v002.%04d.png [1-10]\n" + " 7 a.%03d.tga [1-3, 10, 12-14]\n" + " 1 alpha.txt \n" + " 5 bnc01_TinkSO_tx_0_ty_0.%04d.tif [101-105]\n" + " 5 bnc01_TinkSO_tx_0_ty_1.%04d.tif [101-105]\n" + " 5 bnc01_TinkSO_tx_1_ty_0.%04d.tif [101-105]\n" + " 5 bnc01_TinkSO_tx_1_ty_1.%04d.tif [101-105]\n" + " 4 file.%02d.tif [1-2, 98-99]\n" + " 1 file.info.03.rgb \n" + " 3 file01.%03d.j2k [1-2, 4]\n" + " 4 file01_%04d.rgb [40-43]\n" + " 4 file02_%04d.rgb [44-47]\n" + " 4 file%d.03.rgb [1-4]\n" + " 3 fileA.%04d.jpg [1-3]\n" + " 3 fileA.%04d.png [1-3]\n" + " 1 file_02.tif \n" + " 2 negA.-%04d.exr [1-2]\n" + " 2 negA.%04d.exr [0-1]\n" + " 4 stepA.%d.exr [1001, 1004, 1007, 1010]\n" + " 4 z1_001_v1.%d.png [1-4]\n" + " 4 z1_002_v1.%d.png [1-4]\n" + " 4 z1_002_v2.%d.png [9-12]\n" + ), result, ) @@ -933,9 +929,7 @@ def get_range(frames): self.assertTrue(len(missing), 5000000) self.assertEqual(missing[0][0], 100000001) self.assertEqual(missing[0][-1], 499999999) - self.assertEqual( - seqs[0].format(), " 2 image-%09d-2048x2048.jpg [100000000, 500000000]" - ) + self.assertEqual(seqs[0].format(), " 2 image-%09d-2048x2048.jpg [100000000, 500000000]") self.assertEqual(seqs[0].format("%M"), "[100000001-499999999, ]") # high missing frame count test 3 (from the issue) @@ -1039,9 +1033,7 @@ def test_issue_83(self): ] # test using default frame pattern - seqs1 = pyseq.get_sequences( - filenames, frame_pattern=config.DEFAULT_FRAME_PATTERN - ) + seqs1 = pyseq.get_sequences(filenames, frame_pattern=config.DEFAULT_FRAME_PATTERN) self.assertEqual(len(seqs1), 1) # test if a new file in the sequence is included diff --git a/tests/test_scopy.py b/tests/test_scopy.py index 678dfe7..7ae55d6 100644 --- a/tests/test_scopy.py +++ b/tests/test_scopy.py @@ -36,10 +36,11 @@ import os import subprocess import tempfile + import pytest +from conftest import get_installed_command import pyseq -from conftest import get_installed_command from pyseq.scopy import copy_sequence scopy_bin = get_installed_command("scopy") diff --git a/tests/test_sdiff.py b/tests/test_sdiff.py index 2f3bef2..8b31f43 100644 --- a/tests/test_sdiff.py +++ b/tests/test_sdiff.py @@ -37,10 +37,11 @@ import os import subprocess import tempfile + import pytest +from conftest import get_installed_command import pyseq -from conftest import get_installed_command from pyseq.sdiff import diff_sequences sdiff_bin = get_installed_command("sdiff") diff --git a/tests/test_sfind.py b/tests/test_sfind.py index 3624617..cf823f5 100644 --- a/tests/test_sfind.py +++ b/tests/test_sfind.py @@ -36,8 +36,8 @@ import os import subprocess import tempfile -import pytest +import pytest from conftest import get_installed_command sfind_bin = get_installed_command("sfind") diff --git a/tests/test_smove.py b/tests/test_smove.py index d790136..68051db 100644 --- a/tests/test_smove.py +++ b/tests/test_smove.py @@ -36,10 +36,11 @@ import os import subprocess import tempfile + import pytest +from conftest import get_installed_command import pyseq -from conftest import get_installed_command from pyseq.smove import move_sequence smv_bin = get_installed_command("smv") diff --git a/tests/test_srm.py b/tests/test_srm.py index 29b0135..a134f82 100644 --- a/tests/test_srm.py +++ b/tests/test_srm.py @@ -36,10 +36,11 @@ import os import subprocess import tempfile + import pytest +from conftest import get_installed_command import pyseq -from conftest import get_installed_command from pyseq.sremove import remove_sequence srm_bin = get_installed_command("srm") diff --git a/tests/test_sstat.py b/tests/test_sstat.py index 0611a1f..82b8e15 100644 --- a/tests/test_sstat.py +++ b/tests/test_sstat.py @@ -33,14 +33,15 @@ Contains tests for the sstat module. """ +import json import os import subprocess import tempfile -import json + import pytest +from conftest import get_installed_command import pyseq -from conftest import get_installed_command from pyseq.sstat import json_sstat sstat_bin = get_installed_command("sstat") diff --git a/tests/test_stree.py b/tests/test_stree.py index 791782d..4f5089e 100644 --- a/tests/test_stree.py +++ b/tests/test_stree.py @@ -36,8 +36,8 @@ import os import subprocess import tempfile -import pytest +import pytest from conftest import get_installed_command stree_bin = get_installed_command("stree") From c4ea4e51698827414e8bd5f6e4e8057563e75f94 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 06:55:12 -0700 Subject: [PATCH 24/26] Pin code quality tool versions for consistent local and CI checks --- CONTRIBUTING.md | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea61f14..e93cdcd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ PySeq changes should stay small, clear, and performance-aware. `python -m black --check lib/pyseq tests scripts` `python -m isort --check-only --diff lib/pyseq tests scripts` `python -m flake8 lib/pyseq tests scripts` +- Run those commands from the repo's `.venv` so local results match CI. - Prefer focused changes over broad refactors unless the refactor is the task. - Keep the default filename discovery path permissive and fast. diff --git a/pyproject.toml b/pyproject.toml index bc3aa06..753cf45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ classifiers = [ authors = [ { name = "Ryan Galloway", email = "ryan@rsgalloway.com" }, ] -optional-dependencies = { dev = ["pytest", "flake8", "mccabe", "isort", "black"], test = ["pytest"] } +optional-dependencies = { dev = ["pytest", "flake8==7.1.1", "mccabe==0.7.0", "isort==5.13.2", "black==24.8.0"], test = ["pytest"] } [tool.isort] profile = "black" From 3c55282a79b66876b854b9ebf4d93c500dc001b0 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 07:11:50 -0700 Subject: [PATCH 25/26] Fix negative-frame test state and harden benchmark regression gating --- .github/workflows/benchmarks.yml | 16 ++++++++++++---- lib/pyseq/util.py | 3 ++- tests/test_pyseq.py | 17 ++++++++++++++--- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 21615eb..65eb29b 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -69,14 +69,22 @@ jobs: - name: Compare benchmark runs id: compare - continue-on-error: true if: success() || hashFiles('baseline/baseline-benchmark.json', 'candidate-benchmark.json') != '' run: | + set +e python scripts/benchmark.py \ --compare baseline/baseline-benchmark.json candidate-benchmark.json \ --json benchmark-compare.json \ --fail-on-regression-pct 3 \ --fail-on-regression-s 0.005 > benchmark-compare.md + status=$? + echo "exit_code=$status" >> "$GITHUB_OUTPUT" + if [ "$status" -eq 0 ]; then + echo "threshold_exceeded=false" >> "$GITHUB_OUTPUT" + else + echo "threshold_exceeded=true" >> "$GITHUB_OUTPUT" + fi + exit 0 - name: Write benchmark summaries if: always() @@ -107,13 +115,13 @@ jobs: uses: actions/github-script@v7 env: BENCHMARK_COMPARE_PATH: benchmark-compare.md - BENCHMARK_THRESHOLD_STATUS: ${{ steps.compare.outcome }} + BENCHMARK_THRESHOLD_EXCEEDED: ${{ steps.compare.outputs.threshold_exceeded }} with: script: | const fs = require("fs"); const marker = ""; const compare = fs.readFileSync(process.env.BENCHMARK_COMPARE_PATH, "utf8"); - const failed = process.env.BENCHMARK_THRESHOLD_STATUS !== "success"; + const failed = process.env.BENCHMARK_THRESHOLD_EXCEEDED === "true"; const header = failed ? "## Benchmark Regression Alert" : "## Benchmark Comparison"; @@ -179,7 +187,7 @@ jobs: if-no-files-found: warn - name: Enforce regression threshold - if: always() && steps.compare.conclusion == 'failure' + if: always() && steps.compare.outputs.threshold_exceeded == 'true' run: | echo "Benchmark regression exceeded the +3.00% and +0.005s thresholds." exit 1 diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index 1276de6..235a90d 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -126,8 +126,9 @@ def resolve_sequence(sequence_string: str, include_negative: bool = False): if include_negative and negative_glob and negative_glob != positive_glob: negative_files = glob.glob(os.path.join(directory, negative_glob)) if candidate_files: + existing = set(candidate_files) candidate_files.extend( - candidate for candidate in negative_files if candidate not in candidate_files + candidate for candidate in negative_files if candidate not in existing ) else: candidate_files = negative_files diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index 4ba65df..73a9f4a 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -40,12 +40,12 @@ import sys import tempfile import unittest -from unittest import mock +from contextlib import contextmanager sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from conftest import get_installed_command -from pyseq import Item, Sequence, SequenceError, diff, get_sequences +from pyseq import Item, Sequence, SequenceError, config, diff, get_sequences from pyseq import seq as pyseq from pyseq import uncompress from pyseq.util import resolve_sequence_reference @@ -58,8 +58,19 @@ def assert_read_only_attribute_error(message): assert any(snippet in message for snippet in valid_snippets), message +@contextmanager def enable_negative_frames(): - return mock.patch.dict(os.environ, {"PYSEQ_ALLOW_NEGATIVE_FRAMES": "1"}) + previous = os.environ.get("PYSEQ_ALLOW_NEGATIVE_FRAMES") + os.environ["PYSEQ_ALLOW_NEGATIVE_FRAMES"] = "1" + config.set_frame_pattern(config.PYSEQ_FRAME_PATTERN) + try: + yield + finally: + if previous is None: + os.environ.pop("PYSEQ_ALLOW_NEGATIVE_FRAMES", None) + else: + os.environ["PYSEQ_ALLOW_NEGATIVE_FRAMES"] = previous + config.set_frame_pattern(config.PYSEQ_FRAME_PATTERN) class ItemTestCase(unittest.TestCase): From 8a69cef21d201a5135f1af7b2dc12f0b21a32fff Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Fri, 24 Jul 2026 07:16:23 -0700 Subject: [PATCH 26/26] Adds lazy import explanatory comment --- lib/pyseq/seq.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index 17b38d4..3f4f23c 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -72,6 +72,8 @@ def _ext_key(x: str): return [ext] + _natural_key(name) +# Import frame-range helpers lazily so default pyseq/lss imports do not pay +# frange regex compilation costs unless range parsing/formatting is used. def _format_frame_range_explicit(frames: List[int], pad_with_brackets: bool = True) -> str: from pyseq.frange import format_frame_range_explicit