pax_global_header00006660000000000000000000000064151656167550014533gustar00rootroot0000000000000052 comment=87e078c7d25ec835b57b864b257f1a6316669880 pyseq-0.9.2/000077500000000000000000000000001516561675500127045ustar00rootroot00000000000000pyseq-0.9.2/.github/000077500000000000000000000000001516561675500142445ustar00rootroot00000000000000pyseq-0.9.2/.github/workflows/000077500000000000000000000000001516561675500163015ustar00rootroot00000000000000pyseq-0.9.2/.github/workflows/tests.yml000066400000000000000000000024401516561675500201660ustar00rootroot00000000000000name: tests on: push: branches: - main pull_request: workflow_dispatch: jobs: test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: 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 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: python -m pytest tests/ -q pyseq-0.9.2/.gitignore000066400000000000000000000001611516561675500146720ustar00rootroot00000000000000*.pyc *.swp *~ .DS_Store dist build MANIFEST __pycache__ tmp/ .venv*/ venv*/ .vscode/ pyseq.egg-info/ default.envpyseq-0.9.2/AUTHORS000066400000000000000000000004001516561675500137460ustar00rootroot00000000000000 Developers: ----------- Ryan Galloway Contributors: ------------- Erkan Ozgur Yilmaz Tuan-huy Truong Brogan Ross Johannes Hezer pyseq-0.9.2/CHANGELOG.md000066400000000000000000000130101516561675500145100ustar00rootroot00000000000000CHANGELOG ========= ## 0.9.2 * Renames the move CLI from `smove` to `smv` * Makes `smv` behave more like sequence-aware `mv`, including destination-based renames * Adds explicit range support to `smv` and `scopy` for both compressed and embedded sequence syntax * Adds an initial `srm` CLI for removing resolved sequence members * Removes the legacy `--rename` flag from `smv` and `scopy` in favor of destination-based naming * Updates tests and README examples for the new CLI behavior ## 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 * Update %h directive in uncompress to support spaces * Allows piping to lss * Adds function type hints ## 0.8.4 * Allows setting the frame regex pattern as an env var * Adds config module with default settings * Resolves issue #83 ## 0.8.3 * Resolves issue #79 (padded sequence changes to unpadded) ## 0.8.1 * Adds dist and env files ## 0.8.0 * Refactors module into package * Adds console_scripts entry point in setup (fixes lss on windows) * Adds util module * Resolves Issue #73 ## 0.7.0 * Performance improvements * Deprecates support for Python 2 * Adds %H human readable size directive * Removes debug logging ## 0.6.1 * Addresses issue #69 (strict padding) * Disables pad strictness by default ## 0.6.0 * Fixes issue #67 (hangs on many missing frames) ## 0.5.5 * Removes deprecation warnings ## 0.5.4 * Fixes setup issues for python 2.7 ## 0.5.3 * Fixes bug where changes in frame size (e.g. 9 to 10) cause incorrect pad * Disables strict padding in lss by default (adds --strict option) ## 0.5.1 * Adds %M (missing frames) and %D (parent dir) directives * lss to only use colors when stdout is tty * Fixes issue #37 (walk directory path fix) * Fixes issue #41 (adds strict padding option) ## 0.5.0 * Major performance improvements * Bug fixes (size %d directive attr) ## 0.4.4 * Better support for python 3 strings * Bug fixes ## 0.4.3 * Fixes regex in ``uncompress`` (issue #19) * Adds brackets to %R directive formatting * Fixes lss to use cwd when no args/paths are given * Changes default range delimiter $PYSEQ_RANGE_SEP to a comma ## 0.4.2 * Adds recursive walk() function, and -r option to lss. * Adds frame range separator as env var. * Adds disk usage (%d) directive to formatting. * Adds insert, extend, and magic methods. * Fixes lss to support multiple arguments. ## 0.4.1 * Performance improvement by increasing Sequence._get_missing speed. * Adds size and mtime properties to Item and Sequence classes. * Deprecates getSequences(): use get_sequences() instead. * Deprecates Item.isSibling(): use Item.is_sibling() instead. * Additional PEP8 updates. ## 0.4.0 * Some PEP8 and Python 3+ updates. * Added *unittests* for the whole library (requires python 2.7+). * The ``format`` parameter is renamed to ``fmt`` in ``uncompress()`` and ``Sequence.format()`` to not to shadow the **Python built-in**. * Added a method called ``includes()`` to the ``Sequence`` class that does what the ``contains()`` method was doing in previous versions, that is, it now checks if the given Item could be contained in that particular Sequence. * Updated ``Sequence.contains()`` method behavior updated to better match its name, that is, it now checks if the Item is contained inside the boundaries of the Sequence. * The padding characters are now properly interpreted, as shown below seq = Sequence([ 'file.0001.jpg', 'file.0002.jpg', 'file.0003.jpg', 'file.0006.jpg' ]) print(seq.format('%h%04s-%04e%t')) will print 'file.0001-0006.jpg' print(seq.format('%h%4s-%4e%t')) will print 'file. 1- 6.jpg' ## 0.2.2 * Fixed %R in uncompress() * Fixed minor bug in getSequences() with glob ## 0.2.1b * supports sequences of any serializable, sortable items * fixes bug in lss ## 0.2.0b * Added support for wildcards in getSequence source input and in lss * Added format method to Sequence class for formatted string stdout * Sequence __str__ method now returns simplified compressed sequence string * Added SequenceError exception * Sequence qppend method raises SequenceError if file is non-sequence-member * Export diff function to get numeric differences between two sequential files * Alpha version of uncompress func for deserialization of compressed sequence strings. * Added additional attributes to Item class: path, frame, head, tail * Item name attribute is now base name, fixes bug where contains method didn't work on file paths. * Moved function 'main' to lss permanently. * Added --format and --debug options to lss * Ability to set log level with environment variable $PYSEQ_LOG_LEVEL * Simplified format directives, e.g. from %(head)s to %h, with support for padding, e.g. %04l. * Fixed duplicate sequence index number bug * Set logging level with PYSEQ_LOG_LEVEL environment variable. * Added 32 additional test cases. * Performance improvements. * Added html docs. ## 0.1.2 * ``getSequences`` now takes either a directory path or a Python list of files. * Added setup.py * Added lss script pyseq-0.9.2/LICENSE000066400000000000000000000027451516561675500137210ustar00rootroot00000000000000PySeq Copyright (c) 2010-2025, Ryan Galloway (http://rsg.io) All rights reserved. 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 author 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. pyseq-0.9.2/README.md000066400000000000000000000206541516561675500141720ustar00rootroot00000000000000PySeq ===== PySeq is a python module that finds groups of items that follow a naming convention containing a numerical sequence index (e.g. fileA.001.png, fileA.002.png, fileA.003.png...) and serializes them into a compressed sequence string representing the entire sequence (e.g. fileA.1-3.png). It should work regardless of where the numerical sequence index is embedded in the name. For examples, see basic usage below or http://rsgalloway.github.io/pyseq [Installation](#installation) | [Basic Usage](#basic-usage) | [API Examples](#api-examples) | [Formatting](#formatting) | [Command-Line Tools](#command-line-tools) | [Frame Patterns](#frame-patterns) | [Testing](#testing) ## Installation The easiest way to install pyseq: ```bash $ 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 for users who want to manage those variables externally. #### Distribution If installing from source you can use [distman](https://github.com/rsgalloway/distman) to install PySeq using the provided `dist.json` file: ```bash $ pip install -U distman $ distman [-d] ``` Using distman will deploy the targets defined in the `dist.json` file to the root folder defined by `${DEPLOY_ROOT}`: ## Basic Usage Using the "z1" file sequence example in the "tests" directory, we start by listing the directory contents using `ls`: ```bash $ ls tests/files/z1* tests/files/z1_001_v1.1.png tests/files/z1_001_v1.4.png tests/files/z1_002_v1.3.png tests/files/z1_002_v2.11.png tests/files/z1_001_v1.2.png tests/files/z1_002_v1.1.png tests/files/z1_002_v1.4.png tests/files/z1_002_v2.12.png tests/files/z1_001_v1.3.png tests/files/z1_002_v1.2.png tests/files/z1_002_v2.10.png tests/files/z1_002_v2.9.png ``` Now we list the same directory contents using `lss`, which will find the sequences and display them in the default compressed format: ```bash $ lss tests/files/z1* 4 z1_001_v1.%d.png [1-4] 4 z1_002_v1.%d.png [1-4] 4 z1_002_v2.%d.png [9-12] ``` Recursivly walk a folder and find all the sequences: ```bash $ lss -r tests tests ├── test_pyseq.py └── files ├── 012_vb_110_v001.1-10.png ├── 012_vb_110_v002.1-10.png ├── a.1-14.tga ├── alpha.txt ├── bnc01_TinkSO_tx_0_ty_0.101-105.tif ├── bnc01_TinkSO_tx_0_ty_1.101-105.tif ├── bnc01_TinkSO_tx_1_ty_0.101-105.tif ├── bnc01_TinkSO_tx_1_ty_1.101-105.tif ├── file.1-99.tif ├── file.info.03.rgb ├── file01.1-4.j2k ├── file01_40-43.rgb ├── file02_44-47.rgb ├── file1-4.03.rgb ├── fileA.1-3.jpg ├── fileA.1-3.png ├── file_02.tif ├── z1_001_v1.1-4.png ├── z1_002_v1.1-4.png └── z1_002_v2.9-12.png ``` Piping the output of `find` to `lss`, for example finding all the png sequences: ```bash $ find ./tests/ -name *.png | lss 10 012_vb_110_v001.%04d.png [1-10] 10 012_vb_110_v002.%04d.png [1-10] 3 fileA.%04d.png [1-3] 4 z1_001_v1.%d.png [1-4] 4 z1_002_v1.%d.png [1-4] 4 z1_002_v2.%d.png [9-12] ``` Use the `--format` option to retain the relative path: ```bash $ find tests/ -name "*.png" | lss -f "%D%h%r%t" tests/files/012_vb_110_v001.1-10.png tests/files/012_vb_110_v002.1-10.png tests/files/fileA.1-3.png tests/files/z1_001_v1.1-4.png tests/files/z1_002_v1.1-4.png tests/files/z1_002_v2.9-12.png ``` ## API Examples Compression, or serialization, of lists of items: ```python >>> s = Sequence(['file.0001.jpg', 'file.0002.jpg', 'file.0003.jpg']) >>> print(s) file.1-3.jpg >>> s.append('file.0006.jpg') >>> print(s.format("%h%p%t %R")) file.%04d.jpg [1-3, 6] ``` Uncompression, or deserialization, of compressed sequences strings: ```python >>> s = uncompress('./tests/012_vb_110_v001.%04d.png 1-1001', fmt='%h%p%t %r') >>> len(s) 1001 >>> print(s.format('%04l %h%p%t %R')) 1001 012_vb_110_v001.%04d.png [1-1001] ``` Walk a directory tree and print disk usage for file sequences: ```python >>> for root, dirs, seqs in pyseq.walk(folder): ... for seq in seqs: ... print(seq.format("%h%r%t %H")) 012_vb_110_v001.1000-1321.exr 123.5G 012_vb_110_v002.1000-1163.exr 40.2G 012_vb_110_v003.1000-1027.exr 72.2G ``` ## Formatting The following directives can be embedded in the format string. | Directive | Meaning | |-----------|--------------------------------------| | `%s` | sequence start | | `%e` | sequence end | | `%l` | sequence length | | `%f` | list of found files | | `%m` | list of missing files | | `%M` | explicit missing files [11-14,19-21] | | `%p` | padding, e.g. %06d | | `%r` | implied range, start-end | | `%R` | explicit broken range, [1-10, 15-20] | | `%d` | disk usage | | `%H` | disk usage (human readable) | | `%D` | parent directory | | `%h` | string preceding sequence number | | `%t` | string after the sequence number | Here are some examples using `lss -f ` and `seq.format(..)`: Using `lss -f `: ```bash $ lss tests/files/a*.tga -f "%h%r%t" a.1-14.tga $ lss tests/files/a*.tga -f "%l %h%r%t" 7 a.1-14.tga $ lss tests/files/a*.tga -f "%l %h%r%t %M" 7 a.1-14.tga [4-9, 11] ``` In Python, using `seq.format(..)`: ```python >>> s = pyseq.get_sequences("tests/files/a*.tga")[0] >>> print(s.format("%h%r%t")) a.1-14.tga >>> print(s.format("%l %h%r%t")) 7 a.1-14.tga >>> print(s.format("%l %h%r%t %M")) 7 a.1-14.tga [4-9, 11] ``` ## Command-Line Tools PySeq comes with the following sequence-aware command-line tools: | Command | Description | Example Usage | | ------- | ------------------------------------- | -------------------------------- | | `lss` | List image sequences in a directory | `lss shots/` | | `stree` | Display sequence-aware directory tree | `stree shots/` | | `sfind` | Recursively find image sequences | `sfind assets/ -name "*.exr"` | | `sdiff` | Compare two sequences | `sdiff A.%04d.exr B.%04d.exr` | | `sstat` | Print detailed stats about a sequence | `sstat render.%04d.exr` | | `scopy` | Copy a sequence to another directory | `scopy a.%04d.exr /tmp/output/` | | `srm` | Remove a sequence or frame range | `srm a.1001-1100.exr` | | `smv` | Move or rename a sequence | `smv b.%04d.exr /tmp/archive/` | Example commands: ```bash # List sequences in a folder $ lss tests/files # Show directory structure with grouped sequences $ stree tests/ # Find all .png sequences recursively $ sfind ./tests -name "*.png" # Compare two sequences and print diffs $ sdiff comp_A.%04d.exr comp_B.%04d.exr # Show stats for a sequence $ sstat render.%04d.exr $ sstat --json render.%04d.exr # Copy a sequence into a directory $ scopy input.%04d.exr output/ # Copy an embedded frame range into a new sequence $ scopy input.1-100.exr scene.1001-1100.exr # Remove an embedded frame range $ srm input.1-100.exr # Rename a sequence in place $ smv old.%04d.exr new.%04d.exr # Move an embedded frame range into a new sequence $ smv old.1-100.rgb new.1001-1100.rgb # Move and renumber a sequence starting at frame 1001 $ smv old.%04d.exr archive/ --renumber 1001 ``` ## Frame Patterns The environment var `${PYSEQ_FRAME_PATTERN}` can be used to define custom regex patterns for identifying frame numbers. For example if frames are always preceded with an _, you might use: ```bash $ 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 # matches all numbers, the most flexible PYSEQ_FRAME_PATTERN: \d+ # excludes version numbers, e.g. file_v001.1001.exr PYSEQ_FRAME_PATTERN: ([^v\d])\d+ # frame numbers are dot-delimited, e.g. file.v1.1001.exr PYSEQ_FRAME_PATTERN: \.\d+\. # frame numbers start with an underscore, e.g. file_v1_1001.exr PYSEQ_FRAME_PATTERN: _\d+ ``` ## Testing To run the unit tests, simply run `pytest` in a shell: ```bash $ pytest tests -q ``` pyseq-0.9.2/bin/000077500000000000000000000000001516561675500134545ustar00rootroot00000000000000pyseq-0.9.2/bin/lss000077500000000000000000000035401516561675500142050ustar00rootroot00000000000000#!/usr/bin/env python3 # # 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. # ----------------------------------------------------------------------------- __doc__ = """ Contains a simple wrapper for the lss executable. """ import re import sys from pyseq.lss import main if __name__ == "__main__": sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) sys.exit(main()) pyseq-0.9.2/bin/lss.bat000077500000000000000000000000361516561675500147470ustar00rootroot00000000000000@echo off python %~dp0\lss %* pyseq-0.9.2/bin/scopy000077500000000000000000000035441516561675500145450ustar00rootroot00000000000000#!/usr/bin/env python3 # # 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. # ----------------------------------------------------------------------------- __doc__ = """ Contains a simple wrapper for the scopy executable. """ import re import sys from pyseq.scopy import main if __name__ == "__main__": sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) sys.exit(main()) pyseq-0.9.2/bin/scopy.bat000077500000000000000000000000401516561675500152760ustar00rootroot00000000000000@echo off python %~dp0\scopy %* pyseq-0.9.2/bin/sdiff000077500000000000000000000035441516561675500145030ustar00rootroot00000000000000#!/usr/bin/env python3 # # 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. # ----------------------------------------------------------------------------- __doc__ = """ Contains a simple wrapper for the sdiff executable. """ import re import sys from pyseq.sdiff import main if __name__ == "__main__": sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) sys.exit(main()) pyseq-0.9.2/bin/sdiff.bat000077500000000000000000000000401516561675500152340ustar00rootroot00000000000000@echo off python %~dp0\sdiff %* pyseq-0.9.2/bin/sfind000077500000000000000000000035441516561675500145130ustar00rootroot00000000000000#!/usr/bin/env python3 # # 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. # ----------------------------------------------------------------------------- __doc__ = """ Contains a simple wrapper for the sfind executable. """ import re import sys from pyseq.sfind import main if __name__ == "__main__": sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) sys.exit(main()) pyseq-0.9.2/bin/sfind.bat000077500000000000000000000000401516561675500152440ustar00rootroot00000000000000@echo off python %~dp0\sfind %* pyseq-0.9.2/bin/smv000077500000000000000000000035441516561675500142150ustar00rootroot00000000000000#!/usr/bin/env python3 # # 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. # ----------------------------------------------------------------------------- __doc__ = """ Contains a simple wrapper for the smove executable. """ import re import sys from pyseq.smove import main if __name__ == "__main__": sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) sys.exit(main()) pyseq-0.9.2/bin/smv.bat000077500000000000000000000000401516561675500147460ustar00rootroot00000000000000@echo off python %~dp0\smove %* pyseq-0.9.2/bin/sstat000077500000000000000000000035441516561675500145460ustar00rootroot00000000000000#!/usr/bin/env python3 # # 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. # ----------------------------------------------------------------------------- __doc__ = """ Contains a simple wrapper for the sstat executable. """ import re import sys from pyseq.sstat import main if __name__ == "__main__": sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) sys.exit(main()) pyseq-0.9.2/bin/sstat.bat000077500000000000000000000000401516561675500152770ustar00rootroot00000000000000@echo off python %~dp0\sstat %* pyseq-0.9.2/bin/stree000077500000000000000000000035441516561675500145320ustar00rootroot00000000000000#!/usr/bin/env python3 # # 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. # ----------------------------------------------------------------------------- __doc__ = """ Contains a simple wrapper for the stree executable. """ import re import sys from pyseq.stree import main if __name__ == "__main__": sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) sys.exit(main()) pyseq-0.9.2/bin/stree.bat000077500000000000000000000000401516561675500152630ustar00rootroot00000000000000@echo off python %~dp0\stree %* pyseq-0.9.2/dist.json000066400000000000000000000006261516561675500145460ustar00rootroot00000000000000{ "author": "ryan@rsg.io", "targets": { "bin": { "source": "bin/*", "destination": "{DEPLOY_ROOT}/bin/%1" }, "lib": { "source": "lib/pyseq", "destination": "{DEPLOY_ROOT}/lib/python/pyseq" }, "env": { "source": "pyseq.env", "destination": "{DEPLOY_ROOT}/env/pyseq.env" } } }pyseq-0.9.2/lib/000077500000000000000000000000001516561675500134525ustar00rootroot00000000000000pyseq-0.9.2/lib/pyseq/000077500000000000000000000000001516561675500146135ustar00rootroot00000000000000pyseq-0.9.2/lib/pyseq/__init__.py000066400000000000000000000042531516561675500167300ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """PySeq is a python module that finds groups of items that follow a naming convention containing a numerical sequence index, e.g. :: fileA.001.png, fileA.002.png, fileA.003.png... and serializes them into a compressed sequence string representing the entire sequence, e.g. :: fileA.1-3.png It should work regardless of where the numerical sequence index is embedded in the name. Docs and latest version available for download at http://github.com/rsgalloway/pyseq """ __author__ = "Ryan Galloway" __version__ = "0.9.2" from .seq import * pyseq-0.9.2/lib/pyseq/config.py000066400000000000000000000064141516561675500164370ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains pyseq configs and default settings. """ import os import re # default serialization format string DEFAULT_FORMAT = "%h%r%t" default_format = os.getenv("PYSEQ_DEFAULT_FORMAT", DEFAULT_FORMAT) # default serialization format string for global sequences DEFAULT_GLOBAL_FORMAT = "%4l %h%p%t %R" global_format = os.getenv("PYSEQ_GLOBAL_FORMAT", DEFAULT_GLOBAL_FORMAT) # use strict padding on sequences (pad length must match) PYSEQ_STRICT_PAD = os.getenv("PYSEQ_STRICT_PAD", 0) 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+" PYSEQ_FRAME_PATTERN = os.getenv("PYSEQ_FRAME_PATTERN", DEFAULT_FRAME_PATTERN) def set_frame_pattern(pattern: str = DEFAULT_FRAME_PATTERN): """ Set the regex pattern for matching frame numbers. :param pattern: The regex pattern to use for matching frame numbers. """ global frames_re global PYSEQ_FRAME_PATTERN PYSEQ_FRAME_PATTERN = pattern try: frames_re = re.compile(pattern) except Exception as e: print("Error: Invalid regex pattern: %s" % e) frames_re = re.compile(DEFAULT_FRAME_PATTERN) # set the default frame pattern set_frame_pattern(PYSEQ_FRAME_PATTERN) # regex for matching format directives format_re = re.compile(r"%(?P\d+)?(?P\w+)") # character to join explicit frame ranges on DEFAULT_RANGE_SEP = ", " range_join = os.getenv("PYSEQ_RANGE_SEP", DEFAULT_RANGE_SEP) pyseq-0.9.2/lib/pyseq/lss.py000077500000000000000000000145621516561675500160010ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains the main lss functions for the pyseq module. """ import glob import optparse import os import sys from typing import Any, Optional from pyseq import __version__, get_sequences from pyseq import seq as pyseq from pyseq.util import cli_catch_keyboard_interrupt from pyseq import walk def tree(source: str, level: Optional[int], seq_format: str): """Recursively walk from the source and display all the folders and sequences.""" if sys.stdout.isatty(): blue = "\033[94m" endc = "\033[0m" else: blue = "" endc = "" ends = {} done = [] print("{0}{1}".format(blue, os.path.relpath(source))) for root, dirs, seqs in walk(source, level): if len(dirs) > 0: ends[root] = dirs[-1] else: ends[root] = None sp = "" if root != sorted(source): p = root while p != source: dir_name, base = os.path.split(p) if dir_name == source: break elif dir_name in done: sp = " " + sp elif ends[dir_name] != base: sp = "│ " + sp elif ends[dir_name] == base: sp = "│ " + sp else: sp = " " + sp p = dir_name base = os.path.basename(root) if root == source: pass elif ends[os.path.dirname(root)] == base: print("".join([sp, "└── ", "%s%s%s" % (blue, base, endc)])) done.append(root) ends[os.path.dirname(root)] = None sp += " " else: print("".join([sp, "├── ", "%s%s%s" % (blue, base, endc)])) sp += "│ " sequence_length = len(seqs) for i, seq in enumerate(seqs): if i == (sequence_length - 1) and len(dirs) == 0: print("".join([sp, "└── ", seq.format(seq_format)])) else: print("".join([sp, "├── ", seq.format(seq_format)])) print(endc) def _recur_cb(option: Any, opt_str: str, value: Optional[str], parser: Any): """Callback for the `recursive` argument.""" if value is None: value = -1 else: value = int(value) setattr(parser.values, option.dest, value) @cli_catch_keyboard_interrupt def main(): """Command-line interface.""" usage = ( """ lss [path] [-f format] [-d] [-r] Formatting options: You can format the output of lss using the --format option and passing in a format string. Default format string is "%s" Supported directives: %%s sequence start %%e sequence end %%l sequence length %%f list of found files %%m list of missing files %%p padding, e.g. %%06d %%r absolute range, start-end %%R expanded range, start-end [missing] %%d disk usage %%h string preceding sequence number %%t string after the sequence number Format directives support padding, for example: "%%04l". """ % pyseq.global_format ) parser = optparse.OptionParser(usage=usage, version="%prog " + __version__) parser.add_option( "-f", "--format", dest="format", default=None, help="Format the sequence string.", ) parser.add_option( "-r", "--recursive", dest="recursive", action="callback", callback=_recur_cb, help="Walks the entire directory structure.", ) parser.add_option( "-s", "--strict", dest="strict", action="store_true", default=pyseq.strict_pad, help="Strict padding (default false).", ) (options, args) = parser.parse_args() pyseq.strict_pad = options.strict # stdin is piped, read from stdin if no cli args provided if not args and not sys.stdin.isatty(): args = [line.strip() for line in sys.stdin if line.strip()] # if no args are given, use cwd elif len(args) == 0: args = [os.getcwd()] items = [] for path in args: if os.path.isdir(path): join = os.path.join items = [join(path, x) for x in os.listdir(path)] else: items.extend(glob.glob(path)) if options.recursive is None: for seq in get_sequences(items): print(seq.format(options.format or pyseq.global_format)) else: level = options.recursive for path in args: path = os.path.abspath(path.rstrip(os.sep)) if not os.path.isdir(path): continue tree(path, level, options.format or "%h%r%t") return 0 if __name__ == "__main__": sys.exit(main()) pyseq-0.9.2/lib/pyseq/scopy.py000066400000000000000000000136741516561675500163350ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains the main scopy functions for the pyseq module. """ import sys import os import argparse import shutil from typing import Optional import pyseq from pyseq.util import ( cli_catch_keyboard_interrupt, parse_destination_reference, resolve_sequence_reference, ) def copy_sequence( seq: pyseq.Sequence, src_dir: str, dest_dir: str, rename: Optional[str] = None, renumber: Optional[int] = None, pad: Optional[int] = None, force: bool = False, dryrun: bool = False, verbose: bool = False, ): """Copy a sequence of files from src_dir to dest_dir. :param seq: The sequence object to copy. :param src_dir: The source directory containing the files. :param dest_dir: The destination directory to copy files to. :param rename: Optional new basename for the copied files. :param renumber: Optional new starting frame number. :param pad: Optional number of digits for padding the frame numbers. :param force: If True, overwrite existing files. :param dryrun: If True, print the operations without executing them. :param verbose: If True, print detailed information about the operations. """ dest_basename = rename or seq.head() dest_pad = pad or seq.pad start_frame = renumber or seq.start() for i, frame in enumerate(seq): src_path = os.path.join(src_dir, frame.name) frame_num = start_frame + i dest_frame_name = f"{dest_basename}{frame_num:0{dest_pad}d}{seq.tail()}" dest_path = os.path.join(dest_dir, dest_frame_name) if verbose or dryrun: print(f"{src_path} -> {dest_path}") if not dryrun: os.makedirs(dest_dir, exist_ok=True) if os.path.exists(dest_path) and not force: print( f"File exists: {dest_path} (use --force to overwrite)", file=sys.stderr, ) continue shutil.copy2(src_path, dest_path) @cli_catch_keyboard_interrupt def main(): """Main function to parse cli args and copy sequences.""" parser = argparse.ArgumentParser( description="Copy image sequences with destination-based renaming and renumbering support", ) parser.add_argument( "paths", nargs="+", help="Source sequence(s) followed by a destination directory or sequence pattern", ) parser.add_argument( "--renumber", type=int, help="New starting frame", ) parser.add_argument( "--pad", type=int, help="Padding digits", ) parser.add_argument( "-f", "--force", action="store_true", help="Overwrite existing files", ) parser.add_argument( "-d", "--dryrun", action="store_true", help="Preview copy without performing it", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Verbose output", ) args = parser.parse_args() if len(args.paths) < 2: print("Error: expected at least one source and a destination", file=sys.stderr) return 1 sources = args.paths[:-1] dest = args.paths[-1] for source in sources: try: seq, dirname = resolve_sequence_reference(source) 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" ) 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"] ) copy_sequence( seq, dirname, dest_spec["dest_dir"], rename=rename, renumber=renumber, pad=pad, force=args.force, dryrun=args.dryrun, verbose=args.verbose, ) except Exception as e: print(f"Error processing {source}: {e}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main()) pyseq-0.9.2/lib/pyseq/sdiff.py000066400000000000000000000122141516561675500162600ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains the main sdiff functions for the pyseq module. """ import argparse import json import sys import pyseq from pyseq.util import cli_catch_keyboard_interrupt from pyseq.util import resolve_sequence def diff_sequences( seq1: pyseq.Sequence, seq2: pyseq.Sequence, compare_size: bool = False, ): """Compares two sequences and returns a dictionary of differences. :param seq1: The first sequence to compare. :param seq2: The second sequence to compare. :param compare_size: Boolean indicating whether to compare disk usage. :return: A dictionary containing differences between the two sequences. """ def intval(val): try: return int(val) except: return None diff = { "head": (seq1.head(), seq2.head()), "tail": (seq1.tail(), seq2.tail()), "pad": (seq1.pad, seq2.pad), "start": (seq1.start(), seq2.start()), "end": (seq1.end(), seq2.end()), "length": (seq1.length(), seq2.length()), "missing": { "a_only": sorted(set(seq1.missing()) - set(seq2.missing())), "b_only": sorted(set(seq2.missing()) - set(seq1.missing())), }, } if compare_size: disk_a = intval(seq1.format("%d")) disk_b = intval(seq2.format("%d")) diff["disk_bytes"] = [disk_a, disk_b] diff["disk_human"] = [seq1.format("%H"), seq2.format("%H")] return diff def print_diff(diff: dict, compare_size: bool = False): """Prints the differences between two sequences. :param diff: The dictionary containing differences between sequences. :param compare_size: Boolean indicating whether to compare disk usage. """ def show(label: str, a: str, b: str): if a != b: print(f"{label} mismatch:\n A: {a}\n B: {b}") show("Head", *diff["head"]) show("Tail", *diff["tail"]) show("Padding", *diff["pad"]) show("Start", *diff["start"]) show("End", *diff["end"]) show("Length", *diff["length"]) a_only = diff["missing"]["a_only"] b_only = diff["missing"]["b_only"] if a_only: print(f"Missing in A: {a_only}") if b_only: print(f"Missing in B: {b_only}") if compare_size and "disk_bytes" in diff: a, b = diff["disk_human"] if a != b: 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.""" parser = argparse.ArgumentParser( description="Compare two file sequences and report differences.", ) parser.add_argument( "seq1", help="First sequence (glob or %%d format)", ) parser.add_argument( "seq2", help="Second sequence", ) parser.add_argument( "--size", action="store_true", help="Compare disk usage", ) parser.add_argument( "--json", action="store_true", help="Output result as JSON", ) args = parser.parse_args() try: s1 = resolve_sequence(args.seq1) s2 = resolve_sequence(args.seq2) except Exception as e: print(f"sdiff: error resolving sequence: {e}", file=sys.stderr) return 1 diff = diff_sequences(s1, s2, compare_size=args.size) if args.json: print(json.dumps(diff, indent=4)) else: print(f"Sequence A: {str(s1)}") print(f"Sequence B: {str(s2)}\n") print_diff(diff, compare_size=args.size) return 0 if __name__ == "__main__": sys.exit(main()) pyseq-0.9.2/lib/pyseq/seq.py000077500000000000000000001166501516561675500157710ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains the main pyseq classes and functions. """ import functools import os import re import traceback import warnings from collections import deque from glob import glob, iglob from typing import List, Callable, Union from pyseq import config from pyseq.util import _ext_key from pyseq.config import ( default_format, format_re, global_format, range_join, strict_pad, ) class SequenceError(Exception): """Special exception for Sequence errors.""" pass class FormatError(Exception): """Special exception for Sequence format errors.""" pass def padsize(item, frame): """ Determines the pad size for a given Item. Return value may depend on whether strict padding is enabled or not. 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. """ # strict: frame size (%d) must match between frames (default) # for example: test.09.jpg, test.10.jpg, test.11.jpg if strict_pad: return item.pad or len(frame) # 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 class Item(str): """ Represents a file in a sequence. """ def __init__(self, item: Union[str, os.PathLike]): """ Initializes a new instance of the Item class. :param item: Path to the file. """ super(Item, self).__init__() self.item = item self.__path = getattr(item, "path", None) if self.__path is None: self.__path = str(item) self.__filename = os.path.basename(self.__path) self.__number_matches = [] self.__parts = config.frames_re.split(self.name) self.__stat = None # modified by self.is_sibling() self.frame = None self.head = self.name self.tail = "" self.pad = None def __eq__(self, other): """ Checks if this Item is equal to another Item. :param other: Another Item instance. :return: True if the Items are equal, False otherwise. """ return self.path == other.path def __ne__(self, other): """ Checks if this Item is not equal to another Item. :param other: Another Item instance. :return: True if the Items are not equal, False otherwise. """ return self.path != other.path def __lt__(self, other): """ Checks if this Item is less than another Item. :param other: Another Item instance. :return: True if this Item is less than the other Item, False otherwise. """ return self.frame < other.frame def __gt__(self, other): """ Checks if this Item is greater than another Item. :param other: Another Item instance. :return: True if this Item is greater than the other Item, False otherwise. """ return self.frame > other.frame def __ge__(self, other): """ Checks if this Item is greater than or equal to another Item. :param other: Another Item instance. :return: True if this Item is greater than or equal to the other Item, False otherwise. """ return self.frame >= other.frame def __le__(self, other): """ Checks if this Item is less than or equal to another Item. :param other: Another Item instance. :return: True if this Item is less than or equal to the other Item, False otherwise. """ return self.frame <= other.frame def __hash__(self): """ Returns the hash value of this Item. :return: The hash value. """ return hash(self.path) def __str__(self): """ Returns the string representation of this Item. :return: The string representation. """ return str(self.name) def __repr__(self): """ Returns the official string representation of this Item. :return: The official string representation. """ return '' % self.name def __getattr__(self, key): """ Retrieves the value of the specified attribute. :param key: The name of the attribute. :return: The value of the attribute. """ return getattr(self.item, key) @property def path(self): """ Gets the absolute path of the Item, if it is a filesystem item. :return: The absolute path. """ return self.__path @property def name(self): """ Gets the base name of the Item. :return: The base name. """ return self.__filename @property def dirname(self): """ Gets the directory name of the Item, if it is a filesystem item. :return: The directory name. """ return os.path.dirname(self.__path) @property def digits(self): """ Returns the numerical components of the Item as a list of strings. :return: The numerical components. """ return config.frames_re.findall(self.__filename) @property def number_matches(self): """ Returns the numerical components of the Item as a list of regex match objects. :return: The numerical components. """ if not self.__number_matches: self.__number_matches = list(config.digits_re.finditer(self.__filename)) return self.__number_matches @property def parts(self): """ Returns the non-numerical components of the Item. :return: The non-numerical components. """ return self.__parts @property def exists(self): """ Checks if this Item exists on disk. :return: True if the Item exists, False otherwise. """ return os.path.isfile(self.__path) @property def size(self): """ Returns the size of the Item, reported by os.stat. :return: The size of the Item. """ return self.stat.st_size @property def mtime(self): """ Returns the modification time of the Item. :return: The modification time. """ return self.stat.st_mtime @property @functools.lru_cache(maxsize=None) def stat(self): """ Returns the os.stat object for this file. :return: The os.stat object. """ if self.__stat is None: self.__stat = os.stat(self.__path) return self.__stat def is_sibling(self, item: str): """ Determines if this Item and another Item are part of the same sequence. :param item: Another Item instance. :return: True if this Item and the other Item are sequential siblings, False otherwise. """ if not isinstance(item, Item): item = Item(item) # diff these two items to determine siblinghood d = diff(self, item) is_sibling = (len(d) == 1) and (self.parts == item.parts) # if these items are in the same sequence, set some common attributes on both items if is_sibling: frame = d[0]["frames"][0] self.frame = int(frame) self.pad = padsize(item, frame) if self.pad is None else self.pad self.head = self.name[: d[0]["start"]] self.tail = self.name[d[0]["end"] :] # noqa frame = d[0]["frames"][1] item.frame = int(frame) item.pad = self.pad item.head = item.name[: d[0]["start"]] item.tail = item.name[d[0]["end"] :] # noqa return is_sibling class Sequence(list): """Extends list class with methods that handle item sequentialness. For example: >>> s = Sequence(['file.0001.jpg', 'file.0002.jpg', 'file.0003.jpg']) >>> print(s) file.1-3.jpg >>> s.append('file.0006.jpg') >>> print(s.format('%4l %h%p%t %R')) 4 file.%04d.jpg 1-3 6 >>> s.includes('file.0009.jpg') True >>> s.includes('file.0009.pic') False >>> s.contains('file.0006.jpg') False >>> print(s.format('%h%p%t %r (%R)')) file.%04d.jpg 1-6 (1-3 6) """ def __init__(self, items: List[str]): """ Create a new Sequence class object. :param items: Sequential list of items. :return: pyseq.Sequence class instance. """ # otherwise Sequence consumes the list items = deque(items[::]) super(Sequence, self).__init__([Item(items.popleft())]) self.__missing = [] self.__dirty = False self.__frames = None while items: f = Item(items.popleft()) try: self.append(f) except SequenceError: continue except KeyboardInterrupt: print("Stopping.") break def __attrs__(self): """Replaces format directives with callables to get their values.""" return { "l": self.length, "s": self.start, "e": self.end, "f": self.frames, "m": self.missing, "M": functools.partial(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), "h": self.head, "t": self.tail, } def __str__(self): return self.format(default_format) def __repr__(self): return '' % str(self) def __getattr__(self, key: str): """Get the value of the specified attribute. :param key: The name of the attribute. :return: The value of the attribute. """ return getattr(self[0], key) def __contains__(self, item: Union[Item, str]): """Checks if the item is in the sequence. :param item: Item or string to check. :return: True if item is in the sequence. """ return super(Sequence, self).__contains__(Item(item)) def __setitem__(self, index: Union[int, slice], item: Union[Item, str, List[str]]): """Used to set a particular element in the sequence. :param index: Index of the item to set. :param item: Item or list of items to set. :raises ValueError: If the step in the slice is not 1. """ if type(index) is slice: if index.step not in (1, None): raise ValueError("only step=1 supported") if isinstance(item, str): item = Sequence([item]) super(Sequence, self).__setitem__(index, item) return if not isinstance(item, Item): item = Item(item) if self.includes(item): super(Sequence, self).__setitem__(index, item) self.__frames = None self.__missing = None else: raise SequenceError("Item is not a member of sequence.") def __setslice__(self, start: int, end: int, item: Union[Item, str, List[str]]): """Used to set a slice of the sequence. :param start: Start index. :param end: End index. :param item: Item or list of items to set. """ if isinstance(item, str): item = Sequence([item]) if isinstance(item, list) is False: raise TypeError("Invalid type to add to sequence") for i in item: if self.includes(i) is False: raise SequenceError("Item (%s) is not a member of sequence." % i) super(Sequence, self).__setslice__(start, end, item) self.__frames = None self.__missing = None def __add__(self, item: Union[Item, str, List[str]]): """return a new sequence with the item appended. Accepts an Item, a string, or a list. :param item: Item or string to add to the sequence. :return: Sequence object. """ if isinstance(item, str): item = Sequence([item]) if isinstance(item, list) is False: raise TypeError("Invalid type to add to sequence") ns = Sequence(self[::]) ns.extend(item) return ns def __iadd__(self, item: Union[str, Item, List[str]]): """Adds an item to the sequence. Accepts an Item, a string, or a list. :param item: Item or string to add to the sequence. :return: Sequence object. """ if isinstance(item, str) or isinstance(item, Item): item = [item] if isinstance(item, list) is False: raise TypeError("Invalid type to add to sequence") self.extend(item) return self def format(self, fmt: str = global_format): """Format the stdout string. The following directives can be embedded in the format string. Format directives support padding, for example: "%04l". +-----------+--------------------------------------+ | Directive | Meaning | +===========+======================================+ | ``%s`` | sequence start | +-----------+--------------------------------------+ | ``%e`` | sequence end | +-----------+--------------------------------------+ | ``%l`` | sequence length | +-----------+--------------------------------------+ | ``%f`` | list of found files | +-----------+--------------------------------------+ | ``%m`` | list of missing files | +-----------+--------------------------------------+ | ``%M`` | explicit missing files [11-14,19-21] | +-----------+--------------------------------------+ | ``%p`` | padding, e.g. %06d | +-----------+--------------------------------------+ | ``%r`` | implied range, start-end | +-----------+--------------------------------------+ | ``%R`` | explicit broken range, [1-10, 15-20] | +-----------+--------------------------------------+ | ``%d`` | disk usage | +-----------+--------------------------------------+ | ``%H`` | disk usage (human readable) | +-----------+--------------------------------------+ | ``%D`` | parent directory | +-----------+--------------------------------------+ | ``%h`` | string preceding sequence number | +-----------+--------------------------------------+ | ``%t`` | string after the sequence number | +-----------+--------------------------------------+ :param fmt: Format string. Default is '%4l %h%p%t %R'. :return: Formatted string. """ format_char_types = { "s": "i", "e": "i", "l": "i", "f": "s", "m": "s", "M": "s", "p": "s", "r": "s", "R": "s", "d": "s", "H": "s", "D": "s", "h": "s", "t": "s", } atts = self.__attrs__() for m in format_re.finditer(fmt): var = m.group("var") pad = m.group("pad") try: fmt_char = format_char_types[var] except KeyError: raise FormatError("Bad directive: %%%s" % var) _old = "%s%s" % (pad or "", var) _new = "(%s)%s%s" % (var, pad or "", fmt_char) fmt = fmt.replace(_old, _new) val = atts[var] # only execute the callable once, just in case if callable(val): val = atts[var]() atts[var] = val return fmt % atts @property def mtime(self): """Returns the latest mtime of all items.""" maxDate = list() for i in self: maxDate.append(i.mtime) return max(maxDate) @property def size(self): """Returns the size all items in bytes.""" tempSize = list() for i in self: tempSize.append(i.size) return sum(tempSize) @property def human(self): """Returns the size of all items in human-readable format.""" total_size = self.size units = ["B", "K", "M", "G", "T"] unit_index = 0 while total_size >= 1024 and unit_index < len(units) - 1: total_size /= 1024 unit_index += 1 return f"{total_size:7.1f}{units[unit_index]}" def directory(self): return self[0].dirname + os.sep def length(self): """:return: The length of the sequence.""" return len(self) def frames(self): """:return: List of files in sequence.""" if not hasattr(self, "__frames") or not self.__frames or self.__dirty: self.__frames = self._get_frames() self.__frames.sort() return self.__frames def start(self): """:return: First index number in sequence.""" try: return self.frames()[0] except IndexError: return 0 def end(self): """:return: Last index number in sequence.""" try: return self.frames()[-1] except IndexError: return 0 def missing(self): """:return: List of missing files.""" if not hasattr(self, "__missing") or not self.__missing: self.__missing = self._get_missing() return self.__missing def head(self): """:return: String before the sequence index number.""" return self[0].head def tail(self): """:return: String after the sequence index number.""" return self[0].tail def path(self): """:return: Absolute path to sequence.""" _dirname = str(os.path.dirname(self[0].path)) return os.path.join(_dirname, str(self)) def includes(self, item: Union[str, Item]): """Checks if the item can be contained in this sequence, i.e. if it is a sibling of any of the items in the list. For example: >>> s = Sequence(['fileA.0001.jpg', 'fileA.0002.jpg']) >>> print(s) fileA.1-2.jpg >>> s.includes('fileA.0003.jpg') True >>> s.includes('fileB.0003.jpg') False :param item: pyseq.Item class object. :return: True if item is a sequence member. """ if not self: return True if not isinstance(item, Item): item = Item(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): """Checks for sequence membership. Calls Item.is_sibling() and returns True if item is part of the sequence. For example: >>> s = Sequence(['fileA.0001.jpg', 'fileA.0002.jpg']) >>> print(s) fileA.1-2.jpg >>> s.contains('fileA.0003.jpg') False >>> s.contains('fileB.0003.jpg') False :param item: pyseq.Item class object. :return: True if item is a sequence member. """ if len(self) > 0: if not isinstance(item, Item): item = Item(item) return self.includes(item) and self.end() >= item.frame >= self.start() return False def append(self, item: Item, check_membership: bool = True): """Adds another member to the sequence. :param item: pyseq.Item object. :param check_membership: Check if `item` is a member. Can be useful if membership is checked prior to appending. :exc:`SequenceError` raised if item is not a sequence member. """ if not isinstance(item, Item): item = Item(item) if not check_membership: super(Sequence, self).append(item) else: if self.includes(item): super(Sequence, self).append(item) else: raise SequenceError(f"Item {item} is not a member of this sequence.") def insert(self, index: int, item: Item, check_membership: bool = True): """Add another member to the sequence at the given index. :param index: The index at which to insert the item. :param item: pyseq.Item object. :param check_membership: Check if `item` is a member. Can be useful if membership is checked prior to appending. :raises: `SequenceError` Raised if item is not a sequence member. """ if not isinstance(item, Item): item = Item(item) if not check_membership: super(Sequence, self).insert(index, item) else: if self.includes(item): super(Sequence, self).insert(index, item) else: raise SequenceError(f"Item {item} is not a member of this sequence.") def extend(self, items: List[Item], check_membership: bool = True): """Add members to the sequence. :param items: List of pyseq.Item objects. :param check_membership: Check if `item` is a member. Can be useful if membership is checked prior to appending. :exc: `SequenceError` Raised if any items are not a sequence member. """ for item in items: self.append(item, check_membership=check_membership) def reIndex(self, offset: int, padding: int = None): """Renames and reindexes the items in the sequence, e.g. :: >>> seq.reIndex(offset=100) will add a 100 frame offset to each Item in `seq`, and rename the files on disk. :param offset: The frame offset to apply to each item. :param padding: Change the padding. """ if not padding: padding = self.format("%p") if offset > 0: gen = ( (image, frame) for (image, frame) in zip(reversed(self), reversed(self.frames())) ) else: gen = ((image, frame) for (image, frame) in zip(self, self.frames())) for image, frame in gen: oldName = image.path newFrame = padding % (frame + offset) newFileName = "%s%s%s" % (self.format("%h"), newFrame, self.format("%t")) newName = os.path.join(image.dirname, newFileName) try: import shutil shutil.move(oldName, newName) except Exception as err: warnings.warn( "%s during reIndex %s -> %s: \n%s" % ( err.__class__.__name__, oldName, newName, traceback.format_exc(), ) ) else: self.__dirty = True image.frame = int(newFrame) else: self.frames() def _get_padding(self): """:return: Padding string (e.g. %07d).""" try: pad = min([i.pad for i in self]) if pad is None: return "" if pad < 2: return "%d" return "%%%02dd" % pad except IndexError: return "" def _get_framerange(self, frames: List[int], missing: bool = True): """Returns frame range string, e.g. [1-500]. :param frames: List of ints like [1,4,8,12,15]. :param missing: Expand sequence to exclude missing sequence indices. :return: Formatted frame range string. """ frange = [] start = "" end = "" if not missing: if frames: return "%s-%s" % (self.start(), self.end()) else: return "" if not frames: return "" for i in range(0, len(frames)): frame = frames[i] if isinstance(frame, range): 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))) return "[%s]" % range_join.join(frange) def _get_frames(self): """Finds the sequence indexes from item names.""" return [f.frame for f in self if f.frame is not None] def _get_missing(self, max_size: int = 100000): """Looks for missing sequence indexes in the sequence. :param max_size: maximum missing frame sequence size for returning explcit frames, otherwise use ranges. :return: List of missing frames, or ranges of frames if sequence size is greater than max_size. """ missing = [] frames = self.frames() if len(frames) == 0: return missing elif len(frames) == 1: return frames r = range(frames[0], frames[-1] + 1) if len(r) <= max_size: frames_set = set(frames) r_set = set(r) symmetric_diff = frames_set.symmetric_difference(r_set) return sorted(symmetric_diff) else: for i, f in enumerate(frames[:-1]): missing.append(range(f + 1, frames[i + 1])) return missing def diff(f1: Union[str, Item], f2: Union[str, Item]): """Examines diffs between f1 and f2 and deduces numerical sequence number. For example :: >>> diff('file01_0040.rgb', 'file01_0041.rgb') [{'frames': ('0040', '0041'), 'start': 7, 'end': 11}] >>> diff('file3.03.rgb', 'file4.03.rgb') [{'frames': ('3', '4'), 'start': 4, 'end': 5}] :param f1: pyseq.Item object. :param f2: pyseq.Item object to diff. :return: A dictionary with keys 'frames', 'start', and 'end'. """ if not isinstance(f1, Item): f1 = Item(f1) if not isinstance(f2, Item): f2 = Item(f2) d = [] 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())): continue d.append( { "start": m1.start(), "end": m1.end(), "frames": (m1.group(), m2.group()), } ) return d def uncompress(seq_string: str, fmt: str = global_format): """Basic uncompression or deserialization of a compressed sequence string. For example: >>> seq = pyseq.uncompress('012_vb_110_v001.%04d.png 1-10', fmt='%h%p%t %r') >>> print(seq) 012_vb_110_v001.1-10.png >>> len(seq) 10 >>> seq = pyseq.uncompress('a.%03d.tga [1-3, 10, 12-14]', fmt='%h%p%t %R') >>> print(seq) a.1-14.tga >>> len(seq) 7 >>> seq = pyseq.uncompress('a.%03d.tga 1-14 ([1-3, 10, 12-14])', fmt='%h%p%t %r (%R)') >>> print(seq) a.1-14.tga >>> len(seq) 7 >>> seq = pyseq.uncompress('a.1-100.exr', fmt='%h%r%t') >>> print(seq) a.1-100.exr >>> len(seq) 100 :param seq_string: Compressed sequence string. :param fmt: Format of sequence string. :return: :class:`.Sequence` instance. """ dirname = os.path.dirname(seq_string) # remove directory if "%D" in fmt: fmt = fmt.replace("%D", "") name = os.path.basename(seq_string) # map of directives to regex remap = { "s": r"\d+", "e": r"\d+", "l": r"\d+", "h": r"(.+)?", "t": r"(\S+)?", "r": r"\d+-\d+", "R": r"\[[\d\s?\-%s?]+\]" % re.escape(range_join), "p": r"%\d+d", "m": r"\[.*\]", "f": r"\[.*\]", } # escape any re chars in format fmt = re.escape(fmt) # replace \% with % back again fmt = fmt.replace("\\%", "%") for m in format_re.finditer(fmt): _old = "%%%s%s" % (m.group("pad") or "", m.group("var")) _new = "(?P<%s>%s)" % (m.group("var"), remap.get(m.group("var"), r"\w+")) fmt = fmt.replace(_old, _new) regex = re.compile(fmt) match = regex.match(name) frames = [] missing = [] s = None e = None if not match: return try: pad = match.group("p") except IndexError: pad = "%d" 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) 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) except IndexError: s = match.group("s") e = match.group("e") try: frames = eval(match.group("f")) except IndexError: pass try: missing = eval(match.group("m")) except IndexError: pass items = [] if missing: for i in range(int(s), int(e) + 1): if i in missing: continue f = pad % i name = "%s%s%s" % ( match.groupdict().get("h", ""), f, match.groupdict().get("t", ""), ) items.append(Item(os.path.join(dirname, name))) else: for i in frames: f = pad % i name = "%s%s%s" % ( match.groupdict().get("h", ""), f, match.groupdict().get("t", ""), ) items.append(Item(os.path.join(dirname, name))) seqs = get_sequences(items) if seqs: return seqs[0] return seqs def get_sequences(source: str, frame_pattern: str = config.PYSEQ_FRAME_PATTERN): """Returns a list of Sequence objects given a directory or list that contain sequential members. Get sequences in a directory: >>> seqs = get_sequences('tests/files/') >>> for s in seqs: print(s) ... 012_vb_110_v001.1-10.png 012_vb_110_v002.1-10.png a.1-14.tga alpha.txt bnc01_TinkSO_tx_0_ty_0.101-105.tif bnc01_TinkSO_tx_0_ty_1.101-105.tif bnc01_TinkSO_tx_1_ty_0.101-105.tif bnc01_TinkSO_tx_1_ty_1.101-105.tif file.1-2.tif file.info.03.rgb file01_40-43.rgb file02_44-47.rgb file1-4.03.rgb file_02.tif z1_001_v1.1-4.png z1_002_v1.1-4.png z1_002_v2.1-4.png Get sequences from a list of file names: >>> seqs = get_sequences(['fileA.1.rgb', 'fileA.2.rgb', 'fileB.1.rgb']) >>> for s in seqs: print(s) ... fileA.1-2.rgb fileB.1.rgb :param source: Can be directory path, list of strings, or sortable list of objects. :param frame_pattern: Regular expression pattern for frame matching. :return: List of pyseq.Sequence class objects. """ seqs = [] config.set_frame_pattern(frame_pattern) if isinstance(source, list): items = sorted(source, key=lambda x: str(x)) elif isinstance(source, str): if os.path.isdir(source): items = sorted(glob(os.path.join(source, "*"))) else: items = sorted(glob(source)) else: raise TypeError("Unsupported format for source argument") items = deque(items) # organize the items into sequences while items: item = Item(items.popleft()) found = False for seq in reversed(seqs): if seq.includes(item): seq.append(item, check_membership=False) found = True break if not found: seq = Sequence([item]) seqs.append(seq) return seqs def iget_sequences(source: str, frame_pattern: str = config.PYSEQ_FRAME_PATTERN): """Generator version of get_sequences. Creates Sequences from a various source files. A notable difference is the sort order of iget_sequences versus get_sequences. iget_sequences uses an adaption of natural sorting that starts with the file extension. Because of this, Sequences are returned ordered by their file extension. Get sequences in a directory: >>> seqs = iget_sequences('./tests/files/') >>> for s in seqs: print(s) ... file01.1-4.j2k fileA.1-3.jpg 012_vb_110_v001.1-10.png 012_vb_110_v002.1-10.png fileA.1-3.png z1_001_v1.1-4.png z1_002_v1.1-4.png z1_002_v2.1-4.png file1.03.rgb file01_40-43.rgb file2.03.rgb file02_44-47.rgb file3-4.03.rgb file.info.03.rgb a.1-14.tga bnc01_TinkSO_tx_0_ty_0.101-105.tif bnc01_TinkSO_tx_0_ty_1.101-105.tif bnc01_TinkSO_tx_1_ty_0.101-105.tif bnc01_TinkSO_tx_1_ty_1.101-105.tif file.1-2.tif file_02.tif alpha.txt Get sequences from a list of file names: >>> seqs = iget_sequences(['fileA.1.rgb', 'fileA.2.rgb', 'fileB.1.rgb']) >>> for s in seqs: print(s) ... fileA.1-2.rgb fileB.1.rgb :param source: Can be directory path, list of strings, or sortable list of objects. :param frame_pattern: Regular expression pattern for frame matching. :yield: pyseq.Sequence class objects. """ config.set_frame_pattern(frame_pattern) if isinstance(source, list): items = source elif isinstance(source, str): if os.path.isdir(source): join = os.path.join items = [join(source, x) for x in os.listdir(source)] else: items = iglob(source) else: raise TypeError("Unsupported format for source argument") items = deque(sorted(items, key=_ext_key)) seq = None while items: item = Item(items.popleft()) if seq is None: seq = Sequence([item]) elif seq.includes(item): seq.append(item, check_membership=False) else: yield seq seq = Sequence([item]) if seq is not None: yield seq def walk( source: str, level: int = -1, topdown: bool = True, onerror: Callable[[str, OSError], None] = None, followlinks: bool = False, hidden: bool = False, ): """Generator that traverses a directory structure starting at source looking for sequences. :param source: Valid folder path to traverse. :param level: int, if < 0 traverse entire structure otherwise traverse to given depth. :param topdown: Walk from the top down. :param onerror: Callable to handle os.listdir errors. :param followlinks: Whether to follow links. :param hidden: Include hidden files and dirs. :yield: Tuple of (directory, directories, sequences). """ for root, dirs, files in os.walk(source, topdown, onerror, followlinks): if not hidden: files = [f for f in files if not f[0] == "."] dirs[:] = [d for d in dirs if not d[0] == "."] files = [os.path.join(root, f) for f in files] if topdown is True: parts = root.replace(source, "").split(os.sep) while "" in parts: parts.remove("") if len(parts) == level - 1: del dirs[:] yield root, dirs, get_sequences(files) pyseq-0.9.2/lib/pyseq/sfind.py000066400000000000000000000065641516561675500163030ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains the main sfind functions for the pyseq module. """ import argparse import fnmatch import os import sys import pyseq from pyseq.util import cli_catch_keyboard_interrupt def walk_and_collect_sequences( root: str, include_hidden: bool = False, pattern: str = None, ): """Recursively walk through the directory tree and collect sequences.""" for dirpath, dirnames, filenames in os.walk(root): if not include_hidden: filenames = [f for f in filenames if not f.startswith(".")] seqs = pyseq.get_sequences(filenames) for seq in seqs: full_str = str(seq) if pattern and not fnmatch.fnmatch(full_str, pattern): continue yield os.path.join(dirpath, full_str) @cli_catch_keyboard_interrupt def main(): """Main function to parse arguments and call the sequence finder.""" parser = argparse.ArgumentParser(description="Recursively find image sequences") parser.add_argument( "paths", nargs="+", help="Directory or directories to search.", ) parser.add_argument( "-a", "--all", action="store_true", help="All files are listed (include hidden files).", ) parser.add_argument( "-name", metavar="PATTERN", help="Glob pattern to match sequences (e.g. '*.png').", ) 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) return 0 if __name__ == "__main__": sys.exit(main()) pyseq-0.9.2/lib/pyseq/smove.py000066400000000000000000000137201516561675500163210ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains the main smove functions for the pyseq module. """ import sys import os import argparse import shutil from typing import Optional import pyseq from pyseq.util import ( cli_catch_keyboard_interrupt, parse_destination_reference, resolve_sequence_reference, ) def move_sequence( seq: pyseq.Sequence, src_dir: str, dest_dir: str, rename: Optional[str] = None, renumber: Optional[int] = None, pad: Optional[int] = None, force: bool = False, dryrun: bool = False, verbose: bool = False, ): """Move a sequence of files from one directory to another. :param seq: The sequence object to move. :param src_dir: The source directory containing the files. :param dest_dir: The destination directory to move the files to. :param rename: New basename for the files. :param renumber: New starting frame number. :param pad: Number of digits to pad the frame number. :param force: Overwrite existing files if True. :param dryrun: If True, only print the actions without executing them. :param verbose: If True, print detailed information about the actions. """ dest_basename = rename or seq.head() dest_pad = pad or seq.pad start_frame = renumber or seq.start() for i, frame in enumerate(seq): src_path = os.path.join(src_dir, frame.name) frame_num = start_frame + i dest_frame_name = f"{dest_basename}{frame_num:0{dest_pad}d}{seq.tail()}" dest_path = os.path.join(dest_dir, dest_frame_name) if verbose or dryrun: print(f"{src_path} -> {dest_path}") if not dryrun: os.makedirs(dest_dir, exist_ok=True) if os.path.exists(dest_path) and not force: print( f"File exists: {dest_path} (use --force to overwrite)", file=sys.stderr, ) continue shutil.move(src_path, dest_path) @cli_catch_keyboard_interrupt def main(): """Main function to handle command line arguments and call the move_sequence.""" parser = argparse.ArgumentParser( description="Move image sequences with destination-based renaming and renumbering support", ) parser.add_argument( "paths", nargs="+", help="Source sequence(s) followed by a destination directory or sequence pattern", ) parser.add_argument( "--renumber", type=int, help="New starting frame", ) parser.add_argument( "--pad", type=int, help="Padding digits", ) parser.add_argument( "-f", "--force", action="store_true", help="Overwrite existing files", ) parser.add_argument( "-d", "--dryrun", action="store_true", help="Preview move without performing it", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Verbose output", ) args = parser.parse_args() if len(args.paths) < 2: print("Error: expected at least one source and a destination", file=sys.stderr) return 1 sources = args.paths[:-1] dest = args.paths[-1] for source in sources: try: seq, dirname = resolve_sequence_reference(source) 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" ) 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"] ) dest_dir = dest_spec["dest_dir"] move_sequence( seq, dirname, dest_dir, rename=rename, renumber=renumber, pad=pad, force=args.force, dryrun=args.dryrun, verbose=args.verbose, ) except Exception as e: print(f"Error processing {source}: {e}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main()) pyseq-0.9.2/lib/pyseq/sremove.py000066400000000000000000000071371516561675500166550ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains the main sremove functions for the pyseq module. """ import argparse import os import sys import pyseq from pyseq.util import cli_catch_keyboard_interrupt, resolve_sequence_reference def remove_sequence( seq: pyseq.Sequence, src_dir: str, force: bool = False, dryrun: bool = False, verbose: bool = False, ): """Remove a sequence of files from src_dir.""" for frame in seq: src_path = os.path.join(src_dir, frame.name) if verbose or dryrun: print(src_path) if dryrun: continue try: os.remove(src_path) except FileNotFoundError: if not force: raise @cli_catch_keyboard_interrupt def main(): """Main function to parse cli args and remove sequences.""" parser = argparse.ArgumentParser( description="Remove image sequences resolved from patterns, ranges, or globs", ) parser.add_argument( "sources", nargs="+", help="Source sequences (globs, compressed patterns, or explicit ranges)", ) parser.add_argument( "-f", "--force", action="store_true", help="Ignore missing files", ) parser.add_argument( "-d", "--dryrun", action="store_true", help="Preview removals without performing them", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Verbose output", ) args = parser.parse_args() for source in args.sources: try: seq, dirname = resolve_sequence_reference(source) remove_sequence( seq, dirname, force=args.force, dryrun=args.dryrun, verbose=args.verbose, ) except Exception as e: print(f"Error processing {source}: {e}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main()) pyseq-0.9.2/lib/pyseq/sstat.py000066400000000000000000000130421516561675500163230ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains the main sstat functions for the pyseq module. """ import argparse import datetime import json import os import sys import pyseq from pyseq.util import cli_catch_keyboard_interrupt from pyseq.util import resolve_sequence def format_time(ts: int): """Format a timestamp into a human-readable string. :param ts: The timestamp to format. :return: A formatted string representing the timestamp. """ try: return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S %z") except Exception: return "-" def print_sstat(seq: pyseq.Sequence): """Prints the statistics of a sequence. :param seq: The pyseq.Sequence object to print statistics for. """ 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]) except Exception as e: print(f"Error: cannot stat frame: {e}", file=sys.stderr) return 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}" ) missing = seq.format("%M") print(f"Missing: {missing if missing else 'none'}") print(f"Head: {seq.head()}") print(f"Tail: {seq.tail()}") print(f"Range: {seq.format('%r')}") 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)}") def json_sstat(seq: pyseq.Sequence): """Convert sequence statistics to JSON format. :param seq: The sequence object to convert to JSON. :return: A dictionary containing the sequence statistics. """ st_first = os.stat(os.path.join(seq.format("%D"), seq[0].name)) st_last = os.stat(os.path.join(seq.format("%D"), seq[-1].name)) return { "sequence": str(seq), "head": seq.head(), "tail": seq.tail(), "start": seq.start(), "end": seq.end(), "length": seq.length(), "pad": seq.pad, "range": seq.format("%r"), "missing": seq.missing(), "size_bytes": seq.format("%d"), "size_human": seq.format("%H").strip(), "access": { "first": format_time(st_first.st_atime), "last": format_time(st_last.st_atime), }, "modify": { "first": format_time(st_first.st_mtime), "last": format_time(st_last.st_mtime), }, "change": { "first": format_time(st_first.st_ctime), "last": format_time(st_last.st_ctime), }, } @cli_catch_keyboard_interrupt def main(): """Main function to parse arguments and display sequence statistics.""" parser = argparse.ArgumentParser( description="Display stat-like metadata about a file sequence.\n" "Supports compressed strings (e.g. 'foo.%04d.exr') or glob inputs.", formatter_class=argparse.RawTextHelpFormatter, ) parser.add_argument( "sequence", help="Input sequence as glob or compressed pattern (e.g. 'foo.%%04d.exr')", ) parser.add_argument( "--json", action="store_true", help="Output metadata as JSON", ) args = parser.parse_args() try: seq = resolve_sequence(args.sequence) if args.json: data = json_sstat(seq) print(json.dumps(data, indent=4)) else: print_sstat(seq) except Exception as e: print(f"sstat: error: {e}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main()) pyseq-0.9.2/lib/pyseq/stree.py000066400000000000000000000104231516561675500163070ustar00rootroot00000000000000#!/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. # ----------------------------------------------------------------------------- __doc__ = """ Contains the main stree functions for the pyseq module. """ import argparse import os import sys import pyseq from pyseq import config 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 = "", fmt: str = config.DEFAULT_FORMAT, include_hidden: bool = False, ): """Recursively prints the directory tree of the given root directory. :param root: The root directory to start printing from. :param prefix: The prefix to use for each line of the tree. """ try: entries = os.listdir(root) except OSError as e: print(f"{prefix}[error opening {root}: {e}]", file=sys.stderr) return 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))] seqs = pyseq.get_sequences(files) total = len(dirs) + len(seqs) for i, name in enumerate(dirs + [str(s.format(fmt)) for s in seqs]): is_last = i == total - 1 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: 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.""" parser = argparse.ArgumentParser(description="Display tree of sequences") parser.add_argument( "path", nargs="?", default=os.getcwd(), help="Root directory.", ) parser.add_argument( "-a", "--all", action="store_true", help="All files are listed (include hidden files).", ) parser.add_argument( "-f", "--format", default=config.DEFAULT_FORMAT, help="Format the sequence string.", ) args = parser.parse_args() print(args.path) print_tree(args.path, fmt=args.format, include_hidden=args.all) return 0 if __name__ == "__main__": sys.exit(main()) pyseq-0.9.2/lib/pyseq/util.py000066400000000000000000000303071516561675500161450ustar00rootroot00000000000000#!/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 functools import fnmatch import glob import os import re import sys import warnings from typing import Optional import pyseq from pyseq.config import range_join def deprecated(func): """Deprecation warning decorator.""" def inner(*args, **kwargs): warnings.warn( "Call to deprecated method {}".format(func.__name__), category=DeprecationWarning, stacklevel=2, ) return func(*args, **kwargs) inner.__name__ = func.__name__ inner.__doc__ = func.__doc__ inner.__dict__.update(func.__dict__) 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. :param x: The string to be split. :return: A list of characters and digits. """ 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 "%d" or "%0Nd", where N is a digit. :param s: The string to check. :return: True if the string is a compressed format string, False otherwise. """ return "%d" in s or re.search(r"%0\d+d", s) @functools.lru_cache(maxsize=None) def natural_sort(items: list): """ Sorts a list of items in natural order. :param items: The list of items to be sorted. :return: The sorted list of items. """ return sorted(items, key=_natural_key) def resolve_sequence(sequence_string: str): """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. :return: A pyseq.Sequence object of matching files. """ # split directory and filename directory = os.path.dirname(sequence_string) or "." filename = os.path.basename(sequence_string) # detect %d or %0Nd match = re.search(r"%0?(\d*)d", filename) if not match: raise ValueError("Sequence string must contain '%d' or '%0Nd'") padding = match.group(1) if padding: pad = int(padding) glob_part = 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", "*") 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) # filter using regex (because glob pattern is wide) regex = re.compile(f"^{regex_pattern}$") matches = [f for f in candidate_files if regex.match(os.path.basename(f))] if not matches: raise FileNotFoundError(f"No files match pattern: {sequence_string}") # pass full paths to get_sequences sequences = pyseq.get_sequences(matches) if not sequences: raise ValueError("No valid sequences found") elif len(sequences) > 1: raise ValueError("Multiple sequences found: %s" % sequences) return sequences[0] def build_sequence_pattern(head: str, pad: Optional[int], tail: str) -> str: """Build a compressed sequence pattern from sequence components.""" if pad: return f"{head}%0{pad}d{tail}" return f"{head}%d{tail}" def subset_sequence(seq, frames): """Return a sequence containing only the requested frames.""" frame_set = set(frames) items = [item for item in seq if item.frame in frame_set] found_frames = {item.frame for item in items} missing_frames = sorted(frame_set - found_frames) if missing_frames: raise FileNotFoundError(f"Missing frames in sequence: {missing_frames}") sequences = pyseq.get_sequences(items) if not sequences: raise ValueError("No valid sequence found for requested frame subset") return sequences[0] 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) embedded = re.match( r"^(?P.+?)(?P\[(?:[^\]]+)\]|\d+-\d+)(?P\.[^/\s]+)$", 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)) items = [ pyseq.Item( os.path.join( dirname, f"{embedded.group('head')}{frame}{embedded.group('tail')}", ) ) for frame in frames ] sequences = pyseq.get_sequences(items) if sequences: return { "seq": sequences[0], "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, } return None def resolve_sequence_reference(reference: str): """Resolve a source reference into a sequence and its containing directory.""" explicit = parse_explicit_sequence_string(reference) if explicit: dirname = os.path.dirname(reference) or "." requested_seq = explicit["seq"] if explicit["has_pad"]: pattern = os.path.join( dirname, build_sequence_pattern( requested_seq.head(), requested_seq.pad, requested_seq.tail(), ), ) full_seq = resolve_sequence(pattern) else: sequences = pyseq.get_sequences(os.listdir(dirname)) candidates = [ seq for seq in sequences 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())) ] if not candidates: raise FileNotFoundError(f"No sequence found matching {reference}") if len(candidates) > 1: raise ValueError( f"Multiple sequences found matching {reference}: {candidates}" ) full_seq = candidates[0] return subset_sequence(full_seq, requested_seq.frames()), dirname if is_compressed_format_string(reference): seq = resolve_sequence(reference) dirname = os.path.dirname(reference) or "." return seq, dirname dirname = os.path.dirname(reference) or "." basename = os.path.basename(reference) matches = [f for f in os.listdir(dirname) if fnmatch.fnmatchcase(f, basename)] sequences = pyseq.get_sequences(matches) if not sequences: raise FileNotFoundError(f"No sequence found matching {reference}") if len(sequences) > 1: raise ValueError(f"Multiple sequences found matching {reference}: {sequences}") return sequences[0], dirname def parse_destination_reference(destination: str, source_seq): """Parse a destination string as either a directory or destination sequence.""" explicit = parse_explicit_sequence_string(destination) if explicit: dest_seq = explicit["seq"] dest_frames = list(dest_seq.frames()) 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" ) if dest_frames != expected_frames: raise ValueError("Destination explicit range must be contiguous") if len(dest_seq) != len(source_seq): raise ValueError("Destination explicit range must match source length") return { "kind": "sequence", "dest_dir": os.path.dirname(destination) or ".", "rename": dest_seq.head(), "pad": dest_seq.pad if explicit["has_pad"] else None, "renumber": dest_frames[0], } if not is_compressed_format_string(destination): return { "kind": "directory", "dest_dir": destination, "rename": None, "pad": None, "renumber": None, } filename = os.path.basename(destination) match = re.search(r"%(?:0(?P\d+))?d", filename) if not match: raise ValueError(f"Invalid destination sequence pattern: {destination}") tail = filename[match.end() :] if tail != source_seq.tail(): raise ValueError( "Destination sequence pattern must preserve the source extension" ) return { "kind": "sequence", "dest_dir": os.path.dirname(destination) or ".", "rename": filename[: match.start()], "pad": int(match.group("pad")) if match.group("pad") else None, "renumber": None, } pyseq-0.9.2/pyproject.toml000066400000000000000000000016061516561675500156230ustar00rootroot00000000000000[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.8" 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" smv = "pyseq.smove:main" srm = "pyseq.sremove: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__" } pyseq-0.9.2/pyseq.env000077500000000000000000000016351516561675500145670ustar00rootroot00000000000000#!/usr/bin/env envstack include: [default] all: &all # matches all numbers, the most flexible PYSEQ_FRAME_PATTERN: ${PYSEQ_FRAME_PATTERN:=\d+} # excludes version numbers, e.g. file_v001.1001.exr # PYSEQ_FRAME_PATTERN: ([^v\d])\d+ # frame numbers are dot-delimited, e.g. file.v1.1001.exr # PYSEQ_FRAME_PATTERN: \.\d+\. # frame numbers start with an underscore, e.g. file_v1_1001.exr # 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: file01_%04d.exr # PYSEQ_GLOBAL_FORMAT: "%h%p%t" # sequence string format: file01_40-43.exr # PYSEQ_GLOBAL_FORMAT: "%h%r%t" # use strict padding on sequences (pad length must match) PYSEQ_STRICT_PAD: ${PYSEQ_STRICT_PAD:=0} # character to join explicit frame ranges on PYSEQ_RANGE_SEP: ", " darwin: <<: *all linux: <<: *all windows: <<: *all pyseq-0.9.2/tests/000077500000000000000000000000001516561675500140465ustar00rootroot00000000000000pyseq-0.9.2/tests/conftest.py000066400000000000000000000011451516561675500162460ustar00rootroot00000000000000import 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}") pyseq-0.9.2/tests/files/000077500000000000000000000000001516561675500151505ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0001.png000066400000000000000000000000001516561675500203430ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0002.png000066400000000000000000000000001516561675500203440ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0003.png000066400000000000000000000000001516561675500203450ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0004.png000066400000000000000000000000001516561675500203460ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0005.png000066400000000000000000000000001516561675500203470ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0006.png000066400000000000000000000000001516561675500203500ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0007.png000066400000000000000000000000001516561675500203510ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0008.png000066400000000000000000000000001516561675500203520ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0009.png000066400000000000000000000000001516561675500203530ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v001.0010.png000066400000000000000000000000001516561675500203430ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0001.png000066400000000000000000000000001516561675500203440ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0002.png000066400000000000000000000000001516561675500203450ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0003.png000066400000000000000000000000001516561675500203460ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0004.png000066400000000000000000000000001516561675500203470ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0005.png000066400000000000000000000000001516561675500203500ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0006.png000066400000000000000000000000001516561675500203510ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0007.png000066400000000000000000000000001516561675500203520ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0008.png000066400000000000000000000000001516561675500203530ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0009.png000066400000000000000000000000001516561675500203540ustar00rootroot00000000000000pyseq-0.9.2/tests/files/012_vb_110_v002.0010.png000066400000000000000000000000001516561675500203440ustar00rootroot00000000000000pyseq-0.9.2/tests/files/a.001.tga000066400000000000000000000000001516561675500163520ustar00rootroot00000000000000pyseq-0.9.2/tests/files/a.002.tga000066400000000000000000000000001516561675500163530ustar00rootroot00000000000000pyseq-0.9.2/tests/files/a.003.tga000066400000000000000000000000001516561675500163540ustar00rootroot00000000000000pyseq-0.9.2/tests/files/a.010.tga000066400000000000000000000000001516561675500163520ustar00rootroot00000000000000pyseq-0.9.2/tests/files/a.012.tga000066400000000000000000000000001516561675500163540ustar00rootroot00000000000000pyseq-0.9.2/tests/files/a.013.tga000066400000000000000000000000001516561675500163550ustar00rootroot00000000000000pyseq-0.9.2/tests/files/a.014.tga000066400000000000000000000000001516561675500163560ustar00rootroot00000000000000pyseq-0.9.2/tests/files/alpha.txt000066400000000000000000000000001516561675500167640ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_0.0101.tif000066400000000000000000000000001516561675500222410ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_0.0102.tif000066400000000000000000000000001516561675500222420ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_0.0103.tif000066400000000000000000000000001516561675500222430ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_0.0104.tif000066400000000000000000000000001516561675500222440ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_0.0105.tif000066400000000000000000000000001516561675500222450ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_1.0101.tif000066400000000000000000000000001516561675500222420ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_1.0102.tif000066400000000000000000000000001516561675500222430ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_1.0103.tif000066400000000000000000000000001516561675500222440ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_1.0104.tif000066400000000000000000000000001516561675500222450ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_0_ty_1.0105.tif000066400000000000000000000000001516561675500222460ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_0.0101.tif000066400000000000000000000000001516561675500222420ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_0.0102.tif000066400000000000000000000000001516561675500222430ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_0.0103.tif000066400000000000000000000000001516561675500222440ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_0.0104.tif000066400000000000000000000000001516561675500222450ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_0.0105.tif000066400000000000000000000000001516561675500222460ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_1.0101.tif000066400000000000000000000000001516561675500222430ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_1.0102.tif000066400000000000000000000000001516561675500222440ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_1.0103.tif000066400000000000000000000000001516561675500222450ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_1.0104.tif000066400000000000000000000000001516561675500222460ustar00rootroot00000000000000pyseq-0.9.2/tests/files/bnc01_TinkSO_tx_1_ty_1.0105.tif000066400000000000000000000000001516561675500222470ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file.01.tif000066400000000000000000000000001516561675500170000ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file.02.tif000066400000000000000000000000001516561675500170010ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file.98.tif000066400000000000000000000000001516561675500170200ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file.99.tif000066400000000000000000000000001516561675500170210ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file.info.03.rgb000066400000000000000000000000001516561675500177240ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file01.001.j2k000066400000000000000000000000001516561675500171250ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file01.002.j2k000066400000000000000000000000001516561675500171260ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file01.004.j2k000066400000000000000000000000001516561675500171300ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file01_0040.rgb000066400000000000000000000000001516561675500173550ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file01_0041.rgb000066400000000000000000000000001516561675500173560ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file01_0042.rgb000066400000000000000000000000001516561675500173570ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file01_0043.rgb000066400000000000000000000000001516561675500173600ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file02_0044.rgb000066400000000000000000000000001516561675500173620ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file02_0045.rgb000066400000000000000000000000001516561675500173630ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file02_0046.rgb000066400000000000000000000000001516561675500173640ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file02_0047.rgb000066400000000000000000000000001516561675500173650ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file1.03.rgb000066400000000000000000000000001516561675500170530ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file2.03.rgb000066400000000000000000000000001516561675500170540ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file3.03.rgb000066400000000000000000000000001516561675500170550ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file4.03.rgb000066400000000000000000000000001516561675500170560ustar00rootroot00000000000000pyseq-0.9.2/tests/files/fileA.0001.jpg000066400000000000000000000000001516561675500172370ustar00rootroot00000000000000pyseq-0.9.2/tests/files/fileA.0001.png000066400000000000000000000000001516561675500172430ustar00rootroot00000000000000pyseq-0.9.2/tests/files/fileA.0002.jpg000066400000000000000000000000001516561675500172400ustar00rootroot00000000000000pyseq-0.9.2/tests/files/fileA.0002.png000066400000000000000000000000001516561675500172440ustar00rootroot00000000000000pyseq-0.9.2/tests/files/fileA.0003.jpg000066400000000000000000000000001516561675500172410ustar00rootroot00000000000000pyseq-0.9.2/tests/files/fileA.0003.png000066400000000000000000000000001516561675500172450ustar00rootroot00000000000000pyseq-0.9.2/tests/files/file_02.tif000066400000000000000000000000001516561675500170620ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_001_v1.1.png000066400000000000000000000000001516561675500173230ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_001_v1.2.png000066400000000000000000000000001516561675500173240ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_001_v1.3.png000066400000000000000000000000001516561675500173250ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_001_v1.4.png000066400000000000000000000000001516561675500173260ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_002_v1.1.png000066400000000000000000000000001516561675500173240ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_002_v1.2.png000066400000000000000000000000001516561675500173250ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_002_v1.3.png000066400000000000000000000000001516561675500173260ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_002_v1.4.png000066400000000000000000000000001516561675500173270ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_002_v2.10.png000066400000000000000000000000001516561675500174050ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_002_v2.11.png000066400000000000000000000000001516561675500174060ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_002_v2.12.png000066400000000000000000000000001516561675500174070ustar00rootroot00000000000000pyseq-0.9.2/tests/files/z1_002_v2.9.png000066400000000000000000000000001516561675500173350ustar00rootroot00000000000000pyseq-0.9.2/tests/test_cli_interrupts.py000066400000000000000000000034551516561675500205340ustar00rootroot00000000000000#!/usr/bin/env python import pytest from pyseq import lss, scopy, sdiff, sfind, smove, sremove, 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, "smv")]) 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_reference", _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 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("sys.argv", ["srm", "a.%04d.exr"]) assert sremove.main() == 1 captured = capsys.readouterr() assert "stopping..." in captured.err pyseq-0.9.2/tests/test_config.py000066400000000000000000000050361516561675500167300ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the config module. """ import re from pyseq import config def test_set_frame_pattern_valid(): """Test that a valid regex pattern is set correctly.""" pattern = r"\d+" config.set_frame_pattern(pattern) assert config.frames_re.pattern == pattern assert isinstance(config.frames_re, re.Pattern) def test_set_frame_pattern_invalid(): """Test that invalid regex pattern falls back to default pattern.""" # intentionally broken regex bad_pattern = r"(" config.set_frame_pattern(bad_pattern) # expect fallback to default assert config.frames_re.pattern == config.DEFAULT_FRAME_PATTERN def test_set_frame_pattern_invalid_prints_error(capfd): """Test that invalid regex prints an error and reverts to default pattern.""" bad_pattern = r"[" config.set_frame_pattern(bad_pattern) out, err = capfd.readouterr() assert "Error: Invalid regex pattern" in out assert config.frames_re.pattern == config.DEFAULT_FRAME_PATTERN pyseq-0.9.2/tests/test_lss.py000066400000000000000000000061421516561675500162630ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the lss module. """ import os import subprocess import tempfile import pytest from conftest import get_installed_command lss_bin = get_installed_command("lss") @pytest.fixture def lss_fixture(): """Fixture to create a temporary directory with test files.""" with tempfile.TemporaryDirectory() as tmpdir: for i in range(1, 4): with open(os.path.join(tmpdir, f"shot.{i:04d}.exr"), "w") as f: f.write("frame\n") yield tmpdir def test_lss_with_directory(lss_fixture): """Test lss with a directory input.""" result = subprocess.run( [lss_bin, lss_fixture], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 out = result.stdout assert " 3 shot.%04d.exr [1-3]" in out def test_lss_with_wildcard(lss_fixture): """Test lss with a wildcard pattern.""" pattern = os.path.join(lss_fixture, "shot.*.exr") result = subprocess.run( [lss_bin, pattern], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 out = result.stdout assert " 3 shot.%04d.exr [1-3]" in out def test_lss_stdin_input(lss_fixture): """Test lss with stdin input.""" result = subprocess.run( [lss_bin], input=f"{lss_fixture}\n", stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 out = result.stdout assert " 3 shot.%04d.exr [1-3]" in out pyseq-0.9.2/tests/test_pyseq.py000066400000000000000000001005601516561675500166220ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the pyseq package. """ import os import re import random 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 from pyseq import Item, Sequence, diff, uncompress, get_sequences from pyseq import SequenceError from pyseq import seq as pyseq 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""" def setUp(self): """set up the test""" self.test_path = os.path.abspath( os.path.join( os.sep, "mnt", "S", "Some", "Path", "to", "a", "file", "with", "numbers", "file.0010.exr", ) ) def test_initializing_with_a_string(self): """testing if initializing an Item with a string showing the path of a file is working properly """ i = Item(self.test_path) self.assertTrue(isinstance(i, Item)) def test_path_attribute_is_working_properly(self): """testing if the path attribute is working properly""" i = Item(self.test_path) self.assertEqual(self.test_path, i.path) def test_path_attribute_is_read_only(self): """testing if the path attribute is read only""" i = Item(self.test_path) with self.assertRaises(AttributeError) as cm: setattr(i, "path", "some value") assert_read_only_attribute_error(str(cm.exception)) def test_name_attribute_is_working_properly(self): """testing if the name attribute is working properly""" i = Item(self.test_path) self.assertEqual(i.name, "file.0010.exr") def test_name_attribute_is_read_only(self): """testing if the name attribute is read only""" i = Item(self.test_path) with self.assertRaises(AttributeError) as cm: setattr(i, "name", "some value") assert_read_only_attribute_error(str(cm.exception)) def test_dirname_attribute_is_working_properly(self): """testing if the dirname attribute is working properly""" i = Item(self.test_path) self.assertEqual(i.dirname, os.path.dirname(self.test_path)) def test_dirname_attribute_is_read_only(self): """testing if the dirname attribute is read only""" i = Item(self.test_path) with self.assertRaises(AttributeError) as cm: setattr(i, "dirname", "some value") assert_read_only_attribute_error(str(cm.exception)) def test_digits_attribute_is_working_properly(self): """testing if the digits attribute is working properly""" i = Item(self.test_path) self.assertEqual(i.digits, ["0010"]) def test_digits_attribute_is_read_only(self): """testing if the digits attribute is read only""" i = Item(self.test_path) with self.assertRaises(AttributeError) as cm: setattr(i, "digits", "some value") assert_read_only_attribute_error(str(cm.exception)) def test_parts_attribute_is_working_properly(self): """testing if the parts attribute is working properly""" i = Item(self.test_path) self.assertEqual(i.parts, ["file.", ".exr"]) def test_parts_attribute_is_read_only(self): """testing if the parts attribute is read only""" i = Item(self.test_path) with self.assertRaises(AttributeError) as cm: setattr(i, "parts", "some value") 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""" item1 = Item("/mnt/S/Some/Path/to/a/file/with/numbers/file.0010.exr") item2 = Item("/mnt/S/Some/Path/to/a/file/with/numbers/file.0101.exr") self.assertTrue(item1.is_sibling(item2)) self.assertTrue(item2.is_sibling(item1)) class SequenceTestCase(unittest.TestCase): """tests the pyseq""" def setUp(self): """set the test up""" self.files = ["file.0001.jpg", "file.0002.jpg", "file.0003.jpg"] def test_from_list(self): """testing if Sequence instance can be initialized with a list of file names """ seq = Sequence(self.files) self.assertEqual(str(seq), "file.1-3.jpg") def test_appending_a_new_file(self): """testing if it is possible to append a new item to the list by giving the file name """ seq = Sequence(self.files) test_file = "file.0006.jpg" seq.append(test_file) self.assertTrue(seq.contains("file.0005.jpg")) self.assertTrue(seq.contains(test_file)) self.assertFalse(seq.contains("file.0015.jpg")) def test___setitem__(self): s = Sequence(["file.01.ext", "file.05.ext"]) s[1] = "file.02.ext" self.assertEqual(len(s), 2) self.assertEqual(s[0], Item("file.01.ext")) self.assertEqual(s[1], Item("file.02.ext")) self.assertRaises(SequenceError, s.__setitem__, 0, "item.1.ext") def test___add__(self): s = Sequence(["file.01.ext"]) ns = s + Item("file.02.ext") self.assertEqual(len(ns), 2) self.assertEqual(ns[0], s[0]) self.assertEqual(ns[1], Item("file.02.ext")) self.assertEqual(len(s), 1) ns = s + "file.02.ext" self.assertEqual(len(ns), 2) self.assertEqual(ns[0], s[0]) self.assertEqual(ns[1], Item("file.02.ext")) self.assertEqual(len(s), 1) ns = s + ["file.02.ext"] self.assertEqual(len(ns), 2) self.assertEqual(ns[0], s[0]) self.assertEqual(ns[1], Item("file.02.ext")) self.assertEqual(len(s), 1) ns = s + Sequence(["file.02.ext"]) self.assertEqual(len(ns), 2) self.assertEqual(ns[0], s[0]) self.assertEqual(ns[1], Item("file.02.ext")) self.assertEqual(len(s), 1) self.assertRaises(SequenceError, s.__add__, "item.01.ext") self.assertRaises(TypeError, s.__add__, 1) def test___iadd__(self): s = Sequence(["file.01.ext"]) s += Item("file.02.ext") self.assertEqual(len(s), 2) self.assertEqual(s[0], s[0]) self.assertEqual(s[1], Item("file.02.ext")) s = Sequence(["file.01.ext"]) s += "file.02.ext" self.assertEqual(len(s), 2) self.assertEqual(s[0], s[0]) self.assertEqual(s[1], Item("file.02.ext")) s = Sequence(["file.01.ext"]) s += ["file.02.ext"] self.assertEqual(len(s), 2) self.assertEqual(s[0], s[0]) self.assertEqual(s[1], Item("file.02.ext")) s = Sequence(["file.01.ext"]) s += Sequence(["file.02.ext"]) self.assertEqual(len(s), 2) self.assertEqual(s[0], s[0]) self.assertEqual(s[1], Item("file.02.ext")) def test___setslice___(self): s = Sequence(["file.001.ext"]) s[1:2] = "file.002.ext" self.assertEqual(len(s), 2) self.assertEqual(s[0], Item("file.001.ext")) self.assertEqual(s[1], Item("file.002.ext")) s = Sequence(["file.001.ext"]) s[1:2] = Item("file.002.ext") self.assertEqual(len(s), 2) self.assertEqual(s[0], Item("file.001.ext")) self.assertEqual(s[1], Item("file.002.ext")) s = Sequence(["file.001.ext"]) s[1:2] = [Item("file.002.ext")] self.assertEqual(len(s), 2) self.assertEqual(s[0], Item("file.001.ext")) self.assertEqual(s[1], Item("file.002.ext")) s = Sequence(["file.001.ext"]) s[1:2] = Sequence([Item("file.002.ext")]) self.assertEqual(len(s), 2) self.assertEqual(s[0], Item("file.001.ext")) self.assertEqual(s[1], Item("file.002.ext")) self.assertRaises(SequenceError, s.__setslice__, 1, 2, "item.001.ext") def test_insert(self): s = Sequence(["file.001.ext"]) s.insert(0, "file.002.ext") self.assertEqual(len(s), 2) self.assertEqual(s[0], Item("file.002.ext")) self.assertEqual(s[1], Item("file.001.ext")) self.assertRaises(SequenceError, s.insert, 1, "item") def test_extend(self): s = Sequence(["file.001.ext"]) s.extend(["file.002.ext", "file.003.ext"]) self.assertEqual(len(s), 3) self.assertEqual(s[0], Item("file.001.ext")) self.assertEqual(s[1], Item("file.002.ext")) self.assertEqual(s[2], Item("file.003.ext")) self.assertRaises(SequenceError, s.extend, ["item"]) def test_includes_is_working_properly(self): """testing if Sequence.includes() method is working properly""" seq = Sequence(self.files) self.assertTrue(seq.includes("file.0009.jpg")) self.assertFalse(seq.includes("file.0009.pic")) def test_contains_is_working_properly(self): """test if Sequence.contains() method is working properly""" seq = Sequence(self.files) self.assertFalse(seq.contains("file.0009.jpg")) self.assertFalse(seq.contains("file.0009.pic")) def test_format_is_working_properly_1(self): """test if format is working properly""" seq = Sequence(self.files) seq.append("file.0006.jpg") self.assertEqual(seq.format("%h%p%t %r (%R)"), "file.%04d.jpg 1-6 ([1-3, 6])") def test_format_is_working_properly_2(self): """test if format is working properly""" seq = Sequence(self.files) seq.append("file.0006.jpg") self.assertEqual( "file.0001-0006.jpg", seq.format("%h%04s-%04e%t"), ) self.assertEqual( "file. 1- 6.jpg", seq.format("%h%4s-%4e%t"), ) def test_format_is_working_properly_3(self): """test if format is working properly""" seq = Sequence(self.files) seq.append("file.0006.jpg") seq.append("file.0010.jpg") self.assertEqual( seq.format("%h%p%t %r (missing %M)"), "file.%04d.jpg 1-10 (missing [4-5, 7-9])", ) def test_format_directory_attribute(self): dir_name = os.path.dirname(self.files[0]) + os.sep seq = Sequence(self.files) self.assertEqual(seq.format("%D"), dir_name) def test__get_missing(self): """test that _get_missing works""" # Can't initialize Sequence without an item # seq = Sequence([]) # self.assertEqual(seq._get_missing(), []) seq = Sequence(["file.00010.jpg"]) self.assertEqual(seq._get_missing(), []) seq = Sequence(self.files) seq.append("file.0006.jpg") self.assertEqual(seq._get_missing(), [4, 5]) seq = Sequence(["file.%04d.jpg" % i for i in range(20)]) seq.pop(10) seq.pop(10) seq.pop(10) seq.pop(14) seq.pop(14) missing = [10, 11, 12, 17, 18] self.assertEqual(seq._get_missing(), missing) missing = [] seq = Sequence(["file.0001.jpg"]) for i in range(2, 50): if random.randint(0, 1) == 1: seq.append("file.%04d.jpg" % i) else: missing.append(i) # remove ending random frames while missing[-1] > int(re.search(r"file\.(\d{4})\.jpg", seq[-1]).group(1)): missing.pop(-1) self.assertEqual(seq._get_missing(), missing) class PadSizeTestCase(unittest.TestCase): def test_padsize_with_default_padding(self): item = Item("file.001.exr") frame = "001" expected_padsize = 3 result = pyseq.padsize(item, frame) self.assertEqual(result, expected_padsize) def test_padsize_with_custom_padding(self): item = Item("file.001001.exr") frame = "001001" expected_padsize = 6 result = pyseq.padsize(item, frame) self.assertEqual(result, expected_padsize) def test_padsize_with_no_padding(self): item = Item("file.9.jpg") frame = "9" expected_padsize = 0 result = pyseq.padsize(item, frame) self.assertEqual(result, expected_padsize) def test_padsize_with_strict_padding_disabled(self): pyseq.strict_pad = False item = Item("file.9.jpg") frame = "9" expected_padsize = 0 result = pyseq.padsize(item, frame) self.assertEqual(result, expected_padsize) def test_padsize_with_strict_padding_enabled(self): pyseq.strict_pad = True item = Item("file.09.jpg") frame = "09" expected_padsize = 2 result = pyseq.padsize(item, frame) self.assertEqual(result, expected_padsize) class HelperFunctionsTestCase(unittest.TestCase): """tests the helper functions like pyseq.diff() pyseq.uncompress() pyseq.get_sequences() """ def test_diff_is_working_properly_1(self): """testing if diff is working properly""" self.assertEqual( diff("file01_0040.rgb", "file01_0041.rgb"), [{"frames": ("0040", "0041"), "start": 7, "end": 11}], ) def test_diff_is_working_properly_2(self): """testing if diff is working properly""" self.assertEqual( diff("file3.03.rgb", "file4.03.rgb"), [{"frames": ("3", "4"), "start": 4, "end": 5}], ) def test_uncompress_is_working_properly_1(self): """testing if uncompress is working properly""" seq = uncompress("./tests/files/012_vb_110_v001.%04d.png 1-10", fmt="%h%p%t %r") self.assertEqual("012_vb_110_v001.1-10.png", str(seq)) self.assertEqual(10, len(seq)) def test_uncompress_is_working_properly_2(self): """testing if uncompress is working properly""" seq2 = uncompress("./tests/files/a.%03d.tga [1-3, 10, 12-14]", fmt="%h%p%t %R") self.assertEqual("a.1-14.tga", str(seq2)) self.assertEqual(7, len(seq2)) def test_uncompress_is_working_properly_3(self): """testing if uncompress is working properly""" seq3 = uncompress("a.%03d.tga 1-14 ([1-3, 10, 12-14])", fmt="%h%p%t %r (%R)") self.assertEqual("a.1-14.tga", str(seq3)) self.assertEqual(7, len(seq3)) def test_uncompress_is_working_properly_4(self): """testing if uncompress is working properly""" seq4 = uncompress("a.%03d.tga 1-14 ([1-3, 10, 12-14])", fmt="%h%p%t %s-%e (%R)") self.assertEqual("a.1-14.tga", str(seq4)) def test_uncompress_is_working_properly_5(self): """testing if uncompress is working properly""" seq5 = uncompress("a.%03d.tga 1-14 [1-14]", fmt="%h%p%t %r %R") self.assertEqual("a.1-14.tga", str(seq5)) self.assertEqual(14, len(seq5)) def test_uncompress_is_working_properly_6(self): """testing if uncompress is working properly""" seq6 = uncompress("a.%03d.tga 1-14 ([1-14])", fmt="%h%p%t %r (%R)") self.assertEqual("a.1-14.tga", str(seq6)) self.assertEqual(14, len(seq6)) def test_uncompress_is_working_properly_7(self): """testing if uncompress is working properly, the frame 100000 does not fit inside the pad length """ # enable strict pad checking pyseq.strict_pad = True seq7 = uncompress("a.%03d.tga 1-100000 ([1-10, 100000])", fmt="%h%p%t %r (%R)") self.assertEqual("a.1-10.tga", str(seq7)) self.assertEqual(10, len(seq7)) # disable strict pad checking pyseq.strict_pad = False seq7 = uncompress("a.%03d.tga 1-100000 ([1-10, 100000])", fmt="%h%p%t %r (%R)") self.assertEqual("a.1-100000.tga", str(seq7)) self.assertEqual(11, len(seq7)) def test_uncompress_is_working_properly_8(self): """testing if uncompress is working properly""" seq8 = uncompress("a.%03d.tga 1-100 ([10, 20, 40, 50])", fmt="%h%p%t %r (%m)") self.assertEqual("a.1-100.tga", str(seq8)) self.assertEqual(96, len(seq8)) def test_get_sequences_is_working_properly_1(self): """testing if get_sequences is working properly""" seqs = get_sequences("./files/") expected_results = [ "012_vb_110_v001.1-10.png", "012_vb_110_v002.1-10.png", "a.1-14.tga", "alpha.txt", "bnc01_TinkSO_tx_0_ty_0.101-105.tif", "bnc01_TinkSO_tx_0_ty_1.101-105.tif", "bnc01_TinkSO_tx_1_ty_0.101-105.tif", "bnc01_TinkSO_tx_1_ty_1.101-105.tif", "file.1-2.tif", "file.info.03.rgb", "file01.1-4.j2k", "file01_40-43.rgb", "file02_44-47.rgb", "file1-4.03.rgb", "fileA.1-3.jpg", "fileA.1-3.png", "file_02.tif", "z1_001_v1.1-4.png", "z1_002_v1.1-4.png", "z1_002_v2.1-4.png", ] for seq, expected_result in zip(seqs, expected_results): self.assertEqual(expected_result, str(seq)) def test_get_sequences_is_working_properly_2(self): """testing if get_sequences is working properly""" seqs = get_sequences(["fileA.1.rgb", "fileA.2.rgb", "fileB.1.rgb"]) expected_results = ["fileA.1-2.rgb", "fileB.1.rgb"] for seq, expected_result in zip(seqs, expected_results): self.assertEqual(expected_result, str(seq)) def test_get_sequences_is_working_properly_3(self): """testing if get_sequences is working properly""" seqs = get_sequences("./tests/files/bnc*") expected_results = [ "bnc01_TinkSO_tx_0_ty_0.%04d.tif 101-105", "bnc01_TinkSO_tx_0_ty_1.%04d.tif 101-105", "bnc01_TinkSO_tx_1_ty_0.%04d.tif 101-105", "bnc01_TinkSO_tx_1_ty_1.%04d.tif 101-105", ] for seq, expected_result in zip(seqs, expected_results): self.assertEqual(expected_result, seq.format("%h%p%t %r")) class LSSTestCase(unittest.TestCase): """Tests lss command""" def run_command(self, *args): """a simple wrapper for subprocess.Popen""" process = subprocess.Popen( args, stdout=subprocess.PIPE, universal_newlines=True ) stdout, _ = process.communicate() return stdout def setUp(self): """ """ self.maxDiff = None 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 is disabled.""" test_files = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files") 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 4 z1_001_v1.%d.png [1-4] 4 z1_002_v1.%d.png [1-4] 4 z1_002_v2.%d.png [9-12] """, result, ) class PerformanceTests(unittest.TestCase): """tests the performance of pyseq""" def test_performance_1(self): """tests performance for single 10k frame sequence""" 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) class TestIssues(unittest.TestCase): """tests reported issues on github""" def test_issue_60(self): """tests issue 60. with strict padding disabled, padding should be %d""" # disable strict padding pyseq.strict_pad = False items = [ "file.7.jpg", "file.8.jpg", "file.9.jpg", "file.10.jpg", "file.11.jpg", "file.12.jpg", "file.87.jpg", "file.99.jpg", "file.111.jpg", ] seq = pyseq.get_sequences(items)[0] self.assertEqual(len(items), len(seq)) self.assertEqual(seq._get_padding(), "%d") items = [ "file.7.jpg", "file.8.jpg", "file.9.jpg", "file.10.jpg", "file.11.jpg", "file.12.jpg", ] seq = pyseq.get_sequences(items)[0] self.assertEqual(len(items), len(seq)) self.assertEqual(seq._get_padding(), "%d") seq = pyseq.get_sequences( [ "file.1.jpg", "file.100.jpg", "file.101.jpg", ] )[0] self.assertEqual(len(seq), 3) self.assertEqual(seq._get_padding(), "%d") seq = pyseq.get_sequences( [ "file.10.jpg", "file.11.jpg", "file.12.jpg", ] )[0] self.assertEqual(len(seq), 3) self.assertEqual(seq._get_padding(), "%d") seq = pyseq.get_sequences( [ "file.100.jpg", "file.101.jpg", "file.102.jpg", ] )[0] self.assertEqual(len(seq), 3) self.assertEqual(seq._get_padding(), "%d") seq = pyseq.get_sequences( [ "file.9.jpg", "file.99.jpg", "file.999.jpg", "file.9999.jpg", ] )[0] self.assertEqual(len(seq), 4) self.assertEqual(seq._get_padding(), "%d") seq = pyseq.get_sequences( [ "file.007.jpg", "file.010.jpg", "file.101.jpg", ] )[0] self.assertEqual(len(seq), 3) self.assertEqual(seq._get_padding(), "%03d") # revert to strict pyseq.strict_pad = True seq = pyseq.get_sequences( [ "file.007.jpg", "file.010.jpg", "file.101.jpg", ] )[0] self.assertEqual(len(seq), 3) self.assertEqual(seq._get_padding(), "%03d") def test_issue_67(self): """tests issue 67. hangs on many of missing frames.""" def get_range(frames): return range(frames[0], frames[-1] + 1) # test with strict padding disabled pyseq.strict_pad = False # normal test case (missing frame count w/i tolerance) files = [ "image-1.jpg", "image-383.jpg", "image-844.jpg", "image-2500.jpg", "image-4529.jpg", "image-5000.jpg", ] seqs = get_sequences(files) self.assertEqual(len(seqs), 1) self.assertEqual(len(seqs[0]), len(files)) frames = seqs[0].frames() missing = seqs[0]._get_missing() self.assertEqual(len(frames), len(files)) self.assertEqual(len(missing), 5000 - len(files)) # high missing frame count test 1 files = ["image-1.jpg", "image-1000.jpg", "image-50000000.jpg"] seqs = get_sequences(files) self.assertEqual(len(seqs), 1) frames = seqs[0].frames() missing = seqs[0]._get_missing() self.assertEqual(len(frames), len(files)) r = get_range(frames) self.assertEqual(len(r), 50000000) self.assertTrue(len(missing), 2) # <-- diff behavior: len is num. ranges self.assertEqual(missing[0][0], 2) self.assertEqual(missing[0][-1], 999) self.assertEqual(missing[1][0], 1001) self.assertEqual(missing[1][-1], 49999999) self.assertEqual(seqs[0].format(), " 3 image-%d.jpg [1, 1000, 50000000]") self.assertEqual(seqs[0].format("%M"), "[2-999, 1001-49999999, ]") # test with strict padding enabled pyseq.strict_pad = True # high missing frame count test 2 files = ["image-100000000-2048x2048.jpg", "image-500000000-2048x2048.jpg"] seqs = get_sequences(files) self.assertEqual(len(seqs), 1) frames = seqs[0].frames() self.assertEqual(len(frames), 2) r = get_range(frames) self.assertEqual(len(r), 400000001) missing = seqs[0]._get_missing() 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("%M"), "[100000001-499999999, ]") # high missing frame count test 3 (from the issue) files = [ "file-1364769281-2048x2048.jpg", "file-530573048-2048x2048.jpg", "file-1127718214-2048x2048.jpg", "file-470543560-2048x2048.jpg", "file-155374807-2048x2048.jpg", "file-1182189546-2048x2048.jpg", "file-157742535-2048x2048.jpg", ] seqs = get_sequences(files) self.assertEqual(len(seqs), 2) # files with frame padding=10 self.assertEqual(seqs[0].frames(), [1127718214, 1182189546, 1364769281]) # frame ranges are in the millions frames = seqs[0].frames() r = get_range(frames) self.assertEqual(len(r), 237051068) frames = seqs[1].frames() r = get_range(frames) self.assertEqual(len(r), 375198242) # files with frame padding=9 self.assertEqual(seqs[1].frames(), [155374807, 157742535, 470543560, 530573048]) def test_issue_69(self): """tests issue 69. more strict padding tests.""" # padded frames padded = [ "file.08.jpg", "file.09.jpg", "file.10.jpg", "file.11.jpg", ] # unpadded frames unpadded = [ "file.8.jpg", "file.9.jpg", "file.10.jpg", "file.11.jpg", ] seqformat = "%4l %h%p%t %R" # test with strict padding enabled (default) # with strict pad enabled, the num of digits in each frame must match pyseq.strict_pad = True # test padded frames seqs = get_sequences(padded) self.assertEqual(len(seqs), 1) self.assertEqual(seqs[0].format(seqformat), " 4 file.%02d.jpg [8-11]") # test unpadded frames seqs = get_sequences(unpadded) self.assertEqual(len(seqs), 2) self.assertEqual(seqs[0].format(seqformat), " 2 file.%02d.jpg [10-11]") self.assertEqual(seqs[1].format(seqformat), " 2 file.%d.jpg [8-9]") # test uncompress with strict pad # finds frames 1-9 because num digits changes after 9 s = uncompress("file.1-150.jpg", fmt="%h%r%t") self.assertEqual(len(s), 9) # test with strict padding disabled # with strict pad disabled, the num of digits in each frame can vary pyseq.strict_pad = False # test padded frames seqs = get_sequences(padded) self.assertEqual(len(seqs), 1) self.assertEqual(seqs[0].format(seqformat), " 4 file.%02d.jpg [8-11]") # test unpadded frames seqs = get_sequences(unpadded) self.assertEqual(len(seqs), 1) self.assertEqual(seqs[0].format(seqformat), " 4 file.%d.jpg [8-11]") # test uncompress without strict pad, finds all 150 frames s = uncompress("file.1-150.jpg", fmt="%h%r%t") self.assertEqual(len(s), 150) def test_issue_83(self): """tests issue 83. externalize frame pattern.""" from pyseq import config filenames = [ "file_v001.jpg", "file_v002.jpg", "file_v003.jpg", "file_v004.jpg", ] # test using 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 item = Item("file_v005.jpg") self.assertTrue(seqs1[0].includes(item)) # test using custom frame pattern, different from first sequence seqs2 = pyseq.get_sequences(filenames, frame_pattern="_%d") # should have 4 sequences, with one file each self.assertEqual(len(seqs2), len(filenames)) def test_issue_89(self): """tests issue 89. contains() should ignore unrelated numbers.""" filenames = [ "s001_0030_1.jpg", "s001_0030_2.jpg", "s001_0090_1.jpg", "s001_0090_2.jpg", ] seqs = pyseq.get_sequences(filenames) self.assertEqual(len(seqs), 2) 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.""" # test sequence with whitespace sequence_path = "path/to/file/image ([1-2, 4]).png" sequence = pyseq.uncompress(sequence_path, fmt="%h%R%t") self.assertEqual(str(sequence), "image (1-4).png") self.assertEqual(len(sequence), 3) self.assertEqual( os.path.normpath(sequence[0].path), os.path.normpath(os.path.join("path", "to", "file", "image (1).png")), ) self.assertEqual( os.path.normpath(sequence[1].path), os.path.normpath(os.path.join("path", "to", "file", "image (2).png")), ) self.assertEqual( os.path.normpath(sequence[2].path), os.path.normpath(os.path.join("path", "to", "file", "image (4).png")), ) # test sequence with multiple spaces sequence_path = "other/path/file with spaces [10-40].png" sequence = pyseq.uncompress(sequence_path, fmt="%h%R%t") self.assertEqual(str(sequence), "file with spaces 10-40.png") self.assertEqual(len(sequence), 31) # test sequence with brackets sequence_path = "My file [v2].[1-2].jpg" sequence = pyseq.uncompress(sequence_path, fmt="%h%R%t") self.assertEqual(str(sequence), "My file [v2].1-2.jpg") self.assertEqual(len(sequence), 2) if __name__ == "__main__": unittest.main() pyseq-0.9.2/tests/test_scopy.py000066400000000000000000000114531516561675500166200ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the scopy module. """ import os import subprocess import tempfile import pytest import pyseq from conftest import get_installed_command from pyseq.scopy import copy_sequence scopy_bin = get_installed_command("scopy") @pytest.fixture def sample_sequence(tmp_path): """Create a dummy sequence: test.0001.exr through test.0003.exr""" filenames = [] for i in range(1, 4): name = f"test.{i:04d}.exr" path = tmp_path / name path.write_text("dummy frame") filenames.append(str(path.name)) # just filenames seq = pyseq.get_sequences(filenames)[0] return tmp_path, seq def test_copy_sequence_basic(sample_sequence): """Test the basic functionality of copy_sequence.""" src_dir, seq = sample_sequence with tempfile.TemporaryDirectory() as destdir: copy_sequence( seq=seq, src_dir=str(src_dir), dest_dir=destdir, rename=None, renumber=None, pad=None, force=False, dryrun=False, verbose=False, ) for i in range(1, 4): expected = os.path.join(destdir, f"test.{i:04d}.exr") assert os.path.exists(expected), f"Expected file not copied: {expected}" def test_scopy_cli(sample_sequence): """Test the command line interface for scopy.""" src_dir, _ = sample_sequence with tempfile.TemporaryDirectory() as destdir: pattern = os.path.join(str(src_dir), "test.%04d.exr") result = subprocess.run( [scopy_bin, pattern, destdir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr for i in range(1, 4): expected = os.path.join(destdir, f"test.{i:04d}.exr") assert os.path.exists(expected) def test_scopy_cli_explicit_sequence_string_source_and_dest(tmp_path): """Serialized sequence strings should resolve before copying.""" for i in range(1, 6): (tmp_path / f"shot.{i:04d}.exr").write_text("dummy frame") src = str(tmp_path / "shot.%04d.exr") + " 1-3" dest = str(tmp_path / "take.%04d.exr") + " 1001-1003" result = subprocess.run( [scopy_bin, src, dest], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr for i in range(1, 6): assert os.path.exists(tmp_path / f"shot.{i:04d}.exr") for i in range(1001, 1004): assert os.path.exists(tmp_path / f"take.{i:04d}.exr") def test_scopy_cli_embedded_range_source_and_dest(tmp_path): """Embedded range syntax should work for copy operations too.""" for i in range(1, 6): (tmp_path / f"plate.{i:04d}.rgb").write_text("dummy frame") src = str(tmp_path / "plate.2-4.rgb") dest = str(tmp_path / "beauty.20-22.rgb") result = subprocess.run( [scopy_bin, src, dest], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr for i in range(1, 6): 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") pyseq-0.9.2/tests/test_sdiff.py000066400000000000000000000100021516561675500165430ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the sdiff module. """ import json import os import subprocess import tempfile import pytest import pyseq from conftest import get_installed_command from pyseq.sdiff import diff_sequences sdiff_bin = get_installed_command("sdiff") @pytest.fixture def diff_test_sequences(): """Create two temporary directories with different sequences for testing.""" with tempfile.TemporaryDirectory() as dir1, tempfile.TemporaryDirectory() as dir2: # create: foo.0001-0003.exr for i in range(1, 4): path = os.path.join(dir1, f"foo.{i:04d}.exr") with open(path, "wb") as f: f.write(b"dummy A\n") # create: bar.0001-0002.exr (diff head + 1 missing frame) for i in range(1, 3): path = os.path.join(dir2, f"bar.{i:04d}.exr") with open(path, "wb") as f: f.write(b"dummy B\n") seq1 = pyseq.get_sequences(os.listdir(dir1))[0] seq2 = pyseq.get_sequences(os.listdir(dir2))[0] yield dir1, seq1, dir2, seq2 def test_diff_sequences(diff_test_sequences): """Test the diff_sequences function.""" _, seq1, _, seq2 = diff_test_sequences diff = diff_sequences(seq1, seq2) assert diff["head"][0] != diff["head"][1] assert diff["length"][0] == 3 assert diff["length"][1] == 2 # assert 3 in diff["missing"]["b_only"] def test_sdiff_cli_text(diff_test_sequences): """Test the sdiff CLI with text output.""" dir1, _, dir2, _ = diff_test_sequences pattern1 = os.path.join(dir1, "foo.%04d.exr") pattern2 = os.path.join(dir2, "bar.%04d.exr") result = subprocess.run( [sdiff_bin, pattern1, pattern2], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 assert "Head mismatch" in result.stdout assert "Length mismatch" in result.stdout def test_sdiff_cli_json(diff_test_sequences): """Test the sdiff CLI with JSON output.""" dir1, _, dir2, _ = diff_test_sequences pattern1 = os.path.join(dir1, "foo.%04d.exr") pattern2 = os.path.join(dir2, "bar.%04d.exr") result = subprocess.run( [sdiff_bin, "--json", pattern1, pattern2], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 data = json.loads(result.stdout) assert data["head"][0].startswith("foo") assert data["head"][1].startswith("bar") assert data["length"] == [3, 2] pyseq-0.9.2/tests/test_sfind.py000066400000000000000000000062441516561675500165700ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the sfind module. """ import os import subprocess import tempfile import pytest from conftest import get_installed_command sfind_bin = get_installed_command("sfind") @pytest.fixture def nested_sequence_tree(): """Create a temporary directory with a nested sequence tree for testing.""" with tempfile.TemporaryDirectory() as root: os.makedirs(os.path.join(root, "subdir1")) os.makedirs(os.path.join(root, "subdir2")) for i in range(1, 4): with open(os.path.join(root, f"shotA.{i:04d}.exr"), "w") as f: f.write("A") with open(os.path.join(root, "subdir1", f"shotB.{i:04d}.png"), "w") as f: f.write("B") with open(os.path.join(root, "subdir2", f"notes_{i}.txt"), "w") as f: f.write("notes") yield root def test_sfind_basic(nested_sequence_tree): """Test sfind with no arguments to find all files.""" result = subprocess.run( [sfind_bin, nested_sequence_tree], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 out = result.stdout assert "shotA.1-3.exr" in out assert "shotB.1-3.png" in out def test_sfind_filter(nested_sequence_tree): """Test sfind with a filter to only find PNG files.""" result = subprocess.run( [sfind_bin, nested_sequence_tree, "-name", "*.png"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 out = result.stdout assert "shotB.1-3.png" in out assert "shotA" not in out pyseq-0.9.2/tests/test_smove.py000066400000000000000000000207151516561675500166150ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the smv console command and smove module. """ import os import subprocess import tempfile import pytest import pyseq from conftest import get_installed_command from pyseq.smove import move_sequence smv_bin = get_installed_command("smv") @pytest.fixture def sample_sequence(): """Fixture to create a temporary directory with a sequence of files.""" with tempfile.TemporaryDirectory() as tmpdir: for i in range(1, 4): path = os.path.join(tmpdir, f"test.{i:04d}.exr") with open(path, "w") as f: f.write("dummy frame") seq = pyseq.get_sequences(os.listdir(tmpdir))[0] yield tmpdir, seq def test_move_sequence_basic(sample_sequence): """Test moving a sequence of files.""" src_dir, seq = sample_sequence with tempfile.TemporaryDirectory() as dest_dir: move_sequence( seq=seq, src_dir=src_dir, dest_dir=dest_dir, rename=None, renumber=None, pad=None, force=False, dryrun=False, verbose=False, ) # verify destination files exist for i in range(1, 4): new_path = os.path.join(dest_dir, f"test.{i:04d}.exr") assert os.path.exists(new_path) # verify original files are gone for i in range(1, 4): old_path = os.path.join(src_dir, f"test.{i:04d}.exr") assert not os.path.exists(old_path) def test_smv_cli(sample_sequence): """Test the command-line interface of smv.""" src_dir, _ = sample_sequence with tempfile.TemporaryDirectory() as dest_dir: pattern = os.path.join(src_dir, "test.%04d.exr") result = subprocess.run( [smv_bin, pattern, dest_dir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 for i in range(1, 4): new_path = os.path.join(dest_dir, f"test.{i:04d}.exr") assert os.path.exists(new_path) old_path = os.path.join(src_dir, f"test.{i:04d}.exr") assert not os.path.exists(old_path) def test_smv_cli_rename_sequence_pattern(sample_sequence): """smv should support mv-style sequence renames.""" src_dir, _ = sample_sequence src_pattern = os.path.join(src_dir, "test.%04d.exr") dest_pattern = os.path.join(src_dir, "renamed.%04d.exr") result = subprocess.run( [smv_bin, src_pattern, dest_pattern], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr for i in range(1, 4): assert os.path.exists(os.path.join(src_dir, f"renamed.{i:04d}.exr")) assert not os.path.exists(os.path.join(src_dir, f"test.{i:04d}.exr")) def test_smv_cli_creates_destination_directory(sample_sequence): """smv should create a destination directory when moving a sequence.""" src_dir, _ = sample_sequence parent_dir = tempfile.mkdtemp() try: dest_dir = os.path.join(parent_dir, "archive") pattern = os.path.join(src_dir, "test.%04d.exr") result = subprocess.run( [smv_bin, pattern, dest_dir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr for i in range(1, 4): assert os.path.exists(os.path.join(dest_dir, f"test.{i:04d}.exr")) assert not os.path.exists(os.path.join(src_dir, f"test.{i:04d}.exr")) finally: for root, dirs, files in os.walk(parent_dir, topdown=False): for name in files: os.remove(os.path.join(root, name)) for name in dirs: os.rmdir(os.path.join(root, name)) os.rmdir(parent_dir) def test_smv_cli_wildcard_source(sample_sequence): """Wildcard sources should resolve to a sequence before moving.""" src_dir, _ = sample_sequence dest_dir = tempfile.mkdtemp() try: wildcard = os.path.join(src_dir, "test.*.exr") result = subprocess.run( [smv_bin, wildcard, dest_dir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr for i in range(1, 4): assert os.path.exists(os.path.join(dest_dir, f"test.{i:04d}.exr")) finally: for root, dirs, files in os.walk(dest_dir, topdown=False): for name in files: os.remove(os.path.join(root, name)) for name in dirs: os.rmdir(os.path.join(root, name)) os.rmdir(dest_dir) def test_smv_cli_multiple_sources_require_directory(tmp_path): """Multiple sources should require a destination directory.""" for prefix in ("a", "b"): for i in range(1, 3): (tmp_path / f"{prefix}.{i:04d}.exr").write_text("dummy frame") src_a = str(tmp_path / "a.%04d.exr") src_b = str(tmp_path / "b.%04d.exr") dest_pattern = str(tmp_path / "renamed.%04d.exr") result = subprocess.run( [smv_bin, src_a, src_b, dest_pattern], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 1 assert "destination must be a directory" in result.stderr def test_smv_cli_explicit_sequence_string_source_and_dest(tmp_path): """Serialized sequence strings should resolve before moving.""" for i in range(1, 6): (tmp_path / f"shot.{i:04d}.exr").write_text("dummy frame") src = str(tmp_path / "shot.%04d.exr") + " 1-3" dest = str(tmp_path / "take.%04d.exr") + " 1001-1003" result = subprocess.run( [smv_bin, src, dest], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr for i in range(1001, 1004): assert os.path.exists(tmp_path / f"take.{i:04d}.exr") for i in range(1, 4): assert not os.path.exists(tmp_path / f"shot.{i:04d}.exr") for i in range(4, 6): assert os.path.exists(tmp_path / f"shot.{i:04d}.exr") def test_smv_cli_embedded_range_source_and_dest(tmp_path): """Embedded range syntax should resolve against on-disk padded sequences.""" for i in range(1, 6): (tmp_path / f"plate.{i:04d}.rgb").write_text("dummy frame") src = str(tmp_path / "plate.2-4.rgb") dest = str(tmp_path / "beauty.20-22.rgb") result = subprocess.run( [smv_bin, src, dest], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr for i in range(20, 23): assert os.path.exists(tmp_path / f"beauty.{i:04d}.rgb") assert os.path.exists(tmp_path / "plate.0001.rgb") 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") pyseq-0.9.2/tests/test_srm.py000066400000000000000000000116351516561675500162660ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the srm CLI and sremove module. """ import os import subprocess import tempfile import pytest import pyseq from conftest import get_installed_command from pyseq.sremove import remove_sequence srm_bin = get_installed_command("srm") @pytest.fixture def sample_sequence(): """Fixture to create a temporary directory with a sequence of files.""" with tempfile.TemporaryDirectory() as tmpdir: for i in range(1, 4): path = os.path.join(tmpdir, f"test.{i:04d}.exr") with open(path, "w") as f: f.write("dummy frame") seq = pyseq.get_sequences(os.listdir(tmpdir))[0] yield tmpdir, seq def test_remove_sequence_basic(sample_sequence): """Test removing a sequence of files.""" src_dir, seq = sample_sequence remove_sequence( seq=seq, src_dir=src_dir, force=False, dryrun=False, verbose=False, ) for i in range(1, 4): old_path = os.path.join(src_dir, f"test.{i:04d}.exr") assert not os.path.exists(old_path) def test_srm_cli(sample_sequence): """Test the command-line interface of srm.""" src_dir, _ = sample_sequence pattern = os.path.join(src_dir, "test.%04d.exr") result = subprocess.run( [srm_bin, pattern], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr for i in range(1, 4): old_path = os.path.join(src_dir, f"test.{i:04d}.exr") assert not os.path.exists(old_path) def test_srm_cli_dryrun(sample_sequence): """Dry-run should print planned removals without deleting files.""" src_dir, _ = sample_sequence pattern = os.path.join(src_dir, "test.%04d.exr") result = subprocess.run( [srm_bin, pattern, "--dryrun"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr assert "test.0001.exr" in result.stdout for i in range(1, 4): assert os.path.exists(os.path.join(src_dir, f"test.{i:04d}.exr")) def test_srm_cli_explicit_sequence_string(tmp_path): """Serialized sequence strings should resolve before removal.""" for i in range(1, 6): (tmp_path / f"shot.{i:04d}.exr").write_text("dummy frame") src = str(tmp_path / "shot.%04d.exr") + " 2-4" result = subprocess.run( [srm_bin, src], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr assert os.path.exists(tmp_path / "shot.0001.exr") assert os.path.exists(tmp_path / "shot.0005.exr") for i in range(2, 5): assert not os.path.exists(tmp_path / f"shot.{i:04d}.exr") def test_srm_cli_embedded_range(tmp_path): """Embedded range syntax should resolve against on-disk padded sequences.""" for i in range(1, 6): (tmp_path / f"plate.{i:04d}.rgb").write_text("dummy frame") result = subprocess.run( [srm_bin, str(tmp_path / "plate.2-4.rgb")], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0, result.stderr assert os.path.exists(tmp_path / "plate.0001.rgb") 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") pyseq-0.9.2/tests/test_sstat.py000066400000000000000000000076141516561675500166250ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the sstat module. """ import os import subprocess import tempfile import json import pytest import pyseq from conftest import get_installed_command from pyseq.sstat import json_sstat sstat_bin = get_installed_command("sstat") @pytest.fixture def stat_sequence(): """Fixture to create a temporary directory with a sequence of files.""" with tempfile.TemporaryDirectory() as tmpdir: for i in range(1, 4): with open(os.path.join(tmpdir, f"shotA.{i:04d}.exr"), "w") as f: f.write("data") seq = pyseq.get_sequences(os.path.join(tmpdir, "*"))[0] yield tmpdir, seq def test_json_sstat(stat_sequence): """Test the json_sstat function.""" _, seq = stat_sequence result = json_sstat(seq) assert result["sequence"].startswith("shotA.") assert result["length"] == 3 assert result["pad"] == 4 assert result["start"] == 1 assert result["end"] == 3 assert result["missing"] == [] assert "access" in result and "first" in result["access"] def test_sstat_cli_output(stat_sequence): """Test the sstat CLI output.""" tmpdir, _ = stat_sequence pattern = os.path.join(tmpdir, "shotA.%04d.exr") result = subprocess.run( [sstat_bin, pattern], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 assert "Sequence:" in result.stdout assert "Frames:" in result.stdout assert "Access:" in result.stdout def test_sstat_cli_json(stat_sequence): """Test the sstat CLI JSON output.""" tmpdir, _ = stat_sequence pattern = os.path.join(tmpdir, "shotA.%04d.exr") result = subprocess.run( [sstat_bin, "--json", pattern], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 data = json.loads(result.stdout) assert data["sequence"].startswith("shotA.") assert data["length"] == 3 assert "access" in data def test_sstat_missing_file(): """Test the sstat CLI with a missing file.""" pattern = "not_a_real_file.%04d.exr" result = subprocess.run( [sstat_bin, pattern], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) assert result.returncode != 0 assert "error" in result.stderr.lower() assert "not_a_real_file" in result.stderr pyseq-0.9.2/tests/test_stree.py000066400000000000000000000063631516561675500166110ustar00rootroot00000000000000#!/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. # __doc__ = """ Contains tests for the stree module. """ import os import subprocess import tempfile import pytest from conftest import get_installed_command stree_bin = get_installed_command("stree") @pytest.fixture def tree_fixture(): """Fixture to create a temporary directory with test files.""" with tempfile.TemporaryDirectory() as root: os.makedirs(os.path.join(root, "subdir")) # create foo.1-3.exr in root for i in range(1, 4): with open(os.path.join(root, f"foo.{i:04d}.exr"), "w") as f: f.write("frame\n") # create bar.1-2.exr in subdir for i in range(1, 3): with open(os.path.join(root, "subdir", f"bar.{i:04d}.exr"), "w") as f: f.write("frame\n") yield root def test_stree_output(tree_fixture): """Test stree output.""" result = subprocess.run( [stree_bin, tree_fixture], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 out = result.stdout assert "foo.1-3.exr" in out assert "bar.1-2.exr" in out assert "subdir" in out assert any(token in out for token in ("├──", "└──", "|--", "`--")) def test_stree_default_path(tree_fixture): """Test stree with no path argument, should use cwd.""" cwd = os.getcwd() try: os.chdir(tree_fixture) result = subprocess.run( [stree_bin], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) assert result.returncode == 0 out = result.stdout assert "foo.1-3.exr" in out assert "subdir" in out finally: os.chdir(cwd)