mirror of
https://github.com/encounter/nod.git
synced 2026-07-10 12:18:48 -07:00
Add Python Bindings (#14)
* GC fixes from pr #13 * Fix reading boot.bin * Fix for building gc iso * Fix game title in apply_overrides * Format * Add python binding * Add DiscWriter * Update pyo3 * Fix missing callbacks * Fix process description * Refuse untyped arguments * Replace open_disc with just using DiscReader directly * Add DiscPatcher * format * Add support for changing headers * PyPartitionReader.__repr__ * read_file now opens a file * Add set_dol * Rename to nod-rs * Format * lock file * Remove unused import * Format --------- Co-authored-by: Luke Street <luke@street.dev>
This commit is contained in:
co-authored by
Luke Street
parent
5cf64cf684
commit
047ab46da6
@@ -2,5 +2,7 @@
|
||||
/target
|
||||
/vendor
|
||||
.idea
|
||||
/.env
|
||||
__pycache__
|
||||
.cargo
|
||||
*.tar.zst
|
||||
|
||||
Generated
+71
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
+417
@@ -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: ...
|
||||
@@ -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
|
||||
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
|
||||
+1154
File diff suppressed because it is too large
Load Diff
Generated
+210
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user