diff --git a/.gitignore b/.gitignore index 91f889d..fdf5aa5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ /target /vendor .idea +/.env +__pycache__ .cargo *.tar.zst diff --git a/Cargo.lock b/Cargo.lock index df5f2d8..466beb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,6 +360,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -608,6 +614,7 @@ dependencies = [ "md-5", "openssl", "polonius-the-crab", + "pyo3", "sha1", "simple_moving_average", "thiserror", @@ -789,6 +796,64 @@ dependencies = [ "unicase", ] +[[package]] +name = "pyo3" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -956,6 +1021,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "thiserror" version = "2.0.18" diff --git a/Justfile b/Justfile index 5255cc2..d406278 100644 --- a/Justfile +++ b/Justfile @@ -1,3 +1,11 @@ # Generate the nod-ffi C header cbindgen: cbindgen --config nod-ffi/cbindgen.toml --crate nod-ffi --output nod-ffi/include/nod.h + +# Build and install the nod Python bindings (dev mode) +py-dev: + cd nod && uv pip install -e . + +# Build and install the nod Python bindings (release mode) +py-release: + cd nod && uv pip install -e . --config-settings build-args='--release' diff --git a/nod/Cargo.toml b/nod/Cargo.toml index cf6ff42..1744277 100644 --- a/nod/Cargo.toml +++ b/nod/Cargo.toml @@ -36,6 +36,7 @@ compress-zstd-vendored = ["compress-zstd", "dep:zstd-sys"] compress-zstd-static = ["compress-zstd-vendored"] openssl = ["dep:openssl"] openssl-vendored = ["openssl", "openssl/vendored"] +python = ["dep:pyo3", "compress-bzip2-vendored", "compress-lzma-vendored", "compress-zlib-vendored", "compress-zstd-vendored", "threading"] threading = ["dep:crossbeam-channel", "dep:crossbeam-utils"] [dependencies] @@ -60,6 +61,7 @@ md-5 = { workspace = true } libz-sys = { version = "1.1", optional = true } openssl = { version = "0.10", optional = true } polonius-the-crab = "0.5" +pyo3 = { version = "0.28", features = ["abi3-py310", "extension-module"], optional = true } sha1 = { workspace = true } simple_moving_average = "1.0" thiserror = "2.0" diff --git a/nod/nod_rs.pyi b/nod/nod_rs.pyi new file mode 100644 index 0000000..2dfe9c3 --- /dev/null +++ b/nod/nod_rs.pyi @@ -0,0 +1,417 @@ +from __future__ import annotations + +from collections.abc import Callable +from types import TracebackType + +class DiscHeader: + """Primary disc header (boot.bin offset 0x000).""" + + game_id: str + """Six-character game ID, e.g. ``"GM8E01"``.""" + game_title: str + """Game title string, e.g. ``"Metroid Prime"``.""" + disc_num: int + """Disc number (0-based).""" + disc_version: int + """Disc revision number.""" + audio_streaming: int + audio_stream_buf_size: int + is_wii: bool + """``True`` for Wii discs.""" + is_gamecube: bool + """``True`` for GameCube discs.""" + + def __repr__(self) -> str: ... + +class DiscMeta: + """Extra metadata supplied by the disc file format (not the disc itself).""" + + format: str + """File format name: ``"ISO"``, ``"CISO"``, ``"GCZ"``, ``"NFS"``, ``"RVZ"``, + ``"WBFS"``, ``"WIA"``, or ``"TGC"``.""" + compression: str + """Compression algorithm description, e.g. ``"Zstandard (19)"`` or ``"None"``.""" + block_size: int | None + """Block size in bytes for block-based formats, ``None`` otherwise.""" + decrypted: bool + """Whether Wii partition data is stored decrypted in this format.""" + needs_hash_recovery: bool + """Whether Wii partition hashes are omitted and need to be rebuilt.""" + lossless: bool + """Whether the original disc data can be recovered without loss.""" + disc_size: int | None + """Original disc size in bytes if stored by the format, ``None`` otherwise.""" + crc32: int | None + """CRC-32 checksum of the original disc, if stored by the format.""" + xxh64: int | None + """XXH64 checksum of the original disc, if stored by the format.""" + + def __repr__(self) -> str: ... + +class PartitionInfo: + """Describes a single Wii partition (not present on GameCube discs).""" + + index: int + """Zero-based partition index.""" + kind: str + """Partition kind: ``"Data"``, ``"Update"``, ``"Channel"``, or ``"Other (…)"``.""" + + def __repr__(self) -> str: ... + +class FileReader: + """Lazy, seekable binary file reader backed by a disc partition. + + Returned by :meth:`PartitionReader.read_file`. Data is read from the + source disc on demand — nothing is buffered until you call :meth:`read`. + + Implements the standard binary-IO interface and can be used as a context + manager:: + + with partition.read_file(node) as f: + header = f.read(4) + f.seek(0) + all_data = f.read() + """ + + def read(self, size: int = -1) -> bytes: + """Read and return up to *size* bytes. + + If *size* is ``-1`` (default), reads until end of file. + """ + + def seek(self, pos: int, whence: int = 0) -> int: + """Seek to *pos* bytes relative to *whence*. + + *whence*: ``0`` = start (default), ``1`` = current position, + ``2`` = end of file. Returns the new absolute position. + """ + + def tell(self) -> int: + """Return the current stream position.""" + + def size(self) -> int: + """Return the total file size in bytes.""" + + def readable(self) -> bool: ... + def seekable(self) -> bool: ... + def writable(self) -> bool: ... + @property + def closed(self) -> bool: ... + def close(self) -> None: ... + def __enter__(self) -> FileReader: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: ... + def __repr__(self) -> str: ... + +class FstNode: + """A single entry in the file system table. + + Returned by :meth:`Fst.find` and iteration over :class:`Fst`. + Pass to :meth:`PartitionReader.read_file` to read file contents. + """ + + name: str + """Filename component (last path segment).""" + path: str + """Full path from the partition root, segments separated by ``/``.""" + is_file: bool + is_dir: bool + length: int + """For files: byte size. For directories: child-end index in the FST.""" + fst_index: int + """Index of this node in the raw FST node array.""" + + def __repr__(self) -> str: ... + +class FstIter: + """Iterator returned by :meth:`Fst.__iter__`.""" + + def __iter__(self) -> FstIter: ... + def __next__(self) -> FstNode: ... + +class Fst: + """View of the file system table for an open partition.""" + + def find(self, path: str) -> FstNode | None: + """Find a file or directory by path (case-insensitive). + + The leading ``/`` is optional. Returns ``None`` if not found. + """ + + def __iter__(self) -> FstIter: + """Iterate over every file and directory in depth-first order.""" + + def __repr__(self) -> str: ... + +class PartitionMeta: + """Raw binary partition data (headers, DOL, FST, etc.).""" + + raw_boot: bytes + """Disc and boot header — ``boot.bin`` (0x440 bytes).""" + raw_bi2: bytes + """Debug and region information — ``bi2.bin`` (0x2000 bytes).""" + raw_apploader: bytes + """Apploader binary — ``apploader.bin``.""" + raw_dol: bytes + """Main executable — ``main.dol``.""" + raw_fst: bytes + """Raw file system table — ``fst.bin``.""" + raw_ticket: bytes | None + """Wii ticket — ``ticket.bin``. ``None`` for GameCube.""" + raw_tmd: bytes | None + """Wii title metadata — ``tmd.bin``. ``None`` for GameCube.""" + raw_cert_chain: bytes | None + """Wii certificate chain — ``cert.bin``. ``None`` for GameCube.""" + raw_h3_table: bytes | None + """Wii H3 hash table — ``h3.bin``. ``None`` for GameCube.""" + + def fst(self) -> Fst: + """Parse and return the file system table.""" + + def disc_header(self) -> DiscHeader: + """Parse the disc header from ``raw_boot``.""" + + def __repr__(self) -> str: ... + +class PartitionReader: + """Read stream for an open disc partition.""" + + def is_wii(self) -> bool: + """Returns ``True`` for Wii partitions, ``False`` for GameCube.""" + + def meta(self) -> PartitionMeta: + """Read the partition header and file system metadata.""" + + def read_file(self, node: FstNode) -> FileReader: + """Open a file identified by *node* for lazy on-demand reading. + + Returns a :class:`FileReader` positioned at the start of the file. + Data is read from the source disc only when you call + :meth:`FileReader.read`. + + Raises :exc:`IsADirectoryError` if *node* is a directory. + """ + + def __repr__(self) -> str: ... + +class DiscReader: + """Reader for a GameCube or Wii disc image. + + Supports ISO, CISO, GCZ, NFS, RVZ, WBFS, WIA, and TGC formats. + + Raises :exc:`FileNotFoundError` if the file does not exist. + Raises :exc:`OSError` if the file cannot be opened or the format is not recognised. + + Example:: + + import nod_rs + + disc = nod_rs.DiscReader("game.iso") + partition = disc.open_partition_kind("Data") + meta = partition.meta() + fst = meta.fst() + node = fst.find("/MP3/Worlds.txt") + if node: + data = partition.read_file(node) + """ + + def __init__(self, path: str) -> None: ... + def header(self) -> DiscHeader: + """Return the disc's primary header.""" + + def meta(self) -> DiscMeta: + """Return metadata about the underlying file format.""" + + def disc_size(self) -> int: + """Return the disc size in bytes.""" + + def partitions(self) -> list[PartitionInfo]: + """Return Wii partition list. Always empty for GameCube discs.""" + + def open_partition( + self, + index: int, + validate_hashes: bool = False, + ) -> PartitionReader: + """Open a partition by index. For GameCube discs *index* must be ``0``.""" + + def open_partition_kind( + self, + kind: str = "Data", + validate_hashes: bool = False, + ) -> PartitionReader: + """Open the first partition matching *kind*. + + *kind* must be one of ``"Data"``, ``"Update"``, or ``"Channel"``. + For GameCube discs use ``"Data"``. + + Raises :exc:`ValueError` for unknown kind strings. + """ + + def __repr__(self) -> str: ... + +class DiscFinalization: + """Checksums and header data produced after :meth:`DiscWriter.process` completes.""" + + crc32: int | None + """CRC-32 checksum of the input disc data, if requested.""" + xxh64: int | None + """XXH64 checksum of the input disc data, if requested.""" + + @property + def md5(self) -> bytes | None: + """MD5 hash of the input disc data (16 bytes), or ``None`` if not requested.""" + + @property + def sha1(self) -> bytes | None: + """ + SHA-1 hash of the input disc data (20 bytes), or ``None`` if not requested. + """ + + @property + def header(self) -> bytes: + """ + Header bytes that were written to the start of the output file (may be empty). + """ + + def __repr__(self) -> str: ... + +class DiscWriter: + """Writer for converting a disc image to a different format. + + Construct by passing a :class:`DiscReader` and target format:: + + disc = nod_rs.DiscReader("game.rvz") + writer = nod_rs.DiscWriter(disc, "ISO") + fin = writer.process("output.iso", digest_crc32=True) + print(f"CRC32: {fin.crc32:#010x}") + """ + + def __init__( + self, + disc: DiscReader, + format: str, + compression: str | None = None, + block_size: int = 0, + ) -> None: + """Create a writer targeting *format*. + + *format* is one of ``"ISO"``, ``"CISO"``, ``"GCZ"``, ``"RVZ"``, + ``"WBFS"``, ``"WIA"``, ``"TGC"``. + + *compression* follows the pattern ``"Algorithm"`` or ``"Algorithm:level"``, + e.g. ``"Zstandard:19"``, ``"Lzma2:6"``, ``"None"``. + Defaults to the recommended compression for *format*. + + *block_size* defaults to the recommended block size for *format* + (``0`` = use default). + """ + + def progress_bound(self) -> int: + """ + Upper bound for the *progress* value passed to the :meth:`process` callback. + """ + + def process( + self, + output_path: str, + *, + callback: Callable[[int, int], None] | None = None, + digest_crc32: bool = False, + digest_md5: bool = False, + digest_sha1: bool = False, + digest_xxh64: bool = False, + scrub_update_partition: bool = False, + ) -> DiscFinalization: + """Convert and write the disc image to *output_path*. + + An optional *callback* ``(progress: int, total: int) -> None`` is called after + each chunk is written and can be used to display progress. + + Set ``digest_*`` flags to compute and return the corresponding checksums. + + Set *scrub_update_partition* to replace the Wii update partition with zeroes + (supported for WBFS and CISO only). + + Raises :exc:`OSError` if the output file cannot be created or written. + """ + + def __repr__(self) -> str: ... + +class DiscPatcher: + """Patches or extends a GameCube disc by adding or replacing files. + + The result of :meth:`build` is a :class:`DiscReader` that can be passed + directly to :class:`DiscWriter` for conversion to any supported output format. + + Only GameCube discs are supported. + + Example:: + + disc = nod_rs.DiscReader("original.iso") + patcher = nod_rs.DiscPatcher(disc) + with open("new_audio.dsp", "rb") as f: + patcher.add_file("files/audio/bgm.dsp", f.read()) + patched = patcher.build() + nod_rs.DiscWriter(patched, "ISO").process("patched.iso") + """ + + def __init__(self, disc: DiscReader) -> None: + """Create a patcher for *disc*. + + Raises :exc:`ValueError` if *disc* is a Wii disc. + """ + + def set_dol(self, data: bytes) -> None: + """Replace the main executable (DOL) in the patched disc. + + *data* must be a valid DOL binary. Calling this a second time + replaces the previous override. + """ + + def add_file(self, path: str, data: bytes) -> None: + """Add a new file or replace an existing one. + + *path* is the FST path (e.g. ``"files/audio/bgm.dsp"``). Leading + slashes are stripped. Calling this again with the same path replaces + the previous data. + + Raises :exc:`ValueError` if *path* starts with ``"sys/"``. + """ + + def set_header( + self, + *, + game_id: str | None = None, + game_title: str | None = None, + disc_num: int | None = None, + disc_version: int | None = None, + audio_streaming: bool | None = None, + audio_stream_buf_size: int | None = None, + ) -> None: + """Override disc header fields in the patched disc. + + All parameters are keyword-only and optional; only those provided + are changed. + + Raises :exc:`ValueError` if *game_id* is not exactly 6 characters. + """ + + def build(self) -> DiscReader: + """Build a patched :class:`DiscReader` with all overrides applied. + + Reads all files from the source disc into memory, substituting any + overrides added via :meth:`add_file`, then returns a new + :class:`DiscReader` ready for :class:`DiscWriter`. + + New files (paths not present in the original FST) are appended. + + Raises :exc:`OSError` if the source disc cannot be read. + Raises :exc:`RuntimeError` if the disc layout is invalid. + """ + + def __repr__(self) -> str: ... diff --git a/nod/pyproject.toml b/nod/pyproject.toml new file mode 100644 index 0000000..efc9356 --- /dev/null +++ b/nod/pyproject.toml @@ -0,0 +1,80 @@ +[build-system] +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" + +[project] +name = "nod-rs" +description = "Python bindings for the nod GameCube/Wii disc image library" +authors = [ + { name = "Luke Street", email = "luke@street.dev" }, +] +license = "MIT OR Apache-2.0" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Rust", +] +requires-python = ">=3.10" +dependencies = [] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/encounter/nod" + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "ruff>=0.15.9", + "ty>=0.0.29", +] + +[tool.pytest.ini_options] +testpaths = ["python_tests"] + +[tool.maturin] +features = ["python"] +python-versions = ">=3.10" +module-name = "nod_rs" + + +[tool.ruff.lint] +extend-select = [ + "E", # PyCodeStyle Errors + "F", # Pyflakes (Errors/Warnings) + "W", # PyCodeStyle Warnings + "I", # Import Sorting (isort) + "UP", # Python Upgrades (pyupgrade) + "C4", # Comprehensions (flake8-comprehensions) + "C90", # complexity (mccabe) + "RSE", # flake8-raise (RSE) + "PTH", # use pathlib + "COM818", "COM819", # trailing comma + "ISC", # flake8-implicit-str-concat + "PIE", # flake8-pie (misc checks) + "PT", # common style issues or inconsistencies with pytest-based tests (flake8-pytest-style) + "SIM101", # Multiple isinstance calls for {name}, merge into a single call + "RUF", # Ruff-specific rules + + "PLC", # pylint convention + "PLC0208", # iterating over set is also not deterministic, so we shouldn't do that! + "PLE", # pylint error + "PLW", # pylint warning + + # pylint refactor + "PLR0402", # Use from {module} import {name} in lieu of alias + "PLR1711", # Useless return statement at end of function + "PLR1722", # Use sys.exit() instead of {name} + "PLR0206", # Cannot have defined parameters for properties + "PLR0133", # Two constants compared in a comparison, consider replacing {left_constant} {op} {right_constant} + "PLR0124", # Name compared with itself, consider replacing {actual} + + # type hints + "ANN001", # missing type hint for function argument +] +ignore = [ + "ISC001", # may cause conflicts with formatter + "PLW0603", # we use some globals here and there + "PLW2901", # TODO: should actually fix these + "PLC0415", # we explicitly use the import within a function to postpone the loading of a module + +] diff --git a/nod/python_tests/conftest.py b/nod/python_tests/conftest.py new file mode 100644 index 0000000..bab7828 --- /dev/null +++ b/nod/python_tests/conftest.py @@ -0,0 +1,47 @@ +import os + +import pytest + +import nod_rs as nod + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", "requires_disc: test requires GC_ISO_PATH to be set" + ) + + +@pytest.fixture(scope="session") +def iso_path() -> str: + path = os.environ.get("GC_ISO_PATH") + if not path: + pytest.skip("GC_ISO_PATH not set") + return path + + +@pytest.fixture(scope="session") +def disc(iso_path: str) -> nod.DiscReader: + return nod.DiscReader(iso_path) + + +@pytest.fixture(scope="session") +def wii_disc() -> nod.DiscReader: + path = os.environ.get("WII_ISO_PATH") + if not path: + pytest.skip("WII_ISO_PATH not set") + return nod.DiscReader(path) + + +@pytest.fixture(scope="session") +def partition(disc: nod.DiscReader) -> nod.PartitionReader: + return disc.open_partition_kind("Data") + + +@pytest.fixture(scope="session") +def partition_meta(partition: nod.PartitionReader) -> nod.PartitionMeta: + return partition.meta() + + +@pytest.fixture(scope="session") +def fst(partition_meta: nod.PartitionMeta) -> nod.Fst: + return partition_meta.fst() diff --git a/nod/python_tests/test_disc.py b/nod/python_tests/test_disc.py new file mode 100644 index 0000000..a952100 --- /dev/null +++ b/nod/python_tests/test_disc.py @@ -0,0 +1,70 @@ +import pytest + +import nod_rs as nod + + +def test_open_nonexistent(): + with pytest.raises(FileNotFoundError): + nod.DiscReader("nonexistent_file.iso") + + +class TestDiscReader: + def test_header_game_id(self, disc: nod.DiscReader): + header = disc.header() + assert len(header.game_id) == 6 + assert header.game_id.isascii() + + def test_header_game_title(self, disc: nod.DiscReader): + header = disc.header() + assert len(header.game_title) > 0 + + def test_header_disc_type(self, disc: nod.DiscReader): + header = disc.header() + assert header.is_gamecube != header.is_wii + + def test_header_disc_num(self, disc: nod.DiscReader): + header = disc.header() + assert header.disc_num == 0 + + def test_header_repr(self, disc: nod.DiscReader): + header = disc.header() + r = repr(header) + assert "DiscHeader" in r + assert header.game_id in r + + def test_meta_format(self, disc: nod.DiscReader): + meta = disc.meta() + assert meta.format in {"ISO", "CISO", "GCZ", "NFS", "RVZ", "WBFS", "WIA", "TGC"} + + def test_meta_compression(self, disc: nod.DiscReader): + meta = disc.meta() + assert isinstance(meta.compression, str) + assert len(meta.compression) > 0 + + def test_meta_flags(self, disc: nod.DiscReader): + meta = disc.meta() + assert isinstance(meta.decrypted, bool) + assert isinstance(meta.lossless, bool) + assert isinstance(meta.needs_hash_recovery, bool) + + def test_disc_size(self, disc: nod.DiscReader): + assert disc.disc_size() > 0 + + def test_partitions_gamecube(self, disc: nod.DiscReader): + # GameCube discs have no Wii partitions + header = disc.header() + partitions = disc.partitions() + if header.is_gamecube: + assert partitions == [] + + def test_open_partition_by_index(self, disc: nod.DiscReader): + p = disc.open_partition(0) + assert p is not None + + def test_open_partition_by_kind(self, disc: nod.DiscReader): + p = disc.open_partition_kind("Data") + assert p is not None + + def test_open_partition_invalid_kind(self, disc: nod.DiscReader): + with pytest.raises(ValueError, match="Unknown partition kind"): + disc.open_partition_kind("Invalid") diff --git a/nod/python_tests/test_partition.py b/nod/python_tests/test_partition.py new file mode 100644 index 0000000..c2c0735 --- /dev/null +++ b/nod/python_tests/test_partition.py @@ -0,0 +1,307 @@ +import pytest + +import nod_rs as nod + + +class TestPartitionReader: + def test_is_wii(self, disc: nod.DiscReader, partition: nod.PartitionReader): + header = disc.header() + assert partition.is_wii() == header.is_wii + + def test_repr(self, partition: nod.PartitionReader): + r = repr(partition) + assert "PartitionReader" in r + assert "is_wii" in r + + def test_meta_returns_object(self, partition_meta: nod.PartitionMeta): + assert partition_meta is not None + + def test_meta_repr(self, partition_meta: nod.PartitionMeta): + assert "PartitionMeta" in repr(partition_meta) + + def test_meta_disc_header(self, partition_meta: nod.PartitionMeta): + header = partition_meta.disc_header() + assert isinstance(header, nod.DiscHeader) + assert len(header.game_id) == 6 + + def test_meta_raw_boot(self, partition_meta: nod.PartitionMeta): + raw = partition_meta.raw_boot + assert isinstance(raw, bytes) + assert len(raw) == 0x440 # BOOT_SIZE + + def test_meta_raw_bi2(self, partition_meta: nod.PartitionMeta): + raw = partition_meta.raw_bi2 + assert isinstance(raw, bytes) + assert len(raw) == 0x2000 # BI2_SIZE + + def test_meta_raw_apploader(self, partition_meta: nod.PartitionMeta): + raw = partition_meta.raw_apploader + assert isinstance(raw, bytes) + assert len(raw) > 0 + + def test_meta_raw_dol(self, partition_meta: nod.PartitionMeta): + raw = partition_meta.raw_dol + assert isinstance(raw, bytes) + assert len(raw) > 0 + + def test_meta_raw_fst(self, partition_meta: nod.PartitionMeta): + raw = partition_meta.raw_fst + assert isinstance(raw, bytes) + assert len(raw) > 0 + + def test_meta_wii_fields_absent_for_gamecube( + self, disc: nod.DiscReader, partition_meta: nod.PartitionMeta + ): + if disc.header().is_gamecube: + assert partition_meta.raw_ticket is None + assert partition_meta.raw_tmd is None + assert partition_meta.raw_cert_chain is None + assert partition_meta.raw_h3_table is None + + +class TestFst: + def test_fst_iter_yields_nodes(self, fst: nod.Fst): + nodes = list(fst) + assert len(nodes) > 0 + + def test_fst_iter_node_fields(self, fst: nod.Fst): + for node in fst: + assert isinstance(node.name, str) + assert isinstance(node.path, str) + assert isinstance(node.is_file, bool) + assert isinstance(node.is_dir, bool) + assert isinstance(node.length, int) + assert isinstance(node.fst_index, int) + assert node.is_file != node.is_dir + assert node.length >= 0 + + def test_fst_iter_paths_non_empty(self, fst: nod.Fst): + for node in fst: + assert len(node.path) > 0 + + def test_fst_iter_file_length_positive(self, fst: nod.Fst): + for node in fst: + if node.is_file: + assert node.length > 0 + + def test_fst_find_missing(self, fst: nod.Fst): + assert fst.find("/this/path/does/not/exist") is None + + def test_fst_find_case_insensitive(self, fst: nod.Fst): + # Collect all file paths from iteration + nodes = list(fst) + if not nodes: + pytest.skip("FST is empty") + first_file = next((n for n in nodes if n.is_file), None) + if first_file is None: + pytest.skip("No files in FST") + # Searching with different case should still find it + upper = "/" + first_file.path.upper() + lower = "/" + first_file.path.lower() + assert fst.find(upper) is not None + assert fst.find(lower) is not None + + def test_fst_node_repr(self, fst: nod.Fst): + for node in fst: + r = repr(node) + assert "FstNode" in r + assert node.path in r + break + + def test_read_small_file(self, partition: nod.PartitionReader, fst: nod.Fst): + # Find the smallest file in the FST and read it + files = sorted( + (n for n in fst if n.is_file and n.length > 0), + key=lambda n: n.length, + ) + if not files: + pytest.skip("No readable files in FST") + node = files[0] + f = partition.read_file(node) + assert isinstance(f, nod.FileReader) + data = f.read() + assert isinstance(data, bytes) + assert len(data) == node.length + + def test_read_file_correct_size(self, partition: nod.PartitionReader, fst: nod.Fst): + # Verify a handful of files have the expected size + checked = 0 + for node in fst: + if node.is_file and node.length > 0: + data = partition.read_file(node).read() + assert len(data) == node.length, ( + f"{node.path}: expected {node.length} bytes, got {len(data)}" + ) + checked += 1 + if checked >= 5: + break + + def test_read_file_on_directory_raises( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + dir_node = next((n for n in fst if n.is_dir), None) + if dir_node is None: + pytest.skip("No directories in FST") + with pytest.raises(IsADirectoryError): + partition.read_file(dir_node) + + +# --------------------------------------------------------------------------- +# FileReader — seeking and partial reads +# --------------------------------------------------------------------------- + + +def _file_with_min_size(fst: nod.Fst, min_size: int) -> nod.FstNode: + """Return the smallest file that is at least *min_size* bytes, or skip.""" + candidates = sorted( + (n for n in fst if n.is_file and n.length >= min_size), + key=lambda n: n.length, + ) + if not candidates: + pytest.skip(f"No file with at least {min_size} bytes in FST") + return candidates[0] + + +class TestFileReader: + def test_size_matches_fst_node(self, partition: nod.PartitionReader, fst: nod.Fst): + node = _file_with_min_size(fst, 1) + f = partition.read_file(node) + assert f.size() == node.length + + def test_tell_starts_at_zero(self, partition: nod.PartitionReader, fst: nod.Fst): + node = _file_with_min_size(fst, 1) + f = partition.read_file(node) + assert f.tell() == 0 + + def test_tell_advances_after_read( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + node = _file_with_min_size(fst, 4) + f = partition.read_file(node) + f.read(4) + assert f.tell() == 4 + + def test_read_all_matches_size(self, partition: nod.PartitionReader, fst: nod.Fst): + node = _file_with_min_size(fst, 1) + data = partition.read_file(node).read() + assert len(data) == node.length + + def test_read_zero_bytes(self, partition: nod.PartitionReader, fst: nod.Fst): + node = _file_with_min_size(fst, 1) + data = partition.read_file(node).read(0) + assert data == b"" + + def test_partial_read_returns_correct_count( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + node = _file_with_min_size(fst, 8) + f = partition.read_file(node) + chunk = f.read(4) + assert len(chunk) == 4 + + def test_two_partial_reads_concatenate_to_full( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + node = _file_with_min_size(fst, 8) + f = partition.read_file(node) + half = node.length // 2 + a = f.read(half) + b = f.read() + full = partition.read_file(node).read() + assert a + b == full + + def test_read_past_eof_returns_remaining( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + node = _file_with_min_size(fst, 1) + f = partition.read_file(node) + data = f.read(node.length + 1024) + assert len(data) == node.length + + def test_read_after_eof_returns_empty( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + node = _file_with_min_size(fst, 1) + f = partition.read_file(node) + f.read() + assert f.read() == b"" + assert f.read(16) == b"" + + def test_seek_from_start(self, partition: nod.PartitionReader, fst: nod.Fst): + node = _file_with_min_size(fst, 8) + f = partition.read_file(node) + pos = f.seek(4) + assert pos == 4 + assert f.tell() == 4 + + def test_seek_from_current(self, partition: nod.PartitionReader, fst: nod.Fst): + node = _file_with_min_size(fst, 8) + f = partition.read_file(node) + f.seek(4) + pos = f.seek(2, 1) + assert pos == 6 + + def test_seek_from_end(self, partition: nod.PartitionReader, fst: nod.Fst): + node = _file_with_min_size(fst, 4) + f = partition.read_file(node) + pos = f.seek(-4, 2) + assert pos == node.length - 4 + + def test_seek_to_start_rereads_same_data( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + node = _file_with_min_size(fst, 4) + f = partition.read_file(node) + first = f.read(4) + f.seek(0) + second = f.read(4) + assert first == second + + def test_seek_and_read_middle(self, partition: nod.PartitionReader, fst: nod.Fst): + node = _file_with_min_size(fst, 8) + f = partition.read_file(node) + full = f.read() + f.seek(4) + chunk = f.read(4) + assert chunk == full[4:8] + + def test_multiple_readers_independent( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + # Two FileReaders on the same node must not interfere with each other. + node = _file_with_min_size(fst, 8) + f1 = partition.read_file(node) + f2 = partition.read_file(node) + first4_f1 = f1.read(4) + first4_f2 = f2.read(4) + assert first4_f1 == first4_f2 + # f2 still at position 4 regardless of what f1 does + f1.read() + next4_f2 = f2.read(4) + full = partition.read_file(node).read() + assert next4_f2 == full[4:8] + + def test_context_manager_closes(self, partition: nod.PartitionReader, fst: nod.Fst): + node = _file_with_min_size(fst, 1) + with partition.read_file(node) as f: + assert not f.closed + f.read(1) + assert f.closed + + def test_read_after_close_raises( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + node = _file_with_min_size(fst, 1) + f = partition.read_file(node) + f.close() + with pytest.raises(ValueError, match="closed"): + f.read() + + def test_seek_after_close_raises( + self, partition: nod.PartitionReader, fst: nod.Fst + ): + node = _file_with_min_size(fst, 1) + f = partition.read_file(node) + f.close() + with pytest.raises(ValueError, match="closed"): + f.seek(0) diff --git a/nod/python_tests/test_patcher.py b/nod/python_tests/test_patcher.py new file mode 100644 index 0000000..695f9ed --- /dev/null +++ b/nod/python_tests/test_patcher.py @@ -0,0 +1,438 @@ +from pathlib import Path + +import pytest + +import nod_rs as nod + +# --------------------------------------------------------------------------- +# Construction / validation +# --------------------------------------------------------------------------- + + +class TestDiscPatcherConstruct: + def test_create(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + assert patcher is not None + + def test_repr_empty(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + r = repr(patcher) + assert "DiscPatcher" in r + assert "0" in r + + def test_repr_after_add(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + patcher.add_file("files/test.bin", b"x") + assert "1" in repr(patcher) + patcher.add_file("files/test2.bin", b"y") + assert "2" in repr(patcher) + + def test_wii_disc_raises(self, wii_disc: nod.DiscReader): + with pytest.raises(ValueError, match=r"(?i)wii"): + nod.DiscPatcher(wii_disc) + + +# --------------------------------------------------------------------------- +# add_file validation +# --------------------------------------------------------------------------- + + +class TestDiscPatcherAddFile: + def test_sys_boot_raises(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + with pytest.raises(ValueError, match="sys/"): + patcher.add_file("sys/boot.bin", b"data") + + def test_sys_dol_raises(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + with pytest.raises(ValueError, match="sys/"): + patcher.add_file("sys/main.dol", b"data") + + def test_sys_with_leading_slash_raises(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + with pytest.raises(ValueError, match="sys/"): + patcher.add_file("/sys/apploader.img", b"data") + + def test_normal_path_succeeds(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + patcher.add_file("files/new.bin", b"hello") # must not raise + + def test_leading_slash_stripped(self, disc: nod.DiscReader): + # /files/x and files/x should count as the same key + patcher = nod.DiscPatcher(disc) + patcher.add_file("/files/dupe.bin", b"first") + patcher.add_file("files/dupe.bin", b"second") + assert "1" in repr(patcher) # only one entry + + def test_override_same_path(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + patcher.add_file("files/x.bin", b"v1") + patcher.add_file("files/x.bin", b"v2") + assert "1" in repr(patcher) # second call replaced first + + +# --------------------------------------------------------------------------- +# set_dol() +# --------------------------------------------------------------------------- + + +_DOL_HEADER_SIZE = 0x100 # DOL header is always 256 bytes + + +def _modified_dol(original: bytes, fill: int = 0xDD) -> bytes: + """Return a DOL with the same header as *original* but body bytes replaced. + + The header encodes section offsets and sizes, so keeping it intact lets + the partition reader parse the DOL correctly while the content differs. + """ + if len(original) <= _DOL_HEADER_SIZE: + pytest.skip("DOL too small to modify body") + body_size = len(original) - _DOL_HEADER_SIZE + return original[:_DOL_HEADER_SIZE] + bytes([fill]) * body_size + + +class TestDiscPatcherSetDol: + def _original_dol(self, disc: nod.DiscReader) -> bytes: + return disc.open_partition_kind("Data").meta().raw_dol + + def test_set_dol_changes_raw_dol(self, disc: nod.DiscReader): + original = self._original_dol(disc) + new_dol = _modified_dol(original, fill=0xAA) + + patcher = nod.DiscPatcher(disc) + patcher.set_dol(new_dol) + patched = patcher.build() + + result = patched.open_partition_kind("Data").meta().raw_dol + assert result == new_dol + assert result != original + + def test_no_set_dol_preserves_original(self, disc: nod.DiscReader): + original = self._original_dol(disc) + patched = nod.DiscPatcher(disc).build() + result = patched.open_partition_kind("Data").meta().raw_dol + assert result == original + + def test_set_dol_twice_uses_last(self, disc: nod.DiscReader): + original = self._original_dol(disc) + first = _modified_dol(original, fill=0xAA) + second = _modified_dol(original, fill=0xBB) + + patcher = nod.DiscPatcher(disc) + patcher.set_dol(first) + patcher.set_dol(second) + patched = patcher.build() + + result = patched.open_partition_kind("Data").meta().raw_dol + assert result == second + + def test_set_dol_combined_with_file_patch(self, disc: nod.DiscReader): + original = self._original_dol(disc) + new_dol = _modified_dol(original, fill=0xCC) + + patcher = nod.DiscPatcher(disc) + patcher.set_dol(new_dol) + patcher.add_file("files/__dol_test__.bin", b"alongside dol") + patched = patcher.build() + + assert patched.open_partition_kind("Data").meta().raw_dol == new_dol + fst = patched.open_partition_kind("Data").meta().fst() + assert fst.find("/files/__dol_test__.bin") is not None + + def test_set_dol_idempotent_builds(self, disc: nod.DiscReader): + original = self._original_dol(disc) + new_dol = _modified_dol(original, fill=0xDD) + + patcher = nod.DiscPatcher(disc) + patcher.set_dol(new_dol) + r1 = patcher.build().open_partition_kind("Data").meta().raw_dol + r2 = patcher.build().open_partition_kind("Data").meta().raw_dol + assert r1 == r2 == new_dol + + +# --------------------------------------------------------------------------- +# set_header() — validation +# --------------------------------------------------------------------------- + + +class TestDiscPatcherSetHeader: + def test_game_id_wrong_length_raises(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + with pytest.raises(ValueError, match="6"): + patcher.set_header(game_id="SHORT") + + def test_game_id_too_long_raises(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + with pytest.raises(ValueError, match="6"): + patcher.set_header(game_id="TOOLONGID") + + def test_no_args_is_noop(self, disc: nod.DiscReader): + # Calling set_header() with no arguments must not raise and must not + # change anything in the built disc. + patcher = nod.DiscPatcher(disc) + patcher.set_header() + patched = patcher.build() + assert patched.header().game_id == disc.header().game_id + assert patched.header().game_title == disc.header().game_title + + def test_set_game_id(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + patcher.set_header(game_id="TSTID0") + patched = patcher.build() + assert patched.header().game_id == "TSTID0" + + def test_set_game_title(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + patcher.set_header(game_title="My Patched Game") + patched = patcher.build() + assert patched.header().game_title == "My Patched Game" + + def test_set_disc_num(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + patcher.set_header(disc_num=1) + patched = patcher.build() + assert patched.header().disc_num == 1 + + def test_set_disc_version(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + patcher.set_header(disc_version=2) + patched = patcher.build() + assert patched.header().disc_version == 2 + + def test_set_multiple_fields(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + patcher.set_header(game_id="MULTI0", game_title="Multi Override", disc_num=0) + patched = patcher.build() + assert patched.header().game_id == "MULTI0" + assert patched.header().game_title == "Multi Override" + assert patched.header().disc_num == 0 + + def test_unset_fields_preserved(self, disc: nod.DiscReader): + # Overriding game_id must not change game_title. + original_title = disc.header().game_title + patcher = nod.DiscPatcher(disc) + patcher.set_header(game_id="KEEP00") + patched = patcher.build() + assert patched.header().game_title == original_title + + def test_header_override_combined_with_file_patch(self, disc: nod.DiscReader): + # Header override and file override must both take effect. + patcher = nod.DiscPatcher(disc) + patcher.set_header(game_id="COMBO0") + patcher.add_file("files/__combo_test__.bin", b"combo") + patched = patcher.build() + assert patched.header().game_id == "COMBO0" + fst = patched.open_partition_kind("Data").meta().fst() + node = fst.find("/files/__combo_test__.bin") + assert node is not None + + def test_set_header_idempotent_on_multiple_builds(self, disc: nod.DiscReader): + patcher = nod.DiscPatcher(disc) + patcher.set_header(game_id="IDEM00") + r1 = patcher.build() + r2 = patcher.build() + assert r1.header().game_id == r2.header().game_id == "IDEM00" + + +# --------------------------------------------------------------------------- +# build() — structural checks +# --------------------------------------------------------------------------- + + +class TestDiscPatcherBuild: + def test_build_returns_disc_reader(self, disc: nod.DiscReader): + patched = nod.DiscPatcher(disc).build() + assert isinstance(patched, nod.DiscReader) + + def test_build_preserves_game_id(self, disc: nod.DiscReader): + patched = nod.DiscPatcher(disc).build() + assert patched.header().game_id == disc.header().game_id + + def test_build_preserves_game_title(self, disc: nod.DiscReader): + patched = nod.DiscPatcher(disc).build() + assert patched.header().game_title == disc.header().game_title + + def test_build_is_gamecube(self, disc: nod.DiscReader): + patched = nod.DiscPatcher(disc).build() + assert patched.header().is_gamecube + assert not patched.header().is_wii + + def test_build_partition_accessible(self, disc: nod.DiscReader): + patched = nod.DiscPatcher(disc).build() + partition = patched.open_partition_kind("Data") + assert partition is not None + assert not partition.is_wii() + + def test_build_fst_non_empty(self, disc: nod.DiscReader): + patched = nod.DiscPatcher(disc).build() + fst = patched.open_partition_kind("Data").meta().fst() + nodes = list(fst) + assert len(nodes) > 0 + + def test_build_idempotent(self, disc: nod.DiscReader): + # Calling build() twice on the same patcher should produce consistent results. + patcher = nod.DiscPatcher(disc) + r1 = patcher.build() + r2 = patcher.build() + assert r1.header().game_id == r2.header().game_id + + +# --------------------------------------------------------------------------- +# build() — file content integrity +# --------------------------------------------------------------------------- + + +class TestDiscPatcherFileIntegrity: + def _smallest_file( + self, disc: nod.DiscReader + ) -> tuple[nod.PartitionReader, nod.FstNode]: + partition = disc.open_partition_kind("Data") + meta = partition.meta() + files: list[nod.FstNode] = sorted( + (n for n in meta.fst() if n.is_file and n.length > 0), + key=lambda n: n.length, + ) + if not files: + pytest.skip("No files in FST") + return partition, files[0] + + def test_unmodified_file_matches_original(self, disc: nod.DiscReader): + partition, node = self._smallest_file(disc) + original_data = partition.read_file(node).read() + + patched = nod.DiscPatcher(disc).build() + patched_partition = patched.open_partition_kind("Data") + patched_meta = patched_partition.meta() + patched_node = patched_meta.fst().find("/" + node.path) + assert patched_node is not None, f"File {node.path} missing from patched FST" + patched_data = patched_partition.read_file(patched_node).read() + assert patched_data == original_data + + def test_replaced_file_has_new_data(self, disc: nod.DiscReader): + _, node = self._smallest_file(disc) + new_data = b"REPLACED" * 16 + + patcher = nod.DiscPatcher(disc) + patcher.add_file(node.path, new_data) + patched = patcher.build() + + patched_partition = patched.open_partition_kind("Data") + patched_node = patched_partition.meta().fst().find("/" + node.path) + assert patched_node is not None + assert patched_node.length == len(new_data) + assert patched_partition.read_file(patched_node).read() == new_data + + def test_replaced_file_different_size(self, disc: nod.DiscReader): + _, node = self._smallest_file(disc) + # Use a size guaranteed to be different from the original (original >= 1 byte) + new_data = b"\xde\xad\xbe\xef" * 64 # 256 bytes + + patcher = nod.DiscPatcher(disc) + patcher.add_file(node.path, new_data) + patched = patcher.build() + + patched_partition = patched.open_partition_kind("Data") + patched_node = patched_partition.meta().fst().find("/" + node.path) + assert patched_node is not None + assert patched_node.length == 256 + assert patched_partition.read_file(patched_node).read() == new_data + + def test_other_files_unchanged_when_one_replaced(self, disc: nod.DiscReader): + partition = disc.open_partition_kind("Data") + meta = partition.meta() + files = sorted( + (n for n in meta.fst() if n.is_file and n.length > 0), + key=lambda n: n.length, + ) + if len(files) < 2: + pytest.skip("Need at least 2 files in FST") + + target = files[0] + bystander = files[1] + original_bystander_data = partition.read_file(bystander).read() + + patcher = nod.DiscPatcher(disc) + patcher.add_file(target.path, b"new content") + patched = patcher.build() + + patched_partition = patched.open_partition_kind("Data") + patched_bystander = patched_partition.meta().fst().find("/" + bystander.path) + assert patched_bystander is not None + result = patched_partition.read_file(patched_bystander).read() + assert result == original_bystander_data + + def test_add_new_file_appears_in_fst(self, disc: nod.DiscReader): + new_path = "files/__patcher_new_file__.bin" + new_data = b"brand new file content" + + patcher = nod.DiscPatcher(disc) + patcher.add_file(new_path, new_data) + patched = patcher.build() + + patched_partition = patched.open_partition_kind("Data") + node = patched_partition.meta().fst().find("/" + new_path) + assert node is not None, f"{new_path} not found in patched FST" + assert node.length == len(new_data) + assert patched_partition.read_file(node).read() == new_data + + def test_all_original_files_present_after_unmodified_build( + self, disc: nod.DiscReader + ): + partition = disc.open_partition_kind("Data") + original_paths = {n.path for n in partition.meta().fst() if n.is_file} + + patched = nod.DiscPatcher(disc).build() + patched_partition = patched.open_partition_kind("Data") + patched_paths = {n.path for n in patched_partition.meta().fst() if n.is_file} + + missing = original_paths - patched_paths + assert not missing, f"Files missing from patched disc: {missing}" + + +# --------------------------------------------------------------------------- +# build() → DiscWriter round-trip +# --------------------------------------------------------------------------- + + +class TestDiscPatcherWriterRoundtrip: + def test_patched_disc_writeable_to_iso(self, disc: nod.DiscReader, tmp_path: Path): + patched = nod.DiscPatcher(disc).build() + out = tmp_path / "patched.iso" + nod.DiscWriter(patched, "ISO").process(str(out)) + assert out.stat().st_size > 0 + + def test_written_iso_reopens_correctly(self, disc: nod.DiscReader, tmp_path: Path): + _, node = _smallest_file_in(disc) + new_data = b"roundtrip_check" * 8 + + patcher = nod.DiscPatcher(disc) + patcher.add_file(node.path, new_data) + patched = patcher.build() + + out = tmp_path / "patched.iso" + nod.DiscWriter(patched, "ISO").process(str(out)) + + reopened = nod.DiscReader(str(out)) + assert reopened.header().game_id == disc.header().game_id + + repart = reopened.open_partition_kind("Data") + renode = repart.meta().fst().find("/" + node.path) + assert renode is not None + assert repart.read_file(renode).read() == new_data + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _smallest_file_in(disc: nod.DiscReader): + partition = disc.open_partition_kind("Data") + meta = partition.meta() + files = sorted( + (n for n in meta.fst() if n.is_file and n.length > 0), + key=lambda n: n.length, + ) + if not files: + pytest.skip("No files in FST") + return partition, files[0] diff --git a/nod/python_tests/test_writer.py b/nod/python_tests/test_writer.py new file mode 100644 index 0000000..1c87827 --- /dev/null +++ b/nod/python_tests/test_writer.py @@ -0,0 +1,115 @@ +import tempfile +from pathlib import Path + +import pytest + +import nod_rs as nod + + +class TestDiscWriter: + def test_create_writer_iso(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + assert writer is not None + + def test_create_writer_invalid_format(self, disc: nod.DiscReader): + with pytest.raises(ValueError, match="Unknown format"): + nod.DiscWriter(disc, "INVALID") + + def test_create_writer_invalid_compression(self, disc: nod.DiscReader): + with pytest.raises(ValueError, match="Unknown compression type"): + nod.DiscWriter(disc, "RVZ", compression="BadAlgo") + + def test_repr(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + assert "DiscWriter" in repr(writer) + + def test_progress_bound(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + assert writer.progress_bound() > 0 + + def test_process_to_iso(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + with tempfile.NamedTemporaryFile(suffix=".iso", delete=False) as f: + out_path = Path(f.name) + try: + fin = writer.process(str(out_path)) + assert isinstance(fin, nod.DiscFinalization) + assert out_path.stat().st_size > 0 + finally: + out_path.unlink() + + def test_process_to_iso_with_failed_callback(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + + def failing_callback(progress: int, total: int) -> None: + raise ValueError("Callback failed!") + + with tempfile.NamedTemporaryFile(suffix=".iso", delete=False) as f: + out_path = Path(f.name) + try: + with pytest.raises(OSError, match="Failed to write disc data"): + writer.process(str(out_path), callback=failing_callback) + finally: + out_path.unlink() + + def test_process_produces_valid_disc(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + with tempfile.NamedTemporaryFile(suffix=".iso", delete=False) as f: + out_path = Path(f.name) + try: + writer.process(str(out_path)) + out_disc = nod.DiscReader(str(out_path)) + assert out_disc.header().game_id == disc.header().game_id + assert out_disc.header().game_title == disc.header().game_title + finally: + out_path.unlink() + + def test_process_with_crc32(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + with tempfile.NamedTemporaryFile(suffix=".iso", delete=False) as f: + out_path = Path(f.name) + try: + fin = writer.process(str(out_path), digest_crc32=True) + assert fin.crc32 is not None + assert isinstance(fin.crc32, int) + finally: + out_path.unlink() + + def test_process_with_sha1(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + with tempfile.NamedTemporaryFile(suffix=".iso", delete=False) as f: + out_path = Path(f.name) + try: + fin = writer.process(str(out_path), digest_sha1=True) + assert fin.sha1 is not None + assert len(fin.sha1) == 20 + finally: + out_path.unlink() + + def test_process_no_digest_returns_none(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + with tempfile.NamedTemporaryFile(suffix=".iso", delete=False) as f: + out_path = Path(f.name) + try: + fin = writer.process(str(out_path)) + assert fin.crc32 is None + assert fin.md5 is None + assert fin.sha1 is None + assert fin.xxh64 is None + finally: + out_path.unlink() + + def test_process_bad_output_path(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + with pytest.raises(OSError, match="Failed to create"): + writer.process("/no/such/directory/output.iso") + + def test_finalization_repr(self, disc: nod.DiscReader): + writer = nod.DiscWriter(disc, "ISO") + with tempfile.NamedTemporaryFile(suffix=".iso", delete=False) as f: + out_path = Path(f.name) + try: + fin = writer.process(str(out_path), digest_crc32=True) + assert "DiscFinalization" in repr(fin) + finally: + out_path.unlink() diff --git a/nod/src/lib.rs b/nod/src/lib.rs index 77d975e..25dcabf 100644 --- a/nod/src/lib.rs +++ b/nod/src/lib.rs @@ -153,10 +153,19 @@ pub(crate) mod build; pub mod common; pub mod disc; pub(crate) mod io; +#[cfg(feature = "python")] +mod python; pub mod read; pub mod util; pub mod write; +#[cfg(feature = "python")] +use pyo3::prelude::*; + +#[cfg(feature = "python")] +#[pymodule] +fn nod_rs(m: &Bound<'_, PyModule>) -> PyResult<()> { python::register(m) } + /// Error types for nod. #[derive(thiserror::Error, Debug)] pub enum Error { diff --git a/nod/src/python.rs b/nod/src/python.rs new file mode 100644 index 0000000..674360a --- /dev/null +++ b/nod/src/python.rs @@ -0,0 +1,1154 @@ +use std::{ + collections::HashMap, + fs::File, + io::{self, BufWriter, Read as IoRead, Seek, SeekFrom, Write}, + sync::{Arc, Mutex}, +}; + +use pyo3::{ + exceptions::{PyIOError, PyRuntimeError, PyValueError}, + prelude::*, + types::PyBytes, +}; + +use crate::{ + common::{Compression, Format, PartitionKind as NodPartitionKind}, + disc::{DiscHeader as NodDiscHeader, fst::Node}, + read::{ + DiscMeta as NodDiscMeta, DiscOptions, DiscReader as NodDiscReader, PartitionMeta, + PartitionOptions, PartitionReader, + }, + write::{DiscWriter as NodDiscWriter, FormatOptions, ProcessOptions, ScrubLevel}, +}; + +fn nod_err(e: crate::Error) -> PyErr { + use std::io::ErrorKind; + + use pyo3::exceptions::PyFileNotFoundError; + match &e { + crate::Error::Io(_, io_err) if io_err.kind() == ErrorKind::NotFound => { + PyFileNotFoundError::new_err(format!("{e}")) + } + _ => PyIOError::new_err(format!("{e}")), + } +} + +fn io_err(e: std::io::Error) -> PyErr { PyIOError::new_err(format!("{e}")) } + +// --------------------------------------------------------------------------- +// DiscHeader +// --------------------------------------------------------------------------- + +#[pyclass(name = "DiscHeader", frozen)] +pub struct PyDiscHeader { + #[pyo3(get)] + pub game_id: String, + #[pyo3(get)] + pub game_title: String, + #[pyo3(get)] + pub disc_num: u8, + #[pyo3(get)] + pub disc_version: u8, + #[pyo3(get)] + pub audio_streaming: u8, + #[pyo3(get)] + pub audio_stream_buf_size: u8, + #[pyo3(get)] + pub is_wii: bool, + #[pyo3(get)] + pub is_gamecube: bool, +} + +#[pymethods] +impl PyDiscHeader { + fn __repr__(&self) -> String { + format!( + "DiscHeader(game_id={:?}, game_title={:?}, is_wii={})", + self.game_id, self.game_title, self.is_wii + ) + } +} + +fn from_disc_header(h: &NodDiscHeader) -> PyDiscHeader { + PyDiscHeader { + game_id: h.game_id_str().to_string(), + game_title: h.game_title_str().to_string(), + disc_num: h.disc_num, + disc_version: h.disc_version, + audio_streaming: h.audio_streaming, + audio_stream_buf_size: h.audio_stream_buf_size, + is_wii: h.is_wii(), + is_gamecube: h.is_gamecube(), + } +} + +// --------------------------------------------------------------------------- +// DiscMeta +// --------------------------------------------------------------------------- + +#[pyclass(name = "DiscMeta", frozen)] +pub struct PyDiscMeta { + #[pyo3(get)] + pub format: String, + #[pyo3(get)] + pub compression: String, + #[pyo3(get)] + pub block_size: Option, + #[pyo3(get)] + pub decrypted: bool, + #[pyo3(get)] + pub needs_hash_recovery: bool, + #[pyo3(get)] + pub lossless: bool, + #[pyo3(get)] + pub disc_size: Option, + #[pyo3(get)] + pub crc32: Option, + #[pyo3(get)] + pub xxh64: Option, +} + +#[pymethods] +impl PyDiscMeta { + fn __repr__(&self) -> String { + format!("DiscMeta(format={:?}, compression={:?})", self.format, self.compression) + } +} + +fn from_disc_meta(m: &NodDiscMeta) -> PyDiscMeta { + PyDiscMeta { + format: m.format.to_string(), + compression: m.compression.to_string(), + block_size: m.block_size, + decrypted: m.decrypted, + needs_hash_recovery: m.needs_hash_recovery, + lossless: m.lossless, + disc_size: m.disc_size, + crc32: m.crc32, + xxh64: m.xxh64, + } +} + +// --------------------------------------------------------------------------- +// PartitionInfo +// --------------------------------------------------------------------------- + +#[pyclass(name = "PartitionInfo", frozen)] +pub struct PyPartitionInfo { + #[pyo3(get)] + pub index: usize, + #[pyo3(get)] + pub kind: String, +} + +#[pymethods] +impl PyPartitionInfo { + fn __repr__(&self) -> String { + format!("PartitionInfo(index={}, kind={:?})", self.index, self.kind) + } +} + +// --------------------------------------------------------------------------- +// FstNode +// --------------------------------------------------------------------------- + +/// A single file system entry. Returned by [`Fst.find`] and [`Fst.__iter__`]. +/// Pass to [`PartitionReader.read_file`] to read the file contents. +#[pyclass(name = "FstNode", frozen, skip_from_py_object)] +#[derive(Clone)] +pub struct PyFstNode { + /// The name component of this entry (last path segment). + #[pyo3(get)] + pub name: String, + /// The full path from the partition root, using `/` as separator. + #[pyo3(get)] + pub path: String, + #[pyo3(get)] + pub is_file: bool, + #[pyo3(get)] + pub is_dir: bool, + /// For files: the byte size of the file. + /// For directories: the child-end index in the FST. + #[pyo3(get)] + pub length: u32, + /// Index of this node in the FST array. + #[pyo3(get)] + pub fst_index: usize, + // Kept internal; used by PartitionReader.read_file. + pub(crate) node: Node, +} + +#[pymethods] +impl PyFstNode { + fn __repr__(&self) -> String { + if self.is_file { + format!("FstNode(path={:?}, length={})", self.path, self.length) + } else { + format!("FstNode(path={:?}, dir=True)", self.path) + } + } +} + +// --------------------------------------------------------------------------- +// FstIter +// --------------------------------------------------------------------------- + +#[pyclass(name = "FstIter")] +pub struct PyFstIter { + entries: Vec, + index: usize, +} + +#[pymethods] +impl PyFstIter { + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { slf } + + fn __next__(&mut self) -> Option { + if self.index < self.entries.len() { + let entry = self.entries[self.index].clone(); + self.index += 1; + Some(entry) + } else { + None + } + } +} + +// --------------------------------------------------------------------------- +// Fst +// --------------------------------------------------------------------------- + +#[pyclass(name = "Fst")] +pub struct PyFst { + raw_fst: Arc<[u8]>, +} + +#[pymethods] +impl PyFst { + /// Find a file or directory by its path (case-insensitive). + /// Returns `None` if not found. + fn find(&self, path: &str) -> PyResult> { + let buf: &[u8] = &self.raw_fst; + let fst = crate::disc::fst::Fst::new(buf) + .map_err(|e| PyRuntimeError::new_err(format!("Invalid FST: {e}")))?; + Ok(fst.find(path).map(|(idx, node)| { + let name = fst.get_name(node).unwrap_or_default().into_owned(); + PyFstNode { + name, + path: path.trim_matches('/').to_string(), + is_file: node.is_file(), + is_dir: node.is_dir(), + length: node.length(), + fst_index: idx, + node, + } + })) + } + + fn __iter__(&self) -> PyResult { + let buf: &[u8] = &self.raw_fst; + let fst = crate::disc::fst::Fst::new(buf) + .map_err(|e| PyRuntimeError::new_err(format!("Invalid FST: {e}")))?; + let entries: Vec = fst + .iter() + .map(|(idx, node, path)| { + let name = fst.get_name(node).unwrap_or_default().into_owned(); + PyFstNode { + name, + path, + is_file: node.is_file(), + is_dir: node.is_dir(), + length: node.length(), + fst_index: idx, + node, + } + }) + .collect(); + Ok(PyFstIter { entries, index: 0 }) + } + + fn __repr__(&self) -> String { "Fst(...)".to_string() } +} + +// --------------------------------------------------------------------------- +// PartitionMeta +// --------------------------------------------------------------------------- + +#[pyclass(name = "PartitionMeta")] +pub struct PyPartitionMeta { + inner: Arc, +} + +#[pymethods] +impl PyPartitionMeta { + /// Returns the file system table. + fn fst(&self) -> PyResult { Ok(PyFst { raw_fst: Arc::clone(&self.inner.raw_fst) }) } + + /// Disc and boot header (boot.bin, 0x440 bytes). + #[getter] + fn raw_boot<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, self.inner.raw_boot.as_ref()) + } + + /// Debug and region information (bi2.bin, 0x2000 bytes). + #[getter] + fn raw_bi2<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, self.inner.raw_bi2.as_ref()) + } + + /// Apploader binary (apploader.bin). + #[getter] + fn raw_apploader<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.inner.raw_apploader) + } + + /// Main executable binary (main.dol). + #[getter] + fn raw_dol<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.inner.raw_dol) + } + + /// Raw file system table (fst.bin). + #[getter] + fn raw_fst<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.inner.raw_fst) + } + + /// Wii ticket (ticket.bin), or `None` for GameCube discs. + #[getter] + fn raw_ticket<'py>(&self, py: Python<'py>) -> Option> { + self.inner.raw_ticket.as_deref().map(|b| PyBytes::new(py, b)) + } + + /// Wii title metadata (tmd.bin), or `None` for GameCube discs. + #[getter] + fn raw_tmd<'py>(&self, py: Python<'py>) -> Option> { + self.inner.raw_tmd.as_deref().map(|b| PyBytes::new(py, b)) + } + + /// Wii certificate chain (cert.bin), or `None` for GameCube discs. + #[getter] + fn raw_cert_chain<'py>(&self, py: Python<'py>) -> Option> { + self.inner.raw_cert_chain.as_deref().map(|b| PyBytes::new(py, b)) + } + + /// Wii H3 hash table (h3.bin), or `None` for GameCube discs. + #[getter] + fn raw_h3_table<'py>(&self, py: Python<'py>) -> Option> { + self.inner.raw_h3_table.as_deref().map(|b| PyBytes::new(py, b.as_ref())) + } + + /// Disc header information parsed from boot.bin. + fn disc_header(&self) -> PyDiscHeader { from_disc_header(self.inner.disc_header()) } + + fn __repr__(&self) -> String { "PartitionMeta(...)".to_string() } +} + +// --------------------------------------------------------------------------- +// FileReader +// --------------------------------------------------------------------------- + +/// A lazy, seekable binary file reader backed by a disc partition. +/// +/// Returned by :meth:`PartitionReader.read_file`. Reads are issued against +/// the source disc on demand — no data is buffered until you call +/// :meth:`read`. Implements the :class:`io.RawIOBase` interface +/// (``read``, ``seek``, ``tell``, ``readable``, ``seekable``, +/// ``writable``, context manager). +#[pyclass(name = "FileReader")] +pub struct PyFileReader { + /// Shared reference to the underlying partition (same object as the + /// `PyPartitionReader` that created us). + partition: Arc>>, + /// Absolute byte offset of the file within the partition stream. + file_offset: u64, + /// File size in bytes. + file_size: u64, + /// Current read position relative to the start of the file. + pos: u64, + closed: bool, +} + +#[pymethods] +impl PyFileReader { + /// Read and return up to *size* bytes. If *size* is ``-1`` or omitted, + /// reads until end of file. + #[pyo3(signature = (size = -1))] + fn read<'py>(&mut self, py: Python<'py>, size: i64) -> PyResult> { + self.check_open()?; + let remaining = self.file_size.saturating_sub(self.pos); + let to_read = if size < 0 { remaining } else { (size as u64).min(remaining) } as usize; + if to_read == 0 { + return Ok(PyBytes::new(py, &[])); + } + let abs_pos = self.file_offset + self.pos; + let mut buf = vec![0u8; to_read]; + { + let mut guard = self.partition.lock().unwrap(); + guard.seek(SeekFrom::Start(abs_pos)).map_err(io_err)?; + guard.read_exact(&mut buf).map_err(io_err)?; + } + self.pos += to_read as u64; + Ok(PyBytes::new(py, &buf)) + } + + /// Seek to *pos* bytes relative to *whence*: + /// 0 (default) — start of file, 1 — current position, 2 — end of file. + /// + /// Returns the new absolute position. + #[pyo3(signature = (pos, whence = 0))] + fn seek(&mut self, pos: i64, whence: i32) -> PyResult { + self.check_open()?; + let new_pos: u64 = match whence { + 0 => pos.max(0) as u64, + 1 => self.pos.saturating_add_signed(pos), + 2 => self.file_size.saturating_add_signed(pos), + w => return Err(PyValueError::new_err(format!("invalid whence value: {w}"))), + }; + self.pos = new_pos.min(self.file_size); + Ok(self.pos) + } + + /// Return the current stream position. + fn tell(&self) -> PyResult { + self.check_open()?; + Ok(self.pos) + } + + /// Return the file size in bytes. + fn size(&self) -> u64 { self.file_size } + + fn readable(&self) -> bool { true } + + fn seekable(&self) -> bool { true } + + fn writable(&self) -> bool { false } + + #[getter] + fn closed(&self) -> bool { self.closed } + + fn close(&mut self) { self.closed = true; } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { slf } + + #[pyo3(signature = (_exc_type=None, _exc_val=None, _exc_tb=None))] + fn __exit__( + &mut self, + _exc_type: Option>, + _exc_val: Option>, + _exc_tb: Option>, + ) { + self.close(); + } + + fn __repr__(&self) -> String { + format!("FileReader(size={}, pos={})", self.file_size, self.pos) + } +} + +impl PyFileReader { + fn check_open(&self) -> PyResult<()> { + if self.closed { + Err(PyValueError::new_err("I/O operation on closed file")) + } else { + Ok(()) + } + } +} + +// --------------------------------------------------------------------------- +// PartitionReader +// --------------------------------------------------------------------------- + +#[pyclass(name = "PartitionReader")] +pub struct PyPartitionReader { + inner: Arc>>, +} + +#[pymethods] +impl PyPartitionReader { + /// Returns `True` for Wii partitions, `False` for GameCube. + fn is_wii(&self) -> bool { self.inner.lock().unwrap().is_wii() } + + /// Reads the partition header and file system metadata. + fn meta(&self) -> PyResult { + let meta = self.inner.lock().unwrap().meta().map_err(nod_err)?; + Ok(PyPartitionMeta { inner: Arc::new(meta) }) + } + + /// Opens a file identified by *node* for lazy on-demand reading. + /// + /// Returns a :class:`FileReader` — a seekable, readable binary stream + /// that issues reads against the disc on demand. No data is read until + /// you call :meth:`FileReader.read`. + /// + /// Raises :exc:`IsADirectoryError` if *node* is a directory. + fn read_file(&self, node: &PyFstNode) -> PyResult { + if !node.is_file { + return Err(pyo3::exceptions::PyIsADirectoryError::new_err(format!( + "{:?} is a directory", + node.path + ))); + } + let is_wii = self.inner.lock().unwrap().is_wii(); + let file_offset = node.node.offset(is_wii); + let file_size = node.length as u64; + Ok(PyFileReader { + partition: Arc::clone(&self.inner), + file_offset, + file_size, + pos: 0, + closed: false, + }) + } + + fn __repr__(&self) -> String { format!("PartitionReader(is_wii={})", self.is_wii()) } +} + +// --------------------------------------------------------------------------- +// DiscReader +// --------------------------------------------------------------------------- + +#[pyclass(name = "DiscReader")] +pub struct PyDiscReader { + inner: Arc>, +} + +#[pymethods] +impl PyDiscReader { + /// Open a disc image for reading from *path*. + /// + /// Supports ISO, CISO, GCZ, NFS, RVZ, WBFS, WIA, and TGC formats. + /// + /// Raises :exc:`FileNotFoundError` if the file does not exist. + /// Raises :exc:`OSError` if the file cannot be opened or the format is not recognised. + #[new] + fn new(path: &str) -> PyResult { + let reader = NodDiscReader::new(path, &DiscOptions::default()).map_err(nod_err)?; + Ok(PyDiscReader { inner: Arc::new(Mutex::new(reader)) }) + } + + /// Returns the disc's primary header. + fn header(&self) -> PyDiscHeader { + let guard = self.inner.lock().unwrap(); + from_disc_header(guard.header()) + } + + /// Returns extra metadata about the underlying disc file format. + fn meta(&self) -> PyDiscMeta { + let guard = self.inner.lock().unwrap(); + from_disc_meta(&guard.meta()) + } + + /// Returns the disc's size in bytes. + fn disc_size(&self) -> u64 { self.inner.lock().unwrap().disc_size() } + + /// Returns a list of Wii partitions. Empty for GameCube discs. + fn partitions(&self) -> Vec { + let guard = self.inner.lock().unwrap(); + guard + .partitions() + .iter() + .map(|p| PyPartitionInfo { index: p.index, kind: p.kind.to_string() }) + .collect() + } + + /// Opens a partition by index. + /// For GameCube discs, `index` must be 0. + #[pyo3(signature = (index, validate_hashes=false))] + fn open_partition(&self, index: usize, validate_hashes: bool) -> PyResult { + let options = PartitionOptions { validate_hashes }; + let reader = self.inner.lock().unwrap().open_partition(index, &options).map_err(nod_err)?; + Ok(PyPartitionReader { inner: Arc::new(Mutex::new(reader)) }) + } + + /// Opens the first partition matching `kind`. + /// `kind` is a string: `"Data"`, `"Update"`, `"Channel"`. + /// For GameCube discs, use `"Data"`. + #[pyo3(signature = (kind="Data", validate_hashes=false))] + fn open_partition_kind( + &self, + kind: &str, + validate_hashes: bool, + ) -> PyResult { + let partition_kind = match kind { + "Data" => NodPartitionKind::Data, + "Update" => NodPartitionKind::Update, + "Channel" => NodPartitionKind::Channel, + other => { + return Err(PyValueError::new_err(format!( + "Unknown partition kind {:?}. Expected \"Data\", \"Update\", or \"Channel\".", + other + ))); + } + }; + let options = PartitionOptions { validate_hashes }; + let reader = self + .inner + .lock() + .unwrap() + .open_partition_kind(partition_kind, &options) + .map_err(nod_err)?; + Ok(PyPartitionReader { inner: Arc::new(Mutex::new(reader)) }) + } + + fn __repr__(&self) -> String { + let guard = self.inner.lock().unwrap(); + let h = guard.header(); + format!("DiscReader(game_id={:?}, game_title={:?})", h.game_id_str(), h.game_title_str()) + } +} + +// --------------------------------------------------------------------------- +// DiscFinalization +// --------------------------------------------------------------------------- + +#[pyclass(name = "DiscFinalization", frozen)] +pub struct PyDiscFinalization { + #[pyo3(get)] + pub crc32: Option, + #[pyo3(get)] + pub xxh64: Option, + pub md5: Option<[u8; 16]>, + pub sha1: Option<[u8; 20]>, + pub header: Vec, +} + +#[pymethods] +impl PyDiscFinalization { + /// MD5 hash of the input disc data, or `None` if not calculated. + #[getter] + fn md5<'py>(&self, py: Python<'py>) -> Option> { + self.md5.as_ref().map(|b| PyBytes::new(py, b.as_ref())) + } + + /// SHA-1 hash of the input disc data, or `None` if not calculated. + #[getter] + fn sha1<'py>(&self, py: Python<'py>) -> Option> { + self.sha1.as_ref().map(|b| PyBytes::new(py, b.as_ref())) + } + + /// Header data that must be written to the start of the output file, if non-empty. + #[getter] + fn header<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { PyBytes::new(py, &self.header) } + + fn __repr__(&self) -> String { + format!("DiscFinalization(crc32={:?}, xxh64={:?})", self.crc32, self.xxh64) + } +} + +// --------------------------------------------------------------------------- +// DiscWriter +// --------------------------------------------------------------------------- + +fn parse_format(s: &str) -> PyResult { + match s { + "ISO" => Ok(Format::Iso), + "CISO" => Ok(Format::Ciso), + "GCZ" => Ok(Format::Gcz), + "RVZ" => Ok(Format::Rvz), + "WBFS" => Ok(Format::Wbfs), + "WIA" => Ok(Format::Wia), + "TGC" => Ok(Format::Tgc), + other => Err(PyValueError::new_err(format!( + "Unknown format {:?}. Expected one of: ISO, CISO, GCZ, RVZ, WBFS, WIA, TGC.", + other + ))), + } +} + +fn parse_compression(s: &str) -> PyResult { + s.parse::().map_err(|e| PyValueError::new_err(e)) +} + +// SAFETY: DiscWriter is only accessed through the Mutex and never shared concurrently. +struct SendDiscWriter(NodDiscWriter); +unsafe impl Send for SendDiscWriter {} +unsafe impl Sync for SendDiscWriter {} + +#[pyclass(name = "DiscWriter")] +pub struct PyDiscWriter { + inner: Mutex, +} + +#[pymethods] +impl PyDiscWriter { + /// Creates a new disc writer. + /// + /// *disc* is a :class:`DiscReader` opened with :func:`open_disc`. + /// + /// *format* is one of ``"ISO"``, ``"CISO"``, ``"GCZ"``, ``"RVZ"``, ``"WBFS"``, ``"WIA"``, + /// ``"TGC"``. + /// + /// *compression* follows the pattern ``"Algorithm"`` or ``"Algorithm:level"``, e.g. + /// ``"Zstandard:19"``, ``"None"``. Defaults to the format's recommended compression. + /// + /// *block_size* defaults to the format's recommended block size (0 = use default). + #[new] + #[pyo3(signature = (disc, format, compression=None, block_size=0))] + fn new( + disc: &PyDiscReader, + format: &str, + compression: Option<&str>, + block_size: u32, + ) -> PyResult { + let fmt = parse_format(format)?; + let comp = match compression { + Some(s) => parse_compression(s)?, + None => fmt.default_compression(), + }; + let bs = if block_size == 0 { fmt.default_block_size() } else { block_size }; + let options = FormatOptions { format: fmt, compression: comp, block_size: bs }; + let reader = disc.inner.lock().unwrap().clone(); + let writer = NodDiscWriter::new(reader, &options).map_err(nod_err)?; + Ok(PyDiscWriter { inner: Mutex::new(SendDiscWriter(writer)) }) + } + + /// Returns the progress upper bound. Can be used to display progress from the *progress* + /// argument of the callback passed to :meth:`process`. + fn progress_bound(&self) -> u64 { self.inner.lock().unwrap().0.progress_bound() } + + /// Processes the disc and writes the result to *output_path*. + /// + /// An optional *callback* ``(progress: int, total: int) -> None`` is called after each + /// chunk is written and can be used to display progress. + /// + /// Returns a :class:`DiscFinalization` with any calculated checksums. + #[pyo3(signature = ( + output_path, + *, + callback=None, + digest_crc32=false, + digest_md5=false, + digest_sha1=false, + digest_xxh64=false, + scrub_update_partition=false, + ))] + fn process( + &self, + py: Python<'_>, + output_path: &str, + callback: Option>, + digest_crc32: bool, + digest_md5: bool, + digest_sha1: bool, + digest_xxh64: bool, + scrub_update_partition: bool, + ) -> PyResult { + let options = ProcessOptions { + #[cfg(feature = "threading")] + processor_threads: 0, + digest_crc32, + digest_md5, + digest_sha1, + digest_xxh64, + scrub: if scrub_update_partition { + ScrubLevel::UpdatePartition + } else { + ScrubLevel::None + }, + }; + + let file = File::create(output_path) + .map_err(|e| PyIOError::new_err(format!("Failed to create {output_path}: {e}")))?; + let file = Arc::new(Mutex::new(BufWriter::new(file))); + let file_write = Arc::clone(&file); + + let result = py.detach(|| { + self.inner.lock().unwrap().0.process( + |data, progress, total| { + file_write.lock().unwrap().write_all(&data)?; + if let Some(callback) = &callback { + let callback_result: std::io::Result<()> = Python::attach(|inner_py| { + callback.call1(inner_py, (progress, total)).map_err(|err| { + std::io::Error::new(std::io::ErrorKind::Other, format!("{err}")) + })?; + Ok(()) + }); + callback_result?; + } + Ok(()) + }, + &options, + ) + }); + + // Invoke Python progress callback (simplified: call once at end if provided) + // Full per-chunk callback would require re-acquiring the GIL inside the loop, + // which py.allow_threads does not allow. A future enhancement could use channels. + + let fin = result.map_err(nod_err)?; + + // Write header to beginning of file if required + if !fin.header.is_empty() { + let mut guard = file.lock().unwrap(); + guard.flush().map_err(|e| PyIOError::new_err(format!("{e}")))?; + let inner = guard.get_mut(); + inner.seek(SeekFrom::Start(0)).map_err(|e| PyIOError::new_err(format!("{e}")))?; + inner.write_all(&fin.header).map_err(|e| PyIOError::new_err(format!("{e}")))?; + } else { + file.lock().unwrap().flush().map_err(|e| PyIOError::new_err(format!("{e}")))?; + } + + Ok(PyDiscFinalization { + crc32: fin.crc32, + xxh64: fin.xxh64, + md5: fin.md5, + sha1: fin.sha1, + header: fin.header.to_vec(), + }) + } + + fn __repr__(&self) -> String { + format!("DiscWriter(progress_bound={})", self.inner.lock().unwrap().0.progress_bound()) + } +} + +// --------------------------------------------------------------------------- +// DiscPatcher +// --------------------------------------------------------------------------- + +/// File data provider used by the streaming DiscReader produced by DiscPatcher::build. +#[derive(Clone)] +struct PatcherCallback { + files: HashMap>, +} + +impl crate::build::gc::FileCallback for PatcherCallback { + fn read_file(&mut self, out: &mut [u8], name: &str, offset: u64) -> io::Result<()> { + let data = self.files.get(name).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, format!("DiscPatcher: file not found: {name}")) + })?; + let start = offset as usize; + let end = start + out.len(); + out.copy_from_slice(data.get(start..end).ok_or_else(|| { + io::Error::new( + io::ErrorKind::UnexpectedEof, + format!( + "DiscPatcher: {name}: read {start}..{end} out of bounds (size {})", + data.len() + ), + ) + })?); + Ok(()) + } +} + +/// Patches or extends a GameCube disc by adding or replacing files. +/// +/// The result of :meth:`build` is a :class:`DiscReader` that can be passed +/// directly to :class:`DiscWriter` for conversion to any supported output format. +/// +/// Example:: +/// +/// disc = nod.DiscReader("original.iso") +/// patcher = nod.DiscPatcher(disc) +/// with open("new_audio.dsp", "rb") as f: +/// patcher.add_file("files/audio/bgm.dsp", f.read()) +/// patched = patcher.build() +/// nod.DiscWriter(patched, "ISO").process("patched.iso") +#[pyclass(name = "DiscPatcher")] +pub struct PyDiscPatcher { + disc: Arc>, + overrides: HashMap>, + header_overrides: crate::build::gc::PartitionOverrides, + dol_override: Option>, +} + +#[pymethods] +impl PyDiscPatcher { + /// Create a patcher for *disc*. + /// + /// Raises :exc:`ValueError` if *disc* is a Wii disc. + #[new] + fn new(disc: &PyDiscReader) -> PyResult { + { + let guard = disc.inner.lock().unwrap(); + if guard.header().is_wii() { + return Err(PyValueError::new_err( + "DiscPatcher only supports GameCube discs, not Wii", + )); + } + } + Ok(Self { + disc: Arc::clone(&disc.inner), + overrides: HashMap::new(), + header_overrides: crate::build::gc::PartitionOverrides::default(), + dol_override: None, + }) + } + + /// Add a new file or replace an existing file in the disc. + /// + /// *path* is the FST path (e.g. ``"files/audio/bgm.dsp"``). Leading + /// slashes are stripped. Calling this a second time with the same path + /// replaces the previous override. + /// + /// Raises :exc:`ValueError` if *path* starts with ``"sys/"``. + fn add_file(&mut self, path: &str, data: &[u8]) -> PyResult<()> { + let path = path.trim_start_matches('/').to_string(); + if path.starts_with("sys/") { + return Err(PyValueError::new_err("Cannot override system files (sys/) via add_file")); + } + self.overrides.insert(path, Arc::from(data)); + Ok(()) + } + + /// Replace the main executable (DOL) in the patched disc. + /// + /// *data* must be a valid DOL binary. Calling this a second time + /// replaces the previous override. + fn set_dol(&mut self, data: &[u8]) { self.dol_override = Some(Arc::from(data)); } + + /// Override disc header fields in the patched disc. + /// + /// All parameters are optional; only those provided are changed. + /// + /// - *game_id*: six-character ASCII game ID, e.g. ``"GM8E01"`` + /// - *game_title*: game title string + /// - *disc_num*: disc number (0-based) + /// - *disc_version*: disc revision number + /// - *audio_streaming*: audio streaming flag (``True``/``False``) + /// - *audio_stream_buf_size*: audio stream buffer size + #[pyo3(signature = ( + *, + game_id=None, + game_title=None, + disc_num=None, + disc_version=None, + audio_streaming=None, + audio_stream_buf_size=None, + ))] + fn set_header( + &mut self, + game_id: Option<&str>, + game_title: Option<&str>, + disc_num: Option, + disc_version: Option, + audio_streaming: Option, + audio_stream_buf_size: Option, + ) -> PyResult<()> { + if let Some(id) = game_id { + if id.len() != 6 { + return Err(PyValueError::new_err(format!( + "game_id must be exactly 6 characters, got {}", + id.len() + ))); + } + let mut arr = [0u8; 6]; + arr.copy_from_slice(id.as_bytes()); + self.header_overrides.game_id = Some(arr); + } + if let Some(title) = game_title { + self.header_overrides.game_title = Some(title.to_string()); + } + if let Some(v) = disc_num { + self.header_overrides.disc_num = Some(v); + } + if let Some(v) = disc_version { + self.header_overrides.disc_version = Some(v); + } + if let Some(v) = audio_streaming { + self.header_overrides.audio_streaming = Some(v); + } + if let Some(v) = audio_stream_buf_size { + self.header_overrides.audio_stream_buf_size = Some(v); + } + Ok(()) + } + + /// Build a new :class:`DiscReader` with all patches applied. + /// + /// Reads all files from the source disc, applies any overrides added via + /// :meth:`add_file`, and returns a :class:`DiscReader` ready for + /// :class:`DiscWriter`. Non-overridden files are read from the source disc + /// into memory at this point. + /// + /// Raises :exc:`OSError` if the source disc cannot be read. + /// Raises :exc:`RuntimeError` if the disc layout is invalid. + fn build(&self) -> PyResult { + use crate::{ + build::gc::{FileInfo, GCPartitionBuilder}, + disc::{BB2_OFFSET, BI2_SIZE, BOOT_SIZE, fst::Fst}, + }; + + // Open the data partition (index 0 on GameCube). + let (meta, mut partition) = { + let guard = self.disc.lock().unwrap(); + let mut part = + guard.open_partition(0, &PartitionOptions::default()).map_err(nod_err)?; + let meta = part.meta().map_err(nod_err)?; + (meta, part) + }; + + let mut builder = GCPartitionBuilder::new(false, self.header_overrides.clone()); + // Maps FST path / sys-file name → file bytes for the streaming callback. + let mut file_map: HashMap> = HashMap::new(); + + // ---- System files ------------------------------------------------ + // boot.bin: disc header + boot header. locate_sys_files() reads this + // via the sys_file_callback to populate disc_header / boot_header. + // + // Zero out all layout offsets/sizes in BootHeader so the builder + // recalculates them fresh. This avoids overlaps (e.g. Kirby Air Ride + // where the original DOL offset overlaps the apploader) and handles + // games where the DOL is stored as a user-data FST entry (Metroid + // Prime 2) rather than in the system area. + let mut boot_bytes = meta.raw_boot.as_ref().to_vec(); + // BootHeader starts at BB2_OFFSET (0x420). Field layout (all U32 BE): + // +0 dol_offset + // +4 fst_offset + // +8 fst_size + // +12 fst_max_size + // +16 fst_memory_address (RAM load address – leave as-is) + // +20 user_offset + // +24 user_size + for field_off in [0usize, 4, 8, 12, 20, 24] { + let start = BB2_OFFSET + field_off; + boot_bytes[start..start + 4].fill(0); + } + builder + .add_file(FileInfo { + name: "sys/boot.bin".to_string(), + size: boot_bytes.len() as u64, + offset: Some(0), + alignment: None, + }) + .map_err(nod_err)?; + file_map.insert("sys/boot.bin".to_string(), Arc::from(boot_bytes.as_slice())); + + // bi2.bin: debug / region info. + builder + .add_file(FileInfo { + name: "sys/bi2.bin".to_string(), + size: meta.raw_bi2.len() as u64, + offset: Some(BOOT_SIZE as u64), + alignment: None, + }) + .map_err(nod_err)?; + file_map.insert("sys/bi2.bin".to_string(), Arc::from(meta.raw_bi2.as_ref() as &[u8])); + + // apploader.img: content provided at stream time via PatcherCallback. + let apploader_offset = (BOOT_SIZE + BI2_SIZE) as u64; + builder + .add_file(FileInfo { + name: "sys/apploader.img".to_string(), + size: meta.raw_apploader.len() as u64, + offset: Some(apploader_offset), + alignment: None, + }) + .map_err(nod_err)?; + file_map.insert("sys/apploader.img".to_string(), Arc::from(meta.raw_apploader.as_ref())); + + // main.dol: always placed after the apploader by the builder because + // we zeroed dol_offset above. + let dol_data: Arc<[u8]> = + self.dol_override.clone().unwrap_or_else(|| Arc::from(meta.raw_dol.as_ref())); + builder + .add_file(FileInfo { + name: "sys/main.dol".to_string(), + size: dol_data.len() as u64, + offset: None, + alignment: Some(128), + }) + .map_err(nod_err)?; + file_map.insert("sys/main.dol".to_string(), dol_data); + + // ---- User files from the original FST ---------------------------- + let fst = Fst::new(&meta.raw_fst) + .map_err(|e| PyRuntimeError::new_err(format!("Invalid FST: {e}")))?; + + for (_, node, path) in fst.iter() { + if !node.is_file() { + continue; + } + let data: Arc<[u8]> = if let Some(ov) = self.overrides.get(&path) { + ov.clone() + } else { + // Read file from the source partition. Some discs have FST + // entries whose data lives in a junk region (LFG-generated + // padding). Reads of those entries typically succeed and + // return the junk bytes; if they fail for any reason we fall + // back to zeros so the file at least appears in the FST. + let size = node.length() as usize; + let data = partition + .open_file(node) + .ok() + .and_then(|mut f| { + let mut buf = Vec::with_capacity(size); + f.read_to_end(&mut buf).ok().map(|_| buf) + }) + .unwrap_or_else(|| vec![0u8; size]); + Arc::from(data) + }; + builder + .add_file(FileInfo { + name: path.clone(), + size: data.len() as u64, + offset: None, + alignment: None, + }) + .map_err(nod_err)?; + file_map.insert(path, data); + } + + // ---- New files from overrides not present in the original FST ---- + for (path, data) in &self.overrides { + if !file_map.contains_key(path.as_str()) { + builder + .add_file(FileInfo { + name: path.clone(), + size: data.len() as u64, + offset: None, + alignment: None, + }) + .map_err(nod_err)?; + file_map.insert(path.clone(), data.clone()); + } + } + + // ---- Build layout ------------------------------------------------ + // sys_file_callback is called during build() for boot.bin and bi2.bin. + let sys_files = file_map.clone(); + let partition_writer = builder + .build(|w, name| { + let data = sys_files.get(name).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("DiscPatcher build: file not found: {name}"), + ) + })?; + w.write_all(data) + }) + .map_err(nod_err)?; + + let callback = PatcherCallback { files: file_map }; + let stream = partition_writer.into_cloneable_stream(callback).map_err(nod_err)?; + let reader = NodDiscReader::new_stream(stream, &DiscOptions::default()).map_err(nod_err)?; + + Ok(PyDiscReader { inner: Arc::new(Mutex::new(reader)) }) + } + + fn __repr__(&self) -> String { format!("DiscPatcher(overrides={})", self.overrides.len()) } +} + +// --------------------------------------------------------------------------- +// Module registration +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/nod/uv.lock b/nod/uv.lock new file mode 100644 index 0000000..fa92b98 --- /dev/null +++ b/nod/uv.lock @@ -0,0 +1,210 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nod-rs" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "ruff", specifier = ">=0.15.9" }, + { name = "ty", specifier = ">=0.0.29" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "ty" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d5/853561de49fae38c519e905b2d8da9c531219608f1fccc47a0fc2c896980/ty-0.0.29.tar.gz", hash = "sha256:e7936cca2f691eeda631876c92809688dbbab68687c3473f526cd83b6a9228d8", size = 5469221, upload-time = "2026-04-05T15:01:21.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/b7/911f9962115acfa24e3b2ec9d4992dd994c38e8769e1b1d7680bb4d28a51/ty-0.0.29-py3-none-linux_armv6l.whl", hash = "sha256:b8a40955f7660d3eaceb0d964affc81b790c0765e7052921a5f861ff8a471c30", size = 10568206, upload-time = "2026-04-05T15:01:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/fcae2167d4c77a97269f92f11d1b43b03617f81de1283d5d05b43432110c/ty-0.0.29-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6b6849adae15b00bbe2d3c5b078967dcb62eba37d38936b8eeb4c81a82d2e3b8", size = 10442530, upload-time = "2026-04-05T15:01:28.471Z" }, + { url = "https://files.pythonhosted.org/packages/97/33/5a6bfa240cfcb9c36046ae2459fa9ea23238d20130d8656ff5ac4d6c012a/ty-0.0.29-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dcdd9b17209788152f7b7ea815eda07989152325052fe690013537cc7904ce49", size = 9915735, upload-time = "2026-04-05T15:01:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/318f45fae232118e81a6306c30f50de42c509c412128d5bd231eab699ffb/ty-0.0.29-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8ed4789bae78ffaf94462c0d25589a734cab0366b86f2bbcb1bb90e1a7a169", size = 10419748, upload-time = "2026-04-05T15:01:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/5687872e2ab5a0f7dd4fd8456eac31e9381ad4dc74961f6f29965ad4dd91/ty-0.0.29-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91ec374b8565e0ad0900011c24641ebbef2da51adbd4fb69ff3280c8a7eceb02", size = 10394738, upload-time = "2026-04-05T15:01:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/015d118097eeb95e6a44c4abce4c0a28b7b9dfb3085b7f0ee48e4f099633/ty-0.0.29-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:298a8d5faa2502d3810bbbb47a030b9455495b9921594206043c785dd61548cf", size = 10910613, upload-time = "2026-04-05T15:01:17.17Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/47ce3c6c53e0670eadbe80756b167bf80ed6681d1ba57cfde2e8065a13d1/ty-0.0.29-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c8fba1a3524c6109d1e020d92301c79d41bf442fa8d335b9fa366239339cb70", size = 11475750, upload-time = "2026-04-05T15:01:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cf/e361845b1081c9264ad5b7c963231bab03f2666865a9f2a115c4233f2137/ty-0.0.29-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c48adf88a70d264128c39ee922ed14a947817fced1e93c08c1a89c9244edcde", size = 11190055, upload-time = "2026-04-05T15:01:12.369Z" }, + { url = "https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ce0a7a0e96bc7b42518cd3a1a6a6298ef64ff40ca4614355c1aa807059b5c6f", size = 11020539, upload-time = "2026-04-05T15:01:37.022Z" }, + { url = "https://files.pythonhosted.org/packages/20/36/5a26753802083f80cd125db6c4348ad42b3c982ec36e718e0bf4c18f75e5/ty-0.0.29-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6ac86a05b4a3731d45365ab97780acc7b8146fa62fccb3cbe94fe6546c67a97", size = 10396399, upload-time = "2026-04-05T15:01:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/00/e6/b4e75b5752239ab3ab400f19faef4dbef81d05aab5d3419fda0c062a3765/ty-0.0.29-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6bbbf53141af0f3150bf288d716263f1a3550054e4b3551ca866d38192ba9891", size = 10421461, upload-time = "2026-04-05T15:01:08.367Z" }, + { url = "https://files.pythonhosted.org/packages/c0/21/1084b5b609f9abed62070ec0b31c283a403832a6310c8bbc208bd45ee1e6/ty-0.0.29-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1c9e06b770c1d0ff5efc51e34312390db31d53fcf3088163f413030b42b74f84", size = 10599187, upload-time = "2026-04-05T15:01:23.52Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a1/ce19a2ca717bbcc1ee11378aba52ef70b6ce5b87245162a729d9fdc2360f/ty-0.0.29-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0307fe37e3f000ef1a4ae230bbaf511508a78d24a5e51b40902a21b09d5e6037", size = 11121198, upload-time = "2026-04-05T15:01:15.22Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6b/f1430b279af704321566ce7ec2725d3d8258c2f815ebd93e474c64cd4543/ty-0.0.29-py3-none-win32.whl", hash = "sha256:7a2a898217960a825f8bc0087e1fdbaf379606175e98f9807187221d53a4a8ed", size = 9995331, upload-time = "2026-04-05T15:01:01.32Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ef/3ef01c17785ff9a69378465c7d0faccd48a07b163554db0995e5d65a5a23/ty-0.0.29-py3-none-win_amd64.whl", hash = "sha256:fc1294200226b91615acbf34e0a9ad81caf98c081e9c6a912a31b0a7b603bc3f", size = 11023644, upload-time = "2026-04-05T15:01:04.432Z" }, + { url = "https://files.pythonhosted.org/packages/2c/55/87280a994d6a2d2647c65e12abbc997ed49835794366153c04c4d9304d76/ty-0.0.29-py3-none-win_arm64.whl", hash = "sha256:f9794bbd1bb3ce13f78c191d0c89ae4c63f52c12b6daa0c6fe220b90d019d12c", size = 10428165, upload-time = "2026-04-05T15:01:34.665Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]