diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11b48ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: uv sync --dev + - name: Run tests + run: uv run pytest -v + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Install dependencies + run: uv sync --dev + - name: Run ruff + run: uv run ruff check src tests diff --git a/.gitignore b/.gitignore index e0ec6d5..4df9aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,6 @@ cython_debug/ .censored/ settings.toml .vscode/ + +# Nix build output +result diff --git a/README.md b/README.md index e639ce1..176843a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ add_pack("https://omid.gg/THC", packs, courses) `itg-cli` will generate a config file if one is not found at the default location. This location and the default values vary depending on the platform. -The location of the created config file will be output by after it's created. +The location of the created config file will be printed after it's created. The `init-config` command is used to write a default config file to a location of your choice (or the default location if none is supplied). You can supply @@ -111,7 +111,7 @@ MacOS: ~/Library/Application Support/ITGMania This project is my first published/marketed open source project, so I'm still learning how all this works in practice. That being said, If you run into any bugs -or have ideas for new features, please feel free to create an [issue](https://github.com/lucdar/itg-cli/issues) or [pull request](https://github.com/lucdar/itg-cli/pulls). +or have ideas for new features, please feel free to create an [issue](https://github.com/celex3/itg-cli/issues) or [pull request](https://github.com/celex3/itg-cli/pulls). ## License diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..13ef078 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1783224372, + "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d407951447dcd00442e97087bf374aad70c04cea", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..212f3bf --- /dev/null +++ b/flake.nix @@ -0,0 +1,140 @@ +{ + description = "A CLI tool that automates common administrative tasks for ITGmania/StepMania"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + forAllSystems = nixpkgs.lib.genAttrs [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + # Parse the version from __init__.py so it stays in sync + version = builtins.elemAt (builtins.match ".*__version__ = \"([^\"]+)\".*" ( + builtins.readFile ./src/itg_cli/__init__.py + )) 0; + in + { + packages = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + python = pkgs.python312; # itg-cli requires python >=3.10,<3.13 + + # Dependencies not available in nixpkgs, built from PyPI sdists. + # Hashes come from uv.lock (converted with `nix hash convert`). + msdparser = python.pkgs.buildPythonPackage rec { + pname = "msdparser"; + version = "2.0.0"; + pyproject = true; + src = python.pkgs.fetchPypi { + inherit pname version; + hash = "sha256-m7bPS3BcdoULHOIoI8dotiuiu2PRwkB7vj4sI+OL6OM="; + }; + build-system = [ python.pkgs.setuptools ]; + # sdist ships no tests + doCheck = false; + }; + + simfile = python.pkgs.buildPythonPackage rec { + pname = "simfile"; + version = "2.1.1"; + pyproject = true; + src = python.pkgs.fetchPypi { + inherit pname version; + hash = "sha256-p4hfg5XN0PGc152jTlZ12neKoOjdp2VDzwdeIbhixLc="; + }; + build-system = [ python.pkgs.setuptools ]; + dependencies = [ + msdparser + python.pkgs.fs + ]; + # tests require pyfakefs test fixtures not shipped in the sdist + doCheck = false; + }; + + pyrfc6266 = python.pkgs.buildPythonPackage rec { + pname = "pyrfc6266"; + version = "1.0.2"; + pyproject = true; + src = python.pkgs.fetchPypi { + inherit pname version; + hash = "sha256-PEFha2ofLpom338AX7qmNPlgEhdpzMREWs+0BOn4/Uw="; + }; + build-system = [ python.pkgs.setuptools ]; + dependencies = [ python.pkgs.pyparsing ]; + # upstream pins pyparsing~=3.0.7; newer versions work fine + pythonRelaxDeps = [ "pyparsing" ]; + doCheck = false; + }; + + itg-cli = python.pkgs.buildPythonApplication { + pname = "itg-cli"; + inherit version; + pyproject = true; + src = ./.; + + build-system = [ python.pkgs.setuptools ]; + + dependencies = + [ + simfile + pyrfc6266 + ] + ++ (with python.pkgs; [ + gdown + requests + rich + setuptools + tomlkit + tqdm + typer + ]); + + # nixpkgs ships setuptools >=80; the <80 pin only matters for + # unpatched PyPI installs (pkg_resources compatibility) + pythonRelaxDeps = [ "setuptools" ]; + + # fs emits a pkg_resources deprecation warning on import with + # setuptools >=81; silence it so every CLI run isn't noisy + makeWrapperArgs = [ + "--set-default PYTHONWARNINGS ignore::UserWarning:fs" + ]; + + nativeCheckInputs = [ python.pkgs.pytestCheckHook ]; + + meta = { + description = "A CLI tool that automates common administrative tasks for ITGmania/StepMania"; + homepage = "https://github.com/celex3/itg-cli"; + license = nixpkgs.lib.licenses.mit; + mainProgram = "itg-cli"; + }; + }; + in + { + default = itg-cli; + inherit itg-cli; + } + ); + + devShells = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = pkgs.mkShell { + packages = [ + pkgs.uv + pkgs.python312 + ]; + }; + } + ); + }; +} diff --git a/pyproject.toml b/pyproject.toml index 8c5b521..5933c89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,15 @@ dependencies = [ "simfile==2.1.1", "gdown>=5.2.0", "pyrfc6266>=1.0.2", + "requests>=2.32.0", + "rich>=13.0.0", "tomlkit>=0.13.2", + "tqdm>=4.66.0", "typer>=0.12.5", + "setuptools<80.0.0", ] -requires-python = ">=3.10" -authors = [{name = "Lucas Clark"}] +requires-python = ">=3.10,<3.13" +authors = [{name = "Celeste Clark"}] keywords = ["itg", "cli", "python", "itgmania", "stepmania"] classifiers = [ "Development Status :: 3 - Alpha", @@ -20,7 +24,7 @@ readme = "README.md" license = {file = "LICENSE"} [project.urls] -Repository = "https://github.com/lucdar/itg-cli" +Repository = "https://github.com/celex3/itg-cli" [build-system] requires = ["setuptools"] @@ -34,3 +38,16 @@ version = {attr = "itg_cli.__version__"} [tool.setuptools.package-data] itg_cli = ["config_template.toml"] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "ruff>=0.8", +] + +[tool.ruff] +line-length = 79 +src = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/itg_cli/__init__.py b/src/itg_cli/__init__.py index d93b605..aac38ad 100644 --- a/src/itg_cli/__init__.py +++ b/src/itg_cli/__init__.py @@ -1,11 +1,11 @@ from itg_cli.commands import ( + OverwriteException, + UncensorException, add_pack, add_song, censor, get_censored, uncensor, - OverwriteException, - UncensorException, ) __all__ = [ @@ -17,4 +17,4 @@ "OverwriteException", "UncensorException", ] -__version__ = "1.0.4" +__version__ = "1.0.8" diff --git a/src/itg_cli/__main__.py b/src/itg_cli/__main__.py index ac4503a..4d2e7e2 100644 --- a/src/itg_cli/__main__.py +++ b/src/itg_cli/__main__.py @@ -1,4 +1,3 @@ -import sys import typer from pathlib import Path from rich.columns import Columns @@ -8,8 +7,15 @@ from simfile.dir import SimfilePack from simfile.types import Simfile from typing import Annotated, Callable, Optional, TypeAlias, TypeVar -from itg_cli import * -from itg_cli import __version__ +from itg_cli import ( + OverwriteException, + UncensorException, + __version__, + add_pack, + add_song, + censor, + uncensor, +) from itg_cli._config import CLISettings DEFAULT_CONFIG_PATH = Path(typer.get_app_dir("itg-cli")) / "config.toml" @@ -69,7 +75,6 @@ def song_overwrite_handler( help="automatically overwrite without confirming", ), ] -cli = typer.Typer(no_args_is_help=True) ## Version Flag ## @@ -81,6 +86,7 @@ def version_callback(run: bool): @cli.callback() def typer_entry( + ctx: typer.Context, version: Annotated[ bool, typer.Option( @@ -92,7 +98,10 @@ def typer_entry( ] = False, ): # Initialize config if it doesn't exist - if not DEFAULT_CONFIG_PATH.exists() and "init-config" not in sys.argv: + if ( + not DEFAULT_CONFIG_PATH.exists() + and ctx.invoked_subcommand != "init-config" + ): init_config_command(DEFAULT_CONFIG_PATH) @@ -110,7 +119,7 @@ def init_config_command( path.exists() and not overwrite and not Confirm.ask( - f"Overwrite existing config with default?", default=True + "Overwrite existing config with default?", default=False ) ): print(f"[green]Keeping existing config file: [bright_white]{path}") diff --git a/src/itg_cli/_config.py b/src/itg_cli/_config.py index 2cba3ab..be87d40 100644 --- a/src/itg_cli/_config.py +++ b/src/itg_cli/_config.py @@ -49,7 +49,7 @@ def __init__(self, toml: Path, write_default: bool = False): raise ConfigError(f"Missing table ({table}) in config: {self.location}") required = toml_doc["required"] for key in ["root", "singles_pack_name", "delete_macos_files"]: - if required.get(key) is None: + if required.get(key) is None or required.get(key) == "": raise ConfigError( f"Required field ({key}) is empty or unbound in config: {self.location}" ) @@ -79,7 +79,12 @@ def __write_default_toml(self, toml: Path) -> None: root, cache = None, None match platform.system(): case "Windows": - root = Path(os.getenv("APPDATA")) / "ITGmania" + appdata = os.getenv("APPDATA") + if not appdata: + raise ConfigError( + "APPDATA environment variable is not set." + ) + root = Path(appdata) / "ITGmania" case "Linux": # TODO: verify/add new locations root = Path.home() / ".itgmania" diff --git a/src/itg_cli/_utils.py b/src/itg_cli/_utils.py index 8b099b2..a87ae35 100644 --- a/src/itg_cli/_utils.py +++ b/src/itg_cli/_utils.py @@ -41,10 +41,10 @@ def extract(archive_path: Path) -> Path: formats: `zip, tar, gztar, bztar, xztar` """ - valid_suffixes = [".zip", ".tar", ".xz", ".bz", ".xz"] + valid_suffixes = [".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz"] if archive_path.suffix not in valid_suffixes: raise ValueError( - "Invalid or unsupported archive format: {archive_path.suffix}" + f"Invalid or unsupported archive format: {archive_path.suffix}" ) dest = archive_path.with_suffix("") dest.mkdir() @@ -108,10 +108,17 @@ def download_file(url: str, downloads: Path) -> Path: fuzzy=True, output=os.path.join(downloads, ""), # Append trailing `/` ) + if download_path is None: + raise Exception( + f"Google Drive download failed for {url}. The file may be " + "access-restricted or subject to a download quota." + ) return Path(download_path) else: # try using requests print(f"Making request to {url}...", file=sys.stderr) - response = requests.get(url, allow_redirects=True, stream=True) + response = requests.get( + url, allow_redirects=True, stream=True, timeout=30 + ) parsed_redirected_url = urlparse(response.url) if parsed_redirected_url.netloc != parsed_url.netloc: # potential case where redirected url is a gdrive link @@ -125,8 +132,16 @@ def download_file(url: str, downloads: Path) -> Path: return dest +VALID_CONTENT_TYPES = [ + "application/zip", + "application/x-zip-compressed", + "application/octet-stream", +] + + def validate_response( - r: requests.Response, valid_content_types: list[str] = ["application/zip"] + r: requests.Response, + valid_content_types: list[str] = VALID_CONTENT_TYPES, ) -> None: """ Validates a request response. @@ -142,8 +157,9 @@ def validate_response( ) if "Content-Type" not in r.headers: raise Exception("No Content-Type header found") - if r.headers["Content-Type"] not in valid_content_types: - raise Exception("Invalid Content-Type:", r.headers["Content-Type"]) + content_type = r.headers["Content-Type"].split(";")[0].strip() + if content_type not in valid_content_types: + raise Exception(f"Invalid Content-Type: {content_type}") def get_download_filename(r: requests.Response) -> str: @@ -152,7 +168,11 @@ def get_download_filename(r: requests.Response) -> str: url's basename, or defaults to "download.zip" """ if "Content-Disposition" in r.headers: - return Path(pyrfc6266.parse_filename(r.headers["Content-Disposition"])) + parsed = pyrfc6266.parse_filename(r.headers["Content-Disposition"]) + # Take the basename in case the header contains path separators + name = Path(parsed.replace("\\", "/")).name + if name: + return name name = os.path.basename(r.url) if name.endswith(".zip"): return name diff --git a/src/itg_cli/commands.py b/src/itg_cli/commands.py index 1e1c2ef..5c0d84c 100644 --- a/src/itg_cli/commands.py +++ b/src/itg_cli/commands.py @@ -20,7 +20,7 @@ class OverwriteException(Exception): - """Rasied when an existing pack or simfile is not overwritten.""" + """Raised when an existing pack or simfile is not overwritten.""" class UncensorException(Exception): @@ -79,7 +79,6 @@ def add_pack( if delete_macos_files_flag: delete_macos_files(pack_path) pack = SimfilePack(pack_path) - songs = list(pack.simfiles(strict=False)) # check if pack already exists dest = packs.joinpath(pack_path.name) @@ -90,11 +89,12 @@ def add_pack( raise OverwriteException("Pack already exists.") shutil.rmtree(dest) - # look for a Courses folder countaining .crs files + # look for a Courses folder containing .crs files num_courses = 0 - courses_subfolder = courses.joinpath(pack.name) - courses_subfolder.mkdir(exist_ok=True) crs_parent_dirs = {p.parent for p in working_dir.rglob("*.crs")} + if crs_parent_dirs: + courses_subfolder = courses.joinpath(pack.name) + courses_subfolder.mkdir(exist_ok=True) for crs_parent_dir in crs_parent_dirs: for file in filter(Path.is_file, crs_parent_dir.iterdir()): file.replace(courses_subfolder.joinpath(file.name)) @@ -170,7 +170,7 @@ def add_song( shutil.move(simfile_root, dest) if delete_macos_files_flag: - delete_macos_files(simfile_root) + delete_macos_files(dest) return simfile.opendir(dest, strict=False) @@ -180,7 +180,8 @@ def censor(path: Path, packs: Path, cache: Path) -> Simfile: players. `path` must be a subdirectory of `packs` or an exception will be raised. """ - path = path.absolute() + path = path.resolve() + packs = packs.resolve() # Validate supplied path to sm folder if not path.exists(): raise FileNotFoundError(f"{path} does not exist") @@ -193,6 +194,7 @@ def censor(path: Path, packs: Path, cache: Path) -> Simfile: # Move the simfile to the censored folder under the same pack subdirectory pack_and_song = path.relative_to(packs) destination = packs / ".censored" / pack_and_song + destination.parent.mkdir(parents=True, exist_ok=True) shutil.move(path, destination) # Remove the song's cache entry if it exists cache_entry = "_".join([packs.name, path.parent.name, path.name]) @@ -207,6 +209,8 @@ def get_censored(packs: Path) -> list[tuple[Simfile, str]]: """ out = [] censored = packs / ".censored" + if not censored.is_dir(): + return out for pack in filter(Path.is_dir, censored.iterdir()): for simfile_path in filter(Path.is_dir, pack.iterdir()): out.append(simfile.opendir(simfile_path, strict=False)) @@ -248,6 +252,7 @@ def uncensor( chosen_path = Path(censored[picker(censored)][1]).parent pack_and_song = chosen_path.relative_to(packs / ".censored") destination = packs.joinpath(pack_and_song) + destination.parent.mkdir(parents=True, exist_ok=True) shutil.move(chosen_path, destination) return simfile.opendir(destination)[0] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ec288b0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +from pathlib import Path + +import pytest + +SM_TEMPLATE = """#TITLE:{title}; +#ARTIST:Test Artist; +#OFFSET:0.000; +#BPMS:0.000=120.000; +#NOTES: + dance-single: + : + Beginner: + 1: + 0.000,0.000,0.000,0.000,0.000: +0000 +0000 +0000 +0000 +; +""" + + +def make_song(parent: Path, name: str, title: str | None = None) -> Path: + """Creates a song folder containing a minimal .sm file. Returns its path.""" + song_dir = parent / name + song_dir.mkdir(parents=True) + sm_path = song_dir / f"{name}.sm" + sm_path.write_text(SM_TEMPLATE.format(title=title or name)) + return song_dir + + +def make_pack(parent: Path, name: str, song_names: list[str]) -> Path: + """Creates a pack folder containing the given songs. Returns its path.""" + pack_dir = parent / name + pack_dir.mkdir(parents=True) + for song_name in song_names: + make_song(pack_dir, song_name) + return pack_dir + + +@pytest.fixture +def library(tmp_path: Path) -> dict[str, Path]: + """An itgmania-like directory layout with packs, courses, and cache.""" + dirs = { + "packs": tmp_path / "Songs", + "courses": tmp_path / "Courses", + "cache": tmp_path / "Cache", + "src": tmp_path / "src", # holds input packs/songs for tests + } + for d in dirs.values(): + d.mkdir() + (dirs["cache"] / "Songs").mkdir() + return dirs diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..5bacebe --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,193 @@ +import shutil +from pathlib import Path + +import pytest +from conftest import make_pack, make_song +from itg_cli import ( + OverwriteException, + UncensorException, + add_pack, + add_song, + censor, + get_censored, + uncensor, +) + + +## add_pack ## +def test_add_pack_from_dir(library): + src_pack = make_pack(library["src"], "Test Pack", ["Song A", "Song B"]) + pack, num_courses = add_pack( + str(src_pack), library["packs"], library["courses"] + ) + dest = library["packs"] / "Test Pack" + assert dest.is_dir() + assert (dest / "Song A" / "Song A.sm").is_file() + assert len(list(pack.simfiles(strict=False))) == 2 + assert num_courses == 0 + # Local source is copied, not moved + assert src_pack.is_dir() + + +def test_add_pack_from_zip(library): + src_pack = make_pack(library["src"], "Zipped Pack", ["Song A"]) + zip_base = library["src"] / "archive" / "Zipped Pack" + zip_base.parent.mkdir() + zip_path = shutil.make_archive( + zip_base, "zip", src_pack.parent, "Zipped Pack" + ) + add_pack(zip_path, library["packs"], library["courses"]) + assert (library["packs"] / "Zipped Pack" / "Song A" / "Song A.sm").is_file() + + +def test_add_pack_with_courses(library): + src_pack = make_pack(library["src"], "Course Pack", ["Song A"]) + courses_dir = src_pack / "Courses" + courses_dir.mkdir() + (courses_dir / "marathon.crs").write_text("#COURSE:Test Marathon;") + _, num_courses = add_pack( + str(src_pack), library["packs"], library["courses"] + ) + assert num_courses == 1 + assert (library["courses"] / "Course Pack" / "marathon.crs").is_file() + + +def test_add_pack_no_courses_subfolder_when_pack_has_none(library): + src_pack = make_pack(library["src"], "Plain Pack", ["Song A"]) + add_pack(str(src_pack), library["packs"], library["courses"]) + assert not (library["courses"] / "Plain Pack").exists() + + +def test_add_pack_empty_raises(library): + empty = library["src"] / "Empty Pack" + empty.mkdir() + with pytest.raises(Exception, match="No packs found"): + add_pack(str(empty), library["packs"], library["courses"]) + + +def test_add_pack_overwrite_declined(library): + src_pack = make_pack(library["src"], "Dupe Pack", ["Song A", "Song B"]) + add_pack(str(src_pack), library["packs"], library["courses"]) + with pytest.raises(OverwriteException): + add_pack(str(src_pack), library["packs"], library["courses"]) + # Existing pack is untouched + assert (library["packs"] / "Dupe Pack" / "Song A").is_dir() + + +def test_add_pack_overwrite_accepted(library): + old_pack = make_pack(library["src"], "Dupe Pack", ["Old Song"]) + add_pack(str(old_pack), library["packs"], library["courses"]) + shutil.rmtree(old_pack) + new_pack = make_pack(library["src"], "Dupe Pack", ["New Song"]) + add_pack( + str(new_pack), + library["packs"], + library["courses"], + overwrite=lambda _new, _old: True, + ) + dest = library["packs"] / "Dupe Pack" + assert (dest / "New Song").is_dir() + assert not (dest / "Old Song").exists() + + +## add_song ## +def test_add_song_from_dir(library): + src_song = make_song(library["src"], "Solo Song") + singles = library["packs"] / "Singles" # does not exist yet + sf, loc = add_song(str(src_song), singles) + assert (singles / "Solo Song" / "Solo Song.sm").is_file() + assert sf.title == "Solo Song" + assert Path(loc).is_file() + # Local source is copied, not moved + assert src_song.is_dir() + + +def test_add_song_multiple_raises(library): + src_pack = make_pack(library["src"], "Two Songs", ["Song A", "Song B"]) + with pytest.raises(Exception, match="More than one simfile"): + add_song(str(src_pack), library["packs"] / "Singles") + + +def test_add_song_overwrite_declined(library): + src_song = make_song(library["src"], "Dupe Song") + singles = library["packs"] / "Singles" + add_song(str(src_song), singles) + with pytest.raises(OverwriteException): + add_song(str(src_song), singles) + + +def test_add_song_overwrite_accepted_clears_cache(library): + src_song = make_song(library["src"], "Dupe Song") + singles = library["packs"] / "Singles" + add_song(str(src_song), singles) + cache_entry = library["cache"] / "Songs" / "Songs_Singles_Dupe Song" + cache_entry.write_text("stale cache data") + add_song( + str(src_song), + singles, + cache=library["cache"], + overwrite=lambda _new, _old: True, + ) + assert (singles / "Dupe Song" / "Dupe Song.sm").is_file() + assert not cache_entry.exists() + + +def test_add_song_deletes_macos_files_at_dest(library): + """Regression test: ._ files must be deleted from the *destination*.""" + src_song = make_song(library["src"], "Mac Song") + (src_song / "._Mac Song.sm").write_bytes(b"\x00\x05\x16\x07") + singles = library["packs"] / "Singles" + add_song(str(src_song), singles, delete_macos_files_flag=True) + assert (singles / "Mac Song" / "Mac Song.sm").is_file() + assert not (singles / "Mac Song" / "._Mac Song.sm").exists() + + +## censor / uncensor ## +def test_censor_creates_censored_dir(library): + """Regression test: censor must work when .censored does not exist yet.""" + src_pack = make_pack(library["src"], "Pack", ["Bad Song"]) + add_pack(str(src_pack), library["packs"], library["courses"]) + song_path = library["packs"] / "Pack" / "Bad Song" + sm = censor(song_path, library["packs"], library["cache"]) + assert sm.title == "Bad Song" + assert not song_path.exists() + assert (library["packs"] / ".censored" / "Pack" / "Bad Song").is_dir() + + +def test_censor_outside_packs_raises(library): + stray_song = make_song(library["src"], "Stray Song") + with pytest.raises(Exception, match="is not a pack in"): + censor(stray_song, library["packs"], library["cache"]) + + +def test_censor_missing_path_raises(library): + with pytest.raises(FileNotFoundError): + censor( + library["packs"] / "Pack" / "Ghost Song", + library["packs"], + library["cache"], + ) + + +def test_get_censored_empty(library): + assert get_censored(library["packs"]) == [] + + +def test_uncensor_no_censored_raises(library): + """Regression test: must raise UncensorException, not FileNotFoundError.""" + with pytest.raises(UncensorException): + uncensor(library["packs"]) + + +def test_censor_uncensor_roundtrip(library): + src_pack = make_pack(library["src"], "Pack", ["Bad Song"]) + add_pack(str(src_pack), library["packs"], library["courses"]) + song_path = library["packs"] / "Pack" / "Bad Song" + censor(song_path, library["packs"], library["cache"]) + assert not song_path.exists() + + censored = get_censored(library["packs"]) + assert len(censored) == 1 + sm = uncensor(library["packs"], picker=lambda songs: 0) + assert sm.title == "Bad Song" + assert song_path.is_dir() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1a2e37c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,82 @@ +from pathlib import Path + +import pytest +from itg_cli._config import CLISettings, ConfigError + +CONFIG_TEMPLATE = """version = 1 + +[required] +root = '{root}' +singles_pack_name = 'Singles' +delete_macos_files = false + +[optional] +downloads = '' +packs = '' +courses = '' +cache = '' +""" + + +@pytest.fixture +def itg_root(tmp_path: Path) -> Path: + root = tmp_path / "ITGmania" + for sub in ["Songs", "Courses", "Cache"]: + (root / sub).mkdir(parents=True) + return root + + +def write_config(tmp_path: Path, content: str) -> Path: + cfg = tmp_path / "config.toml" + cfg.write_text(content) + return cfg + + +def test_valid_config(tmp_path, itg_root): + cfg = write_config(tmp_path, CONFIG_TEMPLATE.format(root=itg_root)) + settings = CLISettings(cfg) + assert settings.root == itg_root + assert settings.packs == itg_root / "Songs" + assert settings.courses == itg_root / "Courses" + assert settings.cache == itg_root / "Cache" + assert settings.singles == itg_root / "Songs" / "Singles" + assert settings.downloads is None + assert settings.delete_macos_files is False + + +def test_missing_config_raises(tmp_path): + with pytest.raises(FileNotFoundError): + CLISettings(tmp_path / "nonexistent.toml") + + +def test_empty_root_raises(tmp_path): + cfg = write_config(tmp_path, CONFIG_TEMPLATE.format(root="")) + with pytest.raises(ConfigError, match="root"): + CLISettings(cfg) + + +def test_missing_table_raises(tmp_path): + cfg = write_config(tmp_path, "version = 1\n[required]\nroot = '/tmp'\n") + with pytest.raises(ConfigError, match="optional"): + CLISettings(cfg) + + +def test_nonexistent_dirs_raise(tmp_path): + cfg = write_config( + tmp_path, CONFIG_TEMPLATE.format(root=tmp_path / "nowhere") + ) + with pytest.raises(ConfigError, match="Invalid fields"): + CLISettings(cfg) + + +def test_write_default(tmp_path, itg_root, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Default Linux root is ~/.itgmania; point it at our fake install + itgmania = tmp_path / ".itgmania" + itg_root.rename(itgmania) + cfg_path = tmp_path / "config" / "config.toml" + settings = CLISettings(cfg_path, write_default=True) + assert cfg_path.is_file() + assert settings.root == itgmania + assert settings.packs == itgmania / "Songs" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..cdd1014 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,131 @@ +import shutil +from types import SimpleNamespace + +import pytest +from conftest import make_pack +from itg_cli._utils import ( + delete_macos_files, + extract, + get_download_filename, + simfile_paths, + validate_response, +) + + +## simfile_paths ## +def test_simfile_paths_finds_sm_and_ssc(tmp_path): + (tmp_path / "a").mkdir() + (tmp_path / "a" / "a.sm").touch() + (tmp_path / "b").mkdir() + (tmp_path / "b" / "b.ssc").touch() + found = {p.name for p in simfile_paths(tmp_path)} + assert found == {"a.sm", "b.ssc"} + + +def test_simfile_paths_filters_macosx_and_hidden(tmp_path): + (tmp_path / "__MACOSX" / "a").mkdir(parents=True) + (tmp_path / "__MACOSX" / "a" / "a.sm").touch() + (tmp_path / "b").mkdir() + (tmp_path / "b" / "._b.sm").touch() + (tmp_path / "b" / "b.sm").touch() + found = {p.name for p in simfile_paths(tmp_path)} + assert found == {"b.sm"} + + +## delete_macos_files ## +def test_delete_macos_files(tmp_path): + (tmp_path / "song").mkdir() + (tmp_path / "song" / "._song.sm").touch() + (tmp_path / "song" / "song.sm").touch() + delete_macos_files(tmp_path) + assert not (tmp_path / "song" / "._song.sm").exists() + assert (tmp_path / "song" / "song.sm").exists() + + +## extract ## +def test_extract_zip(tmp_path): + pack = make_pack(tmp_path / "input", "Pack", ["Song A"]) + zip_path = shutil.make_archive( + tmp_path / "Pack", "zip", pack.parent, "Pack" + ) + dest = extract(pack.parent.parent / "Pack.zip") + assert dest == tmp_path / "Pack" + assert (dest / "Pack" / "Song A" / "Song A.sm").is_file() + assert zip_path # silence unused warning + + +def test_extract_targz(tmp_path): + pack = make_pack(tmp_path / "input", "Pack", ["Song A"]) + shutil.make_archive(tmp_path / "Pack", "gztar", pack.parent, "Pack") + dest = extract(tmp_path / "Pack.tar.gz") + assert (dest / "Pack" / "Song A" / "Song A.sm").is_file() + + +def test_extract_invalid_suffix_raises(tmp_path): + bogus = tmp_path / "pack.rar" + bogus.touch() + # Message must interpolate the actual suffix (f-string regression test) + with pytest.raises(ValueError, match=r"\.rar"): + extract(bogus) + + +## validate_response ## +def make_response(content_type="application/zip", status=200): + return SimpleNamespace( + status_code=status, + headers={"Content-Type": content_type}, + url="https://example.com/pack.zip", + ) + + +def test_validate_response_ok(): + validate_response(make_response()) + + +def test_validate_response_content_type_with_params(): + validate_response(make_response("application/zip; charset=binary")) + + +def test_validate_response_octet_stream(): + validate_response(make_response("application/octet-stream")) + + +def test_validate_response_html_raises(): + with pytest.raises(Exception, match="Invalid Content-Type"): + validate_response(make_response("text/html")) + + +def test_validate_response_bad_status_raises(): + with pytest.raises(Exception, match="status 404"): + validate_response(make_response(status=404)) + + +## get_download_filename ## +def test_filename_from_content_disposition(): + r = SimpleNamespace( + headers={"Content-Disposition": 'attachment; filename="pack.zip"'}, + url="https://example.com/download", + ) + assert get_download_filename(r) == "pack.zip" + + +def test_filename_contains_no_path_separators(): + r = SimpleNamespace( + headers={ + "Content-Disposition": 'attachment; filename="../../evil.zip"' + }, + url="https://example.com/download", + ) + name = get_download_filename(r) + assert "/" not in name and "\\" not in name + assert name.endswith("evil.zip") + + +def test_filename_from_url(): + r = SimpleNamespace(headers={}, url="https://example.com/pack.zip") + assert get_download_filename(r) == "pack.zip" + + +def test_filename_fallback(): + r = SimpleNamespace(headers={}, url="https://example.com/download?id=42") + assert get_download_filename(r) == "download.zip" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..842b12b --- /dev/null +++ b/uv.lock @@ -0,0 +1,522 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.13" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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" }, +] +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 = "filelock" +version = "3.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/8a/b24ff2c2d7f20ce930b5efe91e7260247d185d8939707721168ad204e465/filelock-3.24.1.tar.gz", hash = "sha256:3440181dd03f8904c108c8e9f5b11d1663e9fc960f1c837586a11f1c5c041e54", size = 37452, upload-time = "2026-02-15T22:03:16.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/64/3613e89811e79aca8d0d4f2c984fc66336bc9d83529c1cbe02f5df010d0a/filelock-3.24.1-py3-none-any.whl", hash = "sha256:7c59f595e3cf4887dc95b403a896849da49ed183d7c9d7ee855646ca99f10698", size = 24153, upload-time = "2026-02-15T22:03:15.262Z" }, +] + +[[package]] +name = "fs" +version = "2.4.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appdirs" }, + { name = "setuptools" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441, upload-time = "2022-05-02T09:25:54.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261, upload-time = "2022-05-02T09:25:52.363Z" }, +] + +[[package]] +name = "gdown" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "filelock" }, + { name = "requests", extra = ["socks"] }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/cf/919a9fa16faf8e4572a24d941353edaf4d54e3ddcd048e6c1aeb8c7a9903/gdown-5.2.1.tar.gz", hash = "sha256:247c2ad1f579db5b66b54c04e6a871995fc8fd7021708b950b8ba7b32cf90323", size = 284743, upload-time = "2026-01-11T09:34:01.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/21/35dd0a0b7428bd67b12b358d7b4277f693493a3839b071d540a4c8357b78/gdown-5.2.1-py3-none-any.whl", hash = "sha256:391f0480d495fb87644d1a1ee3ddfeb2144e1de31408fbc74f7e3b3ba927052b", size = 18241, upload-time = "2026-01-11T09:34:02.637Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[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 = "itg-cli" +source = { editable = "." } +dependencies = [ + { name = "gdown" }, + { name = "pyrfc6266" }, + { name = "requests" }, + { name = "rich" }, + { name = "setuptools" }, + { name = "simfile" }, + { name = "tomlkit" }, + { name = "tqdm" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "gdown", specifier = ">=5.2.0" }, + { name = "pyrfc6266", specifier = ">=1.0.2" }, + { name = "requests", specifier = ">=2.32.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "setuptools", specifier = "<80.0.0" }, + { name = "simfile", specifier = "==2.1.1" }, + { name = "tomlkit", specifier = ">=0.13.2" }, + { name = "tqdm", specifier = ">=4.66.0" }, + { name = "typer", specifier = ">=0.12.5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msdparser" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a0/554a03cedc509c00440897c4e9e668c23f3970db9a79b66123212eab336c/msdparser-2.0.0.tar.gz", hash = "sha256:9bb6cf4b705c76850b1ce22823c768b62ba2bb63d1c2407bbe3e2c23e38be8e3", size = 6792, upload-time = "2022-07-23T22:54:12.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/78/4df3deda184b573055ac6ba4a7a8fa4084844d4f98b42550f3673550baff/msdparser-2.0.0-py3-none-any.whl", hash = "sha256:bbef8c89b2dc16183ba714354b093fff11a43469971c42972284c5046826002d", size = 8949, upload-time = "2022-07-23T22:54:11.199Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/22/207523d16464c40a0310d2d4d8926daffa00ac1f5b1576170a32db749636/pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", size = 1999906, upload-time = "2022-05-10T23:26:05.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/10/a7d0fa5baea8fe7b50f448ab742f26f52b80bfca85ac2be9d35cdd9a3246/pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc", size = 98338, upload-time = "2022-05-10T23:26:03.201Z" }, +] + +[[package]] +name = "pyrfc6266" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/ef/693fe33d34317facaf26a5716f965dac492d0f47e56e90bf310d7d4e74df/pyrfc6266-1.0.2.tar.gz", hash = "sha256:3c41616b6a1f2e9a26df7f005fbaa634f960121769ccc4445acfb404e9f8fd4c", size = 4454, upload-time = "2022-04-29T14:53:23.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/fc/d416c1bfb54f86259f631fd9ff6a9b813f7050129a377d94c43500109479/pyrfc6266-1.0.2-py3-none-any.whl", hash = "sha256:0532307f319566f337dba97577dfaefe493c3e0c40ab211449ba4566fc2cf73d", size = 4729, upload-time = "2022-04-29T14:53:22.569Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "setuptools" +version = "79.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/71/b6365e6325b3290e14957b2c3a804a529968c77a049b2ed40c095f749707/setuptools-79.0.1.tar.gz", hash = "sha256:128ce7b8f33c3079fd1b067ecbb4051a66e8526e7b65f6cec075dfc650ddfa88", size = 1367909, upload-time = "2025-04-23T22:20:59.241Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/6d/b4752b044bf94cb802d88a888dc7d288baaf77d7910b7dedda74b5ceea0c/setuptools-79.0.1-py3-none-any.whl", hash = "sha256:e147c0549f27767ba362f9da434eab9c5dc0045d5304feb602a0af001089fc51", size = 1256281, upload-time = "2025-04-23T22:20:56.768Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "simfile" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fs" }, + { name = "msdparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/156a1913b0a6636dfc76ac8a28da9b8a9da859fc3bb8ea5a15176f0e077d/simfile-2.1.1.tar.gz", hash = "sha256:a7885f8395cdd0f19cd79da34e5675da778aa0e8dda76543cf075e21b862c4b7", size = 33848, upload-time = "2023-01-08T23:41:52.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/f9/b0c67f88f6aa15697cecb941ccfa7cb3377296ac14c8c9fceea326f05d81/simfile-2.1.1-py3-none-any.whl", hash = "sha256:133094a33cf6b841ca7e620880ef8354c56dd4aef6b2c77582de7f120f69c620", size = 42227, upload-time = "2023-01-08T23:41:51.887Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[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/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 = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typer" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, +] + +[[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" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +]