From 72e08b85f4ee62ca0a4efcc637be2237e57a02f1 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 31 May 2025 06:56:34 -0700 Subject: [PATCH 01/14] catch keyboard interrupts in sfind for clean exit --- lib/pyseq/__init__.py | 2 +- lib/pyseq/sfind.py | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/pyseq/__init__.py b/lib/pyseq/__init__.py index e155c8d..1e40924 100644 --- a/lib/pyseq/__init__.py +++ b/lib/pyseq/__init__.py @@ -48,7 +48,7 @@ """ __author__ = "Ryan Galloway" -__version__ = "0.9.0" +__version__ = "0.9.1" try: import envstack diff --git a/lib/pyseq/sfind.py b/lib/pyseq/sfind.py index 98e69b9..aef379c 100644 --- a/lib/pyseq/sfind.py +++ b/lib/pyseq/sfind.py @@ -81,14 +81,18 @@ def main(): ) args = parser.parse_args() - for path in args.paths: - 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 - ): - print(seq) + try: + for path in args.paths: + 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 + ): + print(seq) + except KeyboardInterrupt: + print("\nstopping...") + sys.exit(1) return 0 From b3fe047fc9360a3ff9992e2e0c1f6d0aa3196732 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 17:21:04 -0700 Subject: [PATCH 02/14] Modernize packaging and remove envstack dependency --- .github/workflows/tests.yml | 39 +++++++++++++++++++++ README.md | 25 +++----------- lib/pyseq/__init__.py | 7 ---- pyproject.toml | 37 ++++++++++++++++++++ pyseq.env | 50 +++++++++++++-------------- requirements.txt | 2 +- setup.py | 68 ------------------------------------- tests/test_pyseq.py | 4 ++- 8 files changed, 107 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bf45f70 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: tests + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package and test dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install -e ".[dev]" + + - name: Run test suite + run: pytest diff --git a/README.md b/README.md index da54903..d7c7070 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ examples, see basic usage below or http://rsgalloway.github.io/pyseq [Frame Patterns](#frame-patterns) | [Testing](#testing) - ## Installation The easiest way to install pyseq: @@ -27,17 +26,9 @@ $ pip install -U pyseq #### Environment -PySeq uses [envstack](https://github.com/rsgalloway/envstack) to externalize -settings and looks for a `pyseq.env` file to source environment variables: - -```bash -$ pip install -U envstack -$ ./pyseq.env -r -PYSEQ_FRAME_PATTERN=\d+ -PYSEQ_GLOBAL_FORMAT=%4l %h%p%t %R -PYSEQ_RANGE_SEP=, -PYSEQ_STRICT_PAD=0 -``` +PySeq reads configuration from standard environment variables. The repository +includes a `pyseq.env` example [envstack](https://github.com/rsgalloway/envstack) +file you can source in a shell or adapt for your own environment: #### Distribution @@ -257,14 +248,6 @@ with an _, you might use: $ export PYSEQ_FRAME_PATTERN="_\d+" ``` -Environment vars can be defined anywhere in your environment, or if using -[envstack](https://github.com/rsgalloway/envstack) add it to the -`pyseq.env` file and make sure it's found in `${ENVPATH}`: - -```bash -$ export ENVPATH=/path/to/env/files -``` - Examples of regex patterns can be found in the `pyseq.env` file: ```yaml @@ -286,5 +269,5 @@ PYSEQ_FRAME_PATTERN: _\d+ To run the unit tests, simply run `pytest` in a shell: ```bash -$ pytest tests/ +$ pytest tests -q ``` diff --git a/lib/pyseq/__init__.py b/lib/pyseq/__init__.py index 1e40924..1d189f1 100644 --- a/lib/pyseq/__init__.py +++ b/lib/pyseq/__init__.py @@ -50,11 +50,4 @@ __author__ = "Ryan Galloway" __version__ = "0.9.1" -try: - import envstack - - envstack.init("pyseq") -except Exception: - pass - from .seq import * diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..37bd53f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyseq" +dynamic = ["version"] +description = "Compressed File Sequence String Module" +readme = "README.md" +requires-python = ">=3.6" +license = { text = "BSD-3-Clause" } +authors = [ + { name = "Ryan Galloway", email = "ryan@rsgalloway.com" }, +] +urls = { Homepage = "http://github.com/rsgalloway/pyseq" } +optional-dependencies = { dev = ["pytest"], test = ["pytest"] } + +[project.scripts] +lss = "pyseq.lss:main" +scopy = "pyseq.scopy:main" +sdiff = "pyseq.sdiff:main" +sfind = "pyseq.sfind:main" +smove = "pyseq.smove:main" +sstat = "pyseq.sstat:main" +stree = "pyseq.stree:main" + +[tool.setuptools] +zip-safe = false + +[tool.setuptools.package-dir] +"" = "lib" + +[tool.setuptools.packages.find] +where = ["lib"] + +[tool.setuptools.dynamic] +version = { attr = "pyseq.__version__" } diff --git a/pyseq.env b/pyseq.env index 87056b1..183ad0f 100755 --- a/pyseq.env +++ b/pyseq.env @@ -1,35 +1,31 @@ -#!/usr/bin/env envstack -include: [default] -all: &all - # matches all numbers, the most flexible - PYSEQ_FRAME_PATTERN: \d+ +# Source this file with: +# set -a +# . ./pyseq.env +# set +a - # excludes version numbers, e.g. file_v001.1001.exr - # PYSEQ_FRAME_PATTERN: ([^v\d])\d+ +# matches all numbers, the most flexible +export PYSEQ_FRAME_PATTERN="${PYSEQ_FRAME_PATTERN:-\\d+}" - # frame numbers are dot-delimited, e.g. file.v1.1001.exr - # PYSEQ_FRAME_PATTERN: \.\d+\. +# excludes version numbers, e.g. file_v001.1001.exr +# export PYSEQ_FRAME_PATTERN="([^v\\d])\\d+" - # frame numbers start with an underscore, e.g. file_v1_1001.exr - # PYSEQ_FRAME_PATTERN: _\d+ +# frame numbers are dot-delimited, e.g. file.v1.1001.exr +# export PYSEQ_FRAME_PATTERN="\\.\\d+\\." - # sequence string format: 4 file01_%04d.exr [40-43] (default) - PYSEQ_GLOBAL_FORMAT: "%4l %h%p%t %R" +# frame numbers start with an underscore, e.g. file_v1_1001.exr +# export PYSEQ_FRAME_PATTERN="_\\d+" - # sequence string format: file01_%04d.exr - # PYSEQ_GLOBAL_FORMAT: "%h%p%t" +# sequence string format: 4 file01_%04d.exr [40-43] (default) +export PYSEQ_GLOBAL_FORMAT="${PYSEQ_GLOBAL_FORMAT:-%4l %h%p%t %R}" - # sequence string format: file01_40-43.exr - # PYSEQ_GLOBAL_FORMAT: "%h%r%t" +# sequence string format: file01_%04d.exr +# export PYSEQ_GLOBAL_FORMAT="%h%p%t" - # use strict padding on sequences (pad length must match) - PYSEQ_STRICT_PAD: ${PYSEQ_STRICT_PAD:=0} +# sequence string format: file01_40-43.exr +# export PYSEQ_GLOBAL_FORMAT="%h%r%t" - # character to join explicit frame ranges on - PYSEQ_RANGE_SEP: ", " -darwin: - <<: *all -linux: - <<: *all -windows: - <<: *all \ No newline at end of file +# use strict padding on sequences (pad length must match) +export PYSEQ_STRICT_PAD="${PYSEQ_STRICT_PAD:-0}" + +# character to join explicit frame ranges on +export PYSEQ_RANGE_SEP="${PYSEQ_RANGE_SEP:-, }" diff --git a/requirements.txt b/requirements.txt index 4eb1809..e079f8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -envstack>=0.8.3 +pytest diff --git a/setup.py b/setup.py deleted file mode 100644 index 1ff0de8..0000000 --- a/setup.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python -# -# Copyright (c) 2011-2025, 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: -# -# - Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# - Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# - Neither the name of the software nor the names of its contributors -# may be used to endorse or promote products derived from this software -# without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# ----------------------------------------------------------------------------- - -import sys -from os import path - -from setuptools import find_packages, setup - -this_directory = path.abspath(path.dirname(__file__)) -with open(path.join(this_directory, "README.md")) as f: - long_description = f.read() - -sys.path.insert(0, "lib") -from pyseq import __version__ - -setup( - name="pyseq", - version=__version__, - description="Compressed File Sequence String Module", - long_description=long_description, - long_description_content_type="text/markdown", - author="Ryan Galloway", - author_email="ryan@rsgalloway.com", - url="http://github.com/rsgalloway/pyseq", - package_dir={"": "lib"}, - packages=find_packages("lib"), - entry_points={ - "console_scripts": [ - "lss = pyseq.lss:main", - "scopy = pyseq.scopy:main", - "sdiff = pyseq.sdiff:main", - "sfind = pyseq.sfind:main", - "smove = pyseq.smove:main", - "sstat = pyseq.sstat:main", - "stree = pyseq.stree:main", - ], - }, - python_requires=">=3.6", - zip_safe=False, -) diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index d7db829..ed05809 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -620,7 +620,9 @@ def test_performance_1(self): print("time taken to create sequence: %s" % (total_time)) self.assertEqual(str(seq), "file.1-9999.jpg") self.assertEqual(len(seq), 9999) - self.assertTrue(total_time < 0.1) + # 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) class TestIssues(unittest.TestCase): From 110d3c1d3de20d0863183e1875ed2209690d3bb6 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 17:24:04 -0700 Subject: [PATCH 03/14] Removes requirements.txt file --- CHANGELOG => CHANGELOG.md | 0 requirements.txt | 1 - 2 files changed, 1 deletion(-) rename CHANGELOG => CHANGELOG.md (100%) delete mode 100644 requirements.txt diff --git a/CHANGELOG b/CHANGELOG.md similarity index 100% rename from CHANGELOG rename to CHANGELOG.md diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e079f8a..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pytest From 1de6f2796bce30f2318fde57c82d57e8257aa6c2 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 17:37:24 -0700 Subject: [PATCH 04/14] Fix Sequence.contains() false positives with unrelated numbers --- lib/pyseq/seq.py | 42 +++++++++++++++++++++++++++++++++++++----- tests/test_pyseq.py | 30 +++++++++++++++++------------- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/lib/pyseq/seq.py b/lib/pyseq/seq.py index 8727cdd..2bfc17d 100755 --- a/lib/pyseq/seq.py +++ b/lib/pyseq/seq.py @@ -663,13 +663,45 @@ def includes(self, item: Union[str, Item]): if not isinstance(item, Item): item = Item(item) - if self[-1] != item: - return self[-1].is_sibling(item) - elif self[0] != item: - return self[0].is_sibling(item) - elif self[0] == item: + if self[-1] == item: + item.frame = self[-1].frame + item.pad = self[-1].pad + item.head = self[-1].head + item.tail = self[-1].tail + return True + + if self[0] == item: + item.frame = self[0].frame + item.pad = self[0].pad + item.head = self[0].head + item.tail = self[0].tail return True + if len(self) == 1: + return self[0].is_sibling(item) + + # Compare against cloned anchors so membership checks do not mutate the + # cached frame metadata on items already stored in the sequence. + canonical_head = self[0].head + canonical_tail = self[0].tail + + anchors = [] + for member in (self[-1], self[0]): + if anchors and member == self[-1] == self[0]: + continue + anchor = Item(member) + anchor.frame = member.frame + anchor.pad = member.pad + anchor.head = member.head + anchor.tail = member.tail + anchors.append(anchor) + + for anchor in anchors: + if anchor.is_sibling(item): + return item.name.startswith(canonical_head) and item.name.endswith( + canonical_tail + ) + return False def contains(self, item: Item): diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index ed05809..80ce084 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -913,22 +913,26 @@ def test_issue_83(self): # should have 4 sequences, with one file each self.assertEqual(len(seqs2), len(filenames)) - # test that items from sequences 1 and 2 are not siblings - seq1item1 = seqs1[0][0] - seq2item1 = seqs2[0][0] - self.assertFalse(seq1item1.is_sibling(seq2item1)) + def test_issue_89(self): + """tests issue 89. contains() should ignore unrelated numbers.""" - # test that 2 items in the sequence 1 are still siblings - seq1item2 = seqs1[0][1] - self.assertTrue(seq1item1.is_sibling(seq1item2)) + filenames = [ + "s001_0030_1.jpg", + "s001_0030_2.jpg", + "s001_0090_1.jpg", + "s001_0090_2.jpg", + ] - # test items in sequences 1 and 2 are not siblings - self.assertFalse(seq1item1.is_sibling(seq2item1)) + seqs = pyseq.get_sequences(filenames) + self.assertEqual(len(seqs), 2) - # test that the new item is still included in the first sequence, - # and excluded from the second sequence - self.assertTrue(seqs1[0].includes(item)) - self.assertFalse(seqs2[0].includes(item)) + seq = seqs[1] + self.assertEqual(str(seq), "s001_0090_1-2.jpg") + self.assertEqual(seq.frames(), [1, 2]) + self.assertFalse(seq.includes("s001_0030_2.jpg")) + self.assertFalse(seq.contains("s001_0030_2.jpg")) + self.assertEqual(seq.frames(), [1, 2]) + self.assertEqual(str(seq), "s001_0090_1-2.jpg") def test_issue_86(self): """tests issue 86. uncompress() with whitespace.""" From 64c8deed2064d364af0857d51536021e10f8199a Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 17:38:50 -0700 Subject: [PATCH 05/14] Update CLI wrapper shebangs to python3 --- bin/lss | 2 +- bin/scopy | 2 +- bin/sdiff | 2 +- bin/sfind | 2 +- bin/smove | 2 +- bin/sstat | 2 +- bin/stree | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bin/lss b/bin/lss index 85847cf..9143b3d 100755 --- a/bin/lss +++ b/bin/lss @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) # diff --git a/bin/scopy b/bin/scopy index 785e482..5f8b4b9 100755 --- a/bin/scopy +++ b/bin/scopy @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) # diff --git a/bin/sdiff b/bin/sdiff index f46eff8..de3bb36 100755 --- a/bin/sdiff +++ b/bin/sdiff @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) # diff --git a/bin/sfind b/bin/sfind index de64f85..3fd29a7 100755 --- a/bin/sfind +++ b/bin/sfind @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) # diff --git a/bin/smove b/bin/smove index 51ce423..2201ed5 100755 --- a/bin/smove +++ b/bin/smove @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) # diff --git a/bin/sstat b/bin/sstat index 456526e..32a37f7 100755 --- a/bin/sstat +++ b/bin/sstat @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) # diff --git a/bin/stree b/bin/stree index 8bbef18..6c3cc96 100755 --- a/bin/stree +++ b/bin/stree @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # Copyright (c) 2011-2025, Ryan Galloway (ryan@rsgalloway.com) # From 8d7e625889fff21d90bb7d4a512f10d3e6dc4ac5 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 17:45:30 -0700 Subject: [PATCH 06/14] Test installed CLI entry points instead of repo wrapper scripts --- tests/conftest.py | 23 +++++++++++++++++++++++ tests/test_lss.py | 7 ++----- tests/test_pyseq.py | 16 +++++++--------- tests/test_scopy.py | 8 ++------ tests/test_sdiff.py | 8 ++------ tests/test_sfind.py | 7 ++----- tests/test_smove.py | 8 ++------ tests/test_sstat.py | 8 ++------ tests/test_stree.py | 7 ++----- 9 files changed, 44 insertions(+), 48 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6dcb4ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +import os +import shutil +import sysconfig + + +def get_installed_command(name): + """Return the installed console script path for the active interpreter.""" + 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 + + raise FileNotFoundError(f"Could not find installed console script: {name}") diff --git a/tests/test_lss.py b/tests/test_lss.py index 9737be2..6c679ab 100644 --- a/tests/test_lss.py +++ b/tests/test_lss.py @@ -38,12 +38,9 @@ import tempfile import pytest -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +from conftest import get_installed_command -if os.name == "nt": - lss_bin = os.path.join(project_root, "bin", "lss.bat") -else: - lss_bin = os.path.join(project_root, "bin", "lss") +lss_bin = get_installed_command("lss") @pytest.fixture diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index 80ce084..b82dd28 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -42,6 +42,7 @@ import time 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 seq as pyseq @@ -88,7 +89,7 @@ def test_path_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "path", "some value") - self.assertEqual(str(cm.exception), "can't set attribute") + self.assertIn("can't set attribute", str(cm.exception)) def test_name_attribute_is_working_properly(self): """testing if the name attribute is working properly""" @@ -101,7 +102,7 @@ def test_name_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "name", "some value") - self.assertEqual(str(cm.exception), "can't set attribute") + self.assertIn("can't set attribute", str(cm.exception)) def test_dirname_attribute_is_working_properly(self): """testing if the dirname attribute is working properly""" @@ -115,7 +116,7 @@ def test_dirname_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "dirname", "some value") - self.assertEqual(str(cm.exception), "can't set attribute") + self.assertIn("can't set attribute", str(cm.exception)) def test_digits_attribute_is_working_properly(self): """testing if the digits attribute is working properly""" @@ -128,7 +129,7 @@ def test_digits_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "digits", "some value") - self.assertEqual(str(cm.exception), "can't set attribute") + self.assertIn("can't set attribute", str(cm.exception)) def test_parts_attribute_is_working_properly(self): """testing if the parts attribute is working properly""" @@ -141,7 +142,7 @@ def test_parts_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "parts", "some value") - self.assertEqual(str(cm.exception), "can't set attribute") + self.assertIn("can't set attribute", str(cm.exception)) def test_is_sibling_method_is_working_properly(self): """testing if the is_sibling() is working properly""" @@ -569,10 +570,7 @@ def run_command(self, *args): def setUp(self): """ """ self.maxDiff = None - self.here = os.path.dirname(__file__) - self.lss = os.path.realpath( - os.path.join(os.path.dirname(self.here), "lib", "pyseq", "lss.py") - ) + self.lss = get_installed_command("lss") def test_lss_is_working_properly_1(self): """testing if the lss command is working properly. Assumes strict pad diff --git a/tests/test_scopy.py b/tests/test_scopy.py index 8cbc099..740300c 100644 --- a/tests/test_scopy.py +++ b/tests/test_scopy.py @@ -39,14 +39,10 @@ import pytest import pyseq +from conftest import get_installed_command from pyseq.scopy import copy_sequence -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) - -if os.name == "nt": - scopy_bin = os.path.join(project_root, "bin", "scopy.bat") -else: - scopy_bin = os.path.join(project_root, "bin", "scopy") +scopy_bin = get_installed_command("scopy") @pytest.fixture diff --git a/tests/test_sdiff.py b/tests/test_sdiff.py index 2dee4dc..2f3bef2 100644 --- a/tests/test_sdiff.py +++ b/tests/test_sdiff.py @@ -40,14 +40,10 @@ import pytest import pyseq +from conftest import get_installed_command from pyseq.sdiff import diff_sequences -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) - -if os.name == "nt": - sdiff_bin = os.path.join(project_root, "bin", "sdiff.bat") -else: - sdiff_bin = os.path.join(project_root, "bin", "sdiff") +sdiff_bin = get_installed_command("sdiff") @pytest.fixture diff --git a/tests/test_sfind.py b/tests/test_sfind.py index 57ab3e9..3624617 100644 --- a/tests/test_sfind.py +++ b/tests/test_sfind.py @@ -38,12 +38,9 @@ import tempfile import pytest -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +from conftest import get_installed_command -if os.name == "nt": - sfind_bin = os.path.join(project_root, "bin", "sfind.bat") -else: - sfind_bin = os.path.join(project_root, "bin", "sfind") +sfind_bin = get_installed_command("sfind") @pytest.fixture diff --git a/tests/test_smove.py b/tests/test_smove.py index ffd181c..5936e58 100644 --- a/tests/test_smove.py +++ b/tests/test_smove.py @@ -39,14 +39,10 @@ import pytest import pyseq +from conftest import get_installed_command from pyseq.smove import move_sequence -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) - -if os.name == "nt": - smove_bin = os.path.join(project_root, "bin", "smove.bat") -else: - smove_bin = os.path.join(project_root, "bin", "smove") +smove_bin = get_installed_command("smove") @pytest.fixture diff --git a/tests/test_sstat.py b/tests/test_sstat.py index 5994ef6..0611a1f 100644 --- a/tests/test_sstat.py +++ b/tests/test_sstat.py @@ -40,14 +40,10 @@ import pytest import pyseq +from conftest import get_installed_command from pyseq.sstat import json_sstat -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) - -if os.name == "nt": - sstat_bin = os.path.join(project_root, "bin", "sstat.bat") -else: - sstat_bin = os.path.join(project_root, "bin", "sstat") +sstat_bin = get_installed_command("sstat") @pytest.fixture diff --git a/tests/test_stree.py b/tests/test_stree.py index f6e5f0d..3ac094d 100644 --- a/tests/test_stree.py +++ b/tests/test_stree.py @@ -38,12 +38,9 @@ import tempfile import pytest -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +from conftest import get_installed_command -if os.name == "nt": - stree_bin = os.path.join(project_root, "bin", "stree.bat") -else: - stree_bin = os.path.join(project_root, "bin", "stree") +stree_bin = get_installed_command("stree") @pytest.fixture From d21ac718055d8c77a5f45fd018ab66b54f7af057 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 17:45:44 -0700 Subject: [PATCH 07/14] Use wildcards for CLI wrapper in dist file --- dist.json | 58 +++---------------------------------------------------- 1 file changed, 3 insertions(+), 55 deletions(-) diff --git a/dist.json b/dist.json index 0cc71e3..092c71b 100644 --- a/dist.json +++ b/dist.json @@ -1,61 +1,9 @@ { "author": "ryan@rsg.io", "targets": { - "lss": { - "source": "bin/lss", - "destination": "{DEPLOY_ROOT}/bin/lss" - }, - "lss.bat": { - "source": "bin/lss.bat", - "destination": "{DEPLOY_ROOT}/bin/lss.bat" - }, - "scopy": { - "source": "bin/scopy", - "destination": "{DEPLOY_ROOT}/bin/scopy" - }, - "scopy.bat": { - "source": "bin/scopy.bat", - "destination": "{DEPLOY_ROOT}/bin/scopy.bat" - }, - "sdiff": { - "source": "bin/sdiff", - "destination": "{DEPLOY_ROOT}/bin/sdiff" - }, - "sdiff.bat": { - "source": "bin/sdiff.bat", - "destination": "{DEPLOY_ROOT}/bin/sdiff.bat" - }, - "sfind": { - "source": "bin/sfind", - "destination": "{DEPLOY_ROOT}/bin/sfind" - }, - "sfind.bat": { - "source": "bin/sfind.bat", - "destination": "{DEPLOY_ROOT}/bin/sfind.bat" - }, - "smove": { - "source": "bin/smove", - "destination": "{DEPLOY_ROOT}/bin/smove" - }, - "smove.bat": { - "source": "bin/smove.bat", - "destination": "{DEPLOY_ROOT}/bin/smove.bat" - }, - "sstat": { - "source": "bin/sstat", - "destination": "{DEPLOY_ROOT}/bin/sstat" - }, - "sstat.bat": { - "source": "bin/sstat.bat", - "destination": "{DEPLOY_ROOT}/bin/sstat.bat" - }, - "stree": { - "source": "bin/stree", - "destination": "{DEPLOY_ROOT}/bin/stree" - }, - "stree.bat": { - "source": "bin/stree.bat", - "destination": "{DEPLOY_ROOT}/bin/stree.bat" + "bin": { + "source": "bin/*", + "destination": "{DEPLOY_ROOT}/bin/%1" }, "lib": { "source": "lib/pyseq", From 3ad0a82bb1177267b25334499b3980e4df6c6ddc Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 17:48:28 -0700 Subject: [PATCH 08/14] Relax read-only attribute tests for newer Python error messages --- tests/test_pyseq.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index b82dd28..86949b3 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -50,6 +50,11 @@ pyseq.default_format = "%h%r%t" +def assert_read_only_attribute_error(message): + valid_snippets = ("can't set attribute", "has no setter") + assert any(snippet in message for snippet in valid_snippets), message + + class ItemTestCase(unittest.TestCase): """tests the Item class""" @@ -89,7 +94,7 @@ def test_path_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "path", "some value") - self.assertIn("can't set attribute", str(cm.exception)) + assert_read_only_attribute_error(str(cm.exception)) def test_name_attribute_is_working_properly(self): """testing if the name attribute is working properly""" @@ -102,7 +107,7 @@ def test_name_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "name", "some value") - self.assertIn("can't set attribute", str(cm.exception)) + assert_read_only_attribute_error(str(cm.exception)) def test_dirname_attribute_is_working_properly(self): """testing if the dirname attribute is working properly""" @@ -116,7 +121,7 @@ def test_dirname_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "dirname", "some value") - self.assertIn("can't set attribute", str(cm.exception)) + assert_read_only_attribute_error(str(cm.exception)) def test_digits_attribute_is_working_properly(self): """testing if the digits attribute is working properly""" @@ -129,7 +134,7 @@ def test_digits_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "digits", "some value") - self.assertIn("can't set attribute", str(cm.exception)) + assert_read_only_attribute_error(str(cm.exception)) def test_parts_attribute_is_working_properly(self): """testing if the parts attribute is working properly""" @@ -142,7 +147,7 @@ def test_parts_attribute_is_read_only(self): with self.assertRaises(AttributeError) as cm: setattr(i, "parts", "some value") - self.assertIn("can't set attribute", str(cm.exception)) + assert_read_only_attribute_error(str(cm.exception)) def test_is_sibling_method_is_working_properly(self): """testing if the is_sibling() is working properly""" From c2a0fc5782f1a8d7c79dc3f7cdd8bb2f6afeac79 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 17:54:13 -0700 Subject: [PATCH 09/14] Handle KeyboardInterrupt cleanly across CLI commands --- lib/pyseq/lss.py | 2 ++ lib/pyseq/scopy.py | 7 +++++- lib/pyseq/sdiff.py | 2 ++ lib/pyseq/sfind.py | 22 ++++++++--------- lib/pyseq/smove.py | 7 +++++- lib/pyseq/sstat.py | 2 ++ lib/pyseq/stree.py | 2 ++ lib/pyseq/util.py | 15 +++++++++++ tests/test_cli_interrupts.py | 48 ++++++++++++++++++++++++++++++++++++ 9 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 tests/test_cli_interrupts.py diff --git a/lib/pyseq/lss.py b/lib/pyseq/lss.py index a126301..89a8333 100755 --- a/lib/pyseq/lss.py +++ b/lib/pyseq/lss.py @@ -41,6 +41,7 @@ from pyseq import __version__, get_sequences from pyseq import seq as pyseq +from pyseq.util import cli_catch_keyboard_interrupt from pyseq import walk @@ -110,6 +111,7 @@ def _recur_cb(option: Any, opt_str: str, value: Optional[str], parser: Any): setattr(parser.values, option.dest, value) +@cli_catch_keyboard_interrupt def main(): """Command-line interface.""" diff --git a/lib/pyseq/scopy.py b/lib/pyseq/scopy.py index 0f5dfd4..3dcbc84 100644 --- a/lib/pyseq/scopy.py +++ b/lib/pyseq/scopy.py @@ -41,7 +41,11 @@ from typing import Optional import pyseq -from pyseq.util import is_compressed_format_string, resolve_sequence +from pyseq.util import ( + cli_catch_keyboard_interrupt, + is_compressed_format_string, + resolve_sequence, +) def copy_sequence( @@ -91,6 +95,7 @@ def copy_sequence( shutil.copy2(src_path, dest_path) +@cli_catch_keyboard_interrupt def main(): """Main function to parse cli args and copy sequences.""" diff --git a/lib/pyseq/sdiff.py b/lib/pyseq/sdiff.py index fea95ba..db534bd 100644 --- a/lib/pyseq/sdiff.py +++ b/lib/pyseq/sdiff.py @@ -38,6 +38,7 @@ import sys import pyseq +from pyseq.util import cli_catch_keyboard_interrupt from pyseq.util import resolve_sequence @@ -113,6 +114,7 @@ def show(label: str, a: str, b: str): print(f"Disk usage mismatch:\n A: {a}\n B: {b}") +@cli_catch_keyboard_interrupt def main(): """Main function to parse arguments and display sequence diffs.""" diff --git a/lib/pyseq/sfind.py b/lib/pyseq/sfind.py index aef379c..655445c 100644 --- a/lib/pyseq/sfind.py +++ b/lib/pyseq/sfind.py @@ -39,6 +39,7 @@ import sys import pyseq +from pyseq.util import cli_catch_keyboard_interrupt def walk_and_collect_sequences( @@ -59,6 +60,7 @@ def walk_and_collect_sequences( yield os.path.join(dirpath, full_str) +@cli_catch_keyboard_interrupt def main(): """Main function to parse arguments and call the sequence finder.""" @@ -81,18 +83,14 @@ def main(): ) args = parser.parse_args() - try: - for path in args.paths: - 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 - ): - print(seq) - except KeyboardInterrupt: - print("\nstopping...") - sys.exit(1) + for path in args.paths: + 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 + ): + print(seq) return 0 diff --git a/lib/pyseq/smove.py b/lib/pyseq/smove.py index 8f6e370..19f62a9 100644 --- a/lib/pyseq/smove.py +++ b/lib/pyseq/smove.py @@ -41,7 +41,11 @@ from typing import Optional import pyseq -from pyseq.util import is_compressed_format_string, resolve_sequence +from pyseq.util import ( + cli_catch_keyboard_interrupt, + is_compressed_format_string, + resolve_sequence, +) def move_sequence( @@ -91,6 +95,7 @@ def move_sequence( shutil.move(src_path, dest_path) +@cli_catch_keyboard_interrupt def main(): """Main function to handle command line arguments and call the move_sequence.""" diff --git a/lib/pyseq/sstat.py b/lib/pyseq/sstat.py index b40575a..fa11650 100644 --- a/lib/pyseq/sstat.py +++ b/lib/pyseq/sstat.py @@ -40,6 +40,7 @@ import sys import pyseq +from pyseq.util import cli_catch_keyboard_interrupt from pyseq.util import resolve_sequence @@ -125,6 +126,7 @@ def json_sstat(seq: pyseq.Sequence): } +@cli_catch_keyboard_interrupt def main(): """Main function to parse arguments and display sequence statistics.""" diff --git a/lib/pyseq/stree.py b/lib/pyseq/stree.py index d486af2..c6398d3 100644 --- a/lib/pyseq/stree.py +++ b/lib/pyseq/stree.py @@ -39,6 +39,7 @@ import pyseq from pyseq import config +from pyseq.util import cli_catch_keyboard_interrupt def print_tree( @@ -78,6 +79,7 @@ def print_tree( print_tree(os.path.join(root, name), next_prefix, fmt, include_hidden) +@cli_catch_keyboard_interrupt def main(): """Main function to parse cli args and print the directory tree.""" diff --git a/lib/pyseq/util.py b/lib/pyseq/util.py index 1647f6d..94a1c88 100644 --- a/lib/pyseq/util.py +++ b/lib/pyseq/util.py @@ -33,6 +33,7 @@ import glob import os import re +import sys import warnings import pyseq @@ -55,6 +56,20 @@ 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. diff --git a/tests/test_cli_interrupts.py b/tests/test_cli_interrupts.py new file mode 100644 index 0000000..e203400 --- /dev/null +++ b/tests/test_cli_interrupts.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python + +import os + +import pytest + +from pyseq import lss, scopy, sdiff, sfind, smove, sstat, stree + + +def _raise_keyboard_interrupt(*args, **kwargs): + raise KeyboardInterrupt() + + +@pytest.mark.parametrize( + ("module", "argv", "patch_target"), + [ + (lss, ["lss", "."], "get_sequences"), + (sfind, ["sfind", "."], "walk_and_collect_sequences"), + (stree, ["stree"], "print_tree"), + (sdiff, ["sdiff", "a.%04d.exr", "b.%04d.exr"], "resolve_sequence"), + (sstat, ["sstat", "a.%04d.exr"], "resolve_sequence"), + ], +) +def test_cli_main_handles_keyboard_interrupt( + monkeypatch, capsys, tmp_path, module, argv, patch_target +): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(module, patch_target, _raise_keyboard_interrupt) + monkeypatch.setattr("sys.argv", argv) + + assert module.main() == 1 + captured = capsys.readouterr() + assert "stopping..." in captured.err + + +@pytest.mark.parametrize(("module", "command"), [(scopy, "scopy"), (smove, "smove")]) +def test_copy_move_cli_main_handles_keyboard_interrupt( + monkeypatch, capsys, tmp_path, module, command +): + dest_dir = tmp_path / "dest" + dest_dir.mkdir() + + monkeypatch.setattr(module, "resolve_sequence", _raise_keyboard_interrupt) + monkeypatch.setattr("sys.argv", [command, "a.%04d.exr", str(dest_dir)]) + + assert module.main() == 1 + captured = capsys.readouterr() + assert "stopping..." in captured.err From 56fe207d317ee3a8b6b08bc035c1729e4dc79f8f Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 17:54:27 -0700 Subject: [PATCH 10/14] Add macOS and Windows coverage to the test matrix --- .github/workflows/tests.yml | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bf45f70..6567195 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,17 +9,31 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: - - "3.8" - - "3.9" - - "3.10" - - "3.11" - - "3.12" - - "3.13" + include: + - os: ubuntu-latest + python-version: "3.8" + - os: ubuntu-latest + python-version: "3.9" + - os: ubuntu-latest + python-version: "3.10" + - os: ubuntu-latest + python-version: "3.11" + - os: ubuntu-latest + python-version: "3.12" + - os: ubuntu-latest + python-version: "3.13" + - os: macos-latest + python-version: "3.11" + - os: macos-latest + python-version: "3.13" + - os: windows-latest + python-version: "3.11" + - os: windows-latest + python-version: "3.13" steps: - name: Check out repository @@ -36,4 +50,4 @@ jobs: python -m pip install -e ".[dev]" - name: Run test suite - run: pytest + run: python -m pytest tests/ -q From 9333e1a6dffd9060556bd847cae1d159bda878af Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 18:00:13 -0700 Subject: [PATCH 11/14] Fix Windows path, stat, and console output compatibility --- lib/pyseq/sstat.py | 5 ++++- lib/pyseq/stree.py | 24 ++++++++++++++++++++++-- tests/test_pyseq.py | 12 +++++++++--- tests/test_stree.py | 2 +- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/lib/pyseq/sstat.py b/lib/pyseq/sstat.py index fa11650..f25540b 100644 --- a/lib/pyseq/sstat.py +++ b/lib/pyseq/sstat.py @@ -65,6 +65,9 @@ def print_sstat(seq: pyseq.Sequence): def stat_path(frame): return os.stat(os.path.join(seq.format("%D"), frame.name)) + def blocks_for(stat_result): + return getattr(stat_result, "st_blocks", 0) + try: st_first = stat_path(seq[0]) st_last = stat_path(seq[-1]) @@ -84,7 +87,7 @@ def format_time_range(t1, t2): print(f"Head: {seq.head()}") print(f"Tail: {seq.tail()}") print(f"Range: {seq.format('%r')}") - print(f"Blocks: {st_first.st_blocks + st_last.st_blocks}") + print(f"Blocks: {blocks_for(st_first) + blocks_for(st_last)}") print(f"Access: {format_time_range(st_first.st_atime, st_last.st_atime)}") print(f"Modify: {format_time_range(st_first.st_mtime, st_last.st_mtime)}") print(f"Change: {format_time_range(st_first.st_ctime, st_last.st_ctime)}") diff --git a/lib/pyseq/stree.py b/lib/pyseq/stree.py index c6398d3..da81506 100644 --- a/lib/pyseq/stree.py +++ b/lib/pyseq/stree.py @@ -42,6 +42,24 @@ from pyseq.util import cli_catch_keyboard_interrupt +def get_tree_tokens(): + """Return unicode tree glyphs when stdout supports them, otherwise ASCII.""" + encoding = (getattr(sys.stdout, "encoding", None) or "").lower() + if encoding.startswith("utf"): + return { + "tee": "├── ", + "last": "└── ", + "pipe": "│ ", + "space": " ", + } + return { + "tee": "|-- ", + "last": "`-- ", + "pipe": "| ", + "space": " ", + } + + def print_tree( root: str, prefix: str = "", @@ -63,6 +81,8 @@ def print_tree( if not include_hidden: entries = [e for e in entries if not e.startswith(".")] + tree = get_tree_tokens() + files = [e for e in entries if os.path.isfile(os.path.join(root, e))] dirs = [e for e in entries if os.path.isdir(os.path.join(root, e))] @@ -71,8 +91,8 @@ def print_tree( for i, name in enumerate(dirs + [str(s.format(fmt)) for s in seqs]): is_last = i == total - 1 - connector = "└── " if is_last else "├── " - next_prefix = prefix + (" " if is_last else "│ ") + connector = tree["last"] if is_last else tree["tee"] + next_prefix = prefix + (tree["space"] if is_last else tree["pipe"]) print(f"{prefix}{connector}{name}") if name in dirs: diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index 86949b3..6d08dde 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -945,9 +945,15 @@ def test_issue_86(self): sequence = pyseq.uncompress(sequence_path, fmt="%h%R%t") self.assertEqual(str(sequence), "image (1-4).png") self.assertEqual(len(sequence), 3) - self.assertEqual(sequence[0].path, "path/to/file/image (1).png") - self.assertEqual(sequence[1].path, "path/to/file/image (2).png") - self.assertEqual(sequence[2].path, "path/to/file/image (4).png") + self.assertEqual( + sequence[0].path, os.path.join("path", "to", "file", "image (1).png") + ) + self.assertEqual( + sequence[1].path, os.path.join("path", "to", "file", "image (2).png") + ) + self.assertEqual( + sequence[2].path, os.path.join("path", "to", "file", "image (4).png") + ) # test sequence with multiple spaces sequence_path = "other/path/file with spaces [10-40].png" diff --git a/tests/test_stree.py b/tests/test_stree.py index 3ac094d..791782d 100644 --- a/tests/test_stree.py +++ b/tests/test_stree.py @@ -77,7 +77,7 @@ def test_stree_output(tree_fixture): assert "foo.1-3.exr" in out assert "bar.1-2.exr" in out assert "subdir" in out - assert "├──" in out or "└──" in out # tree chars + assert any(token in out for token in ("├──", "└──", "|--", "`--")) def test_stree_default_path(tree_fixture): From 1db2bb89e1903d7aaffed29436df16e67e7dbc09 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 18:02:51 -0700 Subject: [PATCH 12/14] Normalize issue 86 path assertions across platforms --- tests/test_pyseq.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_pyseq.py b/tests/test_pyseq.py index 6d08dde..b164b9a 100644 --- a/tests/test_pyseq.py +++ b/tests/test_pyseq.py @@ -946,13 +946,16 @@ def test_issue_86(self): self.assertEqual(str(sequence), "image (1-4).png") self.assertEqual(len(sequence), 3) self.assertEqual( - sequence[0].path, os.path.join("path", "to", "file", "image (1).png") + os.path.normpath(sequence[0].path), + os.path.normpath(os.path.join("path", "to", "file", "image (1).png")), ) self.assertEqual( - sequence[1].path, os.path.join("path", "to", "file", "image (2).png") + os.path.normpath(sequence[1].path), + os.path.normpath(os.path.join("path", "to", "file", "image (2).png")), ) self.assertEqual( - sequence[2].path, os.path.join("path", "to", "file", "image (4).png") + os.path.normpath(sequence[2].path), + os.path.normpath(os.path.join("path", "to", "file", "image (4).png")), ) # test sequence with multiple spaces From fb6a3f140e63d6cc698d3aea846c8e128dbb9cc5 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 18:08:45 -0700 Subject: [PATCH 13/14] Add 0.9.1 changelog entry --- CHANGELOG.md | 10 ++++++++++ README.md | 4 ++-- pyproject.toml | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9930f63..025d24a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ CHANGELOG ========= +## 0.9.1 + +* Removes the envstack runtime dependency and migrates packaging metadata to `pyproject.toml` +* Adds GitHub Actions test coverage across Python 3.8+ and multiple operating systems +* Tests installed console script entry points instead of relying on repo wrapper scripts +* Resolves issue #88 by handling `KeyboardInterrupt` cleanly across CLI commands +* Resolves issue #89 by fixing `Sequence.contains()` false positives with unrelated numbers +* Improves Windows compatibility in tests and CLI output handling +* Miscellaneous test and documentation cleanup + ## 0.9.0 * Adds initial versions of pyseq aware cli tools diff --git a/README.md b/README.md index d7c7070..2bb71b3 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ $ pip install -U pyseq #### Environment PySeq reads configuration from standard environment variables. The repository -includes a `pyseq.env` example [envstack](https://github.com/rsgalloway/envstack) -file you can source in a shell or adapt for your own environment: +includes a `pyseq.env` example file you can source in a shell or adapt for your +own environment: #### Distribution diff --git a/pyproject.toml b/pyproject.toml index 37bd53f..558e0a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyseq" dynamic = ["version"] description = "Compressed File Sequence String Module" readme = "README.md" -requires-python = ">=3.6" +requires-python = ">=3.8" license = { text = "BSD-3-Clause" } authors = [ { name = "Ryan Galloway", email = "ryan@rsgalloway.com" }, From 4426d68d6bbedbac6a9f3e3c29fc89ccaa5b016e Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 4 Apr 2026 18:14:49 -0700 Subject: [PATCH 14/14] Restore pyseq.env as an envstack file --- README.md | 12 +++++++-- pyseq.env | 50 +++++++++++++++++++----------------- tests/test_cli_interrupts.py | 2 -- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2bb71b3..9209b9a 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ $ pip install -U pyseq #### Environment PySeq reads configuration from standard environment variables. The repository -includes a `pyseq.env` example file you can source in a shell or adapt for your -own environment: +includes a `pyseq.env` example [envstack](https://github.com/rsgalloway/envstack) +file for users who want to manage those variables externally. #### Distribution @@ -248,6 +248,14 @@ with an _, you might use: $ export PYSEQ_FRAME_PATTERN="_\d+" ``` +Environment vars can be defined anywhere in your environment, or if using +`envstack`, add them to `pyseq.env` and make sure that file is found in +`${ENVPATH}`: + +```bash +$ export ENVPATH=/path/to/env/files +``` + Examples of regex patterns can be found in the `pyseq.env` file: ```yaml diff --git a/pyseq.env b/pyseq.env index 183ad0f..6a592ae 100755 --- a/pyseq.env +++ b/pyseq.env @@ -1,31 +1,35 @@ -# Source this file with: -# set -a -# . ./pyseq.env -# set +a +#!/usr/bin/env envstack +include: [default] +all: &all + # matches all numbers, the most flexible + PYSEQ_FRAME_PATTERN: ${PYSEQ_FRAME_PATTERN:=\d+} -# matches all numbers, the most flexible -export PYSEQ_FRAME_PATTERN="${PYSEQ_FRAME_PATTERN:-\\d+}" + # excludes version numbers, e.g. file_v001.1001.exr + # PYSEQ_FRAME_PATTERN: ([^v\d])\d+ -# excludes version numbers, e.g. file_v001.1001.exr -# export PYSEQ_FRAME_PATTERN="([^v\\d])\\d+" + # frame numbers are dot-delimited, e.g. file.v1.1001.exr + # PYSEQ_FRAME_PATTERN: \.\d+\. -# frame numbers are dot-delimited, e.g. file.v1.1001.exr -# export PYSEQ_FRAME_PATTERN="\\.\\d+\\." + # frame numbers start with an underscore, e.g. file_v1_1001.exr + # PYSEQ_FRAME_PATTERN: _\d+ -# frame numbers start with an underscore, e.g. file_v1_1001.exr -# export PYSEQ_FRAME_PATTERN="_\\d+" + # sequence string format: 4 file01_%04d.exr [40-43] (default) + PYSEQ_GLOBAL_FORMAT: "%4l %h%p%t %R" -# sequence string format: 4 file01_%04d.exr [40-43] (default) -export PYSEQ_GLOBAL_FORMAT="${PYSEQ_GLOBAL_FORMAT:-%4l %h%p%t %R}" + # sequence string format: file01_%04d.exr + # PYSEQ_GLOBAL_FORMAT: "%h%p%t" -# sequence string format: file01_%04d.exr -# export PYSEQ_GLOBAL_FORMAT="%h%p%t" + # sequence string format: file01_40-43.exr + # PYSEQ_GLOBAL_FORMAT: "%h%r%t" -# sequence string format: file01_40-43.exr -# export PYSEQ_GLOBAL_FORMAT="%h%r%t" + # use strict padding on sequences (pad length must match) + PYSEQ_STRICT_PAD: ${PYSEQ_STRICT_PAD:=0} -# use strict padding on sequences (pad length must match) -export PYSEQ_STRICT_PAD="${PYSEQ_STRICT_PAD:-0}" - -# character to join explicit frame ranges on -export PYSEQ_RANGE_SEP="${PYSEQ_RANGE_SEP:-, }" + # character to join explicit frame ranges on + PYSEQ_RANGE_SEP: ", " +darwin: + <<: *all +linux: + <<: *all +windows: + <<: *all diff --git a/tests/test_cli_interrupts.py b/tests/test_cli_interrupts.py index e203400..a4ef2e4 100644 --- a/tests/test_cli_interrupts.py +++ b/tests/test_cli_interrupts.py @@ -1,7 +1,5 @@ #!/usr/bin/env python -import os - import pytest from pyseq import lss, scopy, sdiff, sfind, smove, sstat, stree