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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
CHANGELOG
=========

## 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`
Expand Down
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ PySeq comes with the following sequence-aware command-line tools:
| `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/` |
| `smove` | Move a sequence to another directory | `smove b.%04d.exr /tmp/archive/` |
| `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:

Expand All @@ -231,11 +232,23 @@ $ sdiff comp_A.%04d.exr comp_B.%04d.exr
$ sstat render.%04d.exr
$ sstat --json render.%04d.exr

# Copy a sequence and rename it
$ scopy input.%04d.exr output/ --rename scene01
# 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
$ smove old.%04d.exr archive/ --renumber 1001
$ smv old.%04d.exr archive/ --renumber 1001
```

## Frame Patterns
Expand Down
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion lib/pyseq/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@
"""

__author__ = "Ryan Galloway"
__version__ = "0.9.1"
__version__ = "0.9.2"

from .seq import *
66 changes: 30 additions & 36 deletions lib/pyseq/scopy.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,13 @@
import os
import argparse
import shutil
import fnmatch
from typing import Optional

import pyseq
from pyseq.util import (
cli_catch_keyboard_interrupt,
is_compressed_format_string,
resolve_sequence,
parse_destination_reference,
resolve_sequence_reference,
)


Expand Down Expand Up @@ -100,20 +99,12 @@ def main():
"""Main function to parse cli args and copy sequences."""

parser = argparse.ArgumentParser(
description="Copy image sequences with renaming/renumbering support",
description="Copy image sequences with destination-based renaming and renumbering support",
)
parser.add_argument(
"sources",
"paths",
nargs="+",
help="Source sequences (wildcards or compressed format strings)",
)
parser.add_argument(
"dest",
help="Destination directory",
)
parser.add_argument(
"--rename",
help="Rename sequence basename",
help="Source sequence(s) followed by a destination directory or sequence pattern",
)
parser.add_argument(
"--renumber",
Expand Down Expand Up @@ -145,35 +136,38 @@ def main():
)
args = parser.parse_args()

if not os.path.isdir(args.dest):
print(f"Error: destination {args.dest} is not a directory", file=sys.stderr)
if len(args.paths) < 2:
print("Error: expected at least one source and a destination", file=sys.stderr)
return 1

for source in args.sources:
sources = args.paths[:-1]
dest = args.paths[-1]

for source in sources:
try:
if is_compressed_format_string(source):
seq = resolve_sequence(source)
dirname = os.path.dirname(source) or "."
else:
# treat as glob
dirname = os.path.dirname(source) or "."
basename = os.path.basename(source)
matches = [
f for f in os.listdir(dirname) if fnmatch.fnmatchcase(f, basename)
]
sequences = pyseq.get_sequences(matches)
if not sequences:
print(f"No sequence found matching {source}", file=sys.stderr)
continue
seq = sequences[0]
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,
args.dest,
rename=args.rename,
renumber=args.renumber,
pad=args.pad,
dest_spec["dest_dir"],
rename=rename,
renumber=renumber,
pad=pad,
force=args.force,
dryrun=args.dryrun,
verbose=args.verbose,
Expand Down
67 changes: 31 additions & 36 deletions lib/pyseq/smove.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,13 @@
import os
import argparse
import shutil
import fnmatch
from typing import Optional

import pyseq
from pyseq.util import (
cli_catch_keyboard_interrupt,
is_compressed_format_string,
resolve_sequence,
parse_destination_reference,
resolve_sequence_reference,
)


Expand Down Expand Up @@ -100,20 +99,12 @@ def main():
"""Main function to handle command line arguments and call the move_sequence."""

parser = argparse.ArgumentParser(
description="Move image sequences with renaming/renumbering support",
description="Move image sequences with destination-based renaming and renumbering support",
)
parser.add_argument(
"sources",
"paths",
nargs="+",
help="Source sequences (wildcards or compressed format strings)",
)
parser.add_argument(
"dest",
help="Destination directory",
)
parser.add_argument(
"--rename",
help="Rename sequence basename",
help="Source sequence(s) followed by a destination directory or sequence pattern",
)
parser.add_argument(
"--renumber",
Expand Down Expand Up @@ -145,35 +136,39 @@ def main():
)
args = parser.parse_args()

if not os.path.isdir(args.dest):
print(f"Error: destination {args.dest} is not a directory", file=sys.stderr)
if len(args.paths) < 2:
print("Error: expected at least one source and a destination", file=sys.stderr)
return 1

for source in args.sources:
sources = args.paths[:-1]
dest = args.paths[-1]

for source in sources:
try:
if is_compressed_format_string(source):
seq = resolve_sequence(source)
dirname = os.path.dirname(source) or "."
else:
# treat as glob
dirname = os.path.dirname(source) or "."
basename = os.path.basename(source)
matches = [
f for f in os.listdir(dirname) if fnmatch.fnmatchcase(f, basename)
]
sequences = pyseq.get_sequences(matches)
if not sequences:
print(f"No sequence found matching {source}", file=sys.stderr)
continue
seq = sequences[0]
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,
args.dest,
rename=args.rename,
renumber=args.renumber,
pad=args.pad,
dest_dir,
rename=rename,
renumber=renumber,
pad=pad,
force=args.force,
dryrun=args.dryrun,
verbose=args.verbose,
Expand Down
117 changes: 117 additions & 0 deletions lib/pyseq/sremove.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/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())
Loading
Loading