diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 07f72d6..0000000 --- a/.flake8 +++ /dev/null @@ -1,11 +0,0 @@ -[flake8] -max-line-length=120 -ignore = - E121, - E123, - E126, - E226, - E24, - E704, - W605 -exclude = ./tests diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..f25bf11 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,88 @@ +name: CI +on: + pull_request: + types: [opened, synchronize, reopened, edited] # edited: un-WIP starts CI + push: + branches: [develop, main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Cancel superseded PR runs. Never cancel a push run. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + lint: + if: >- + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + runs-on: ubuntu-latest + container: python:3.14 + env: + UV_PYTHON_DOWNLOADS: never + steps: + - name: Install node (Gitea actions are node programs) + run: apt-get update && apt-get install -y --no-install-recommends nodejs + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: git config --global --add safe.directory "$PWD" + - run: pip install uv + - run: uv sync --locked + - run: uv run ruff check + - run: uv run ruff format --check + + test: + if: >- + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + runs-on: ubuntu-latest + container: python:${{ matrix.python }} + strategy: + fail-fast: false + matrix: + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + env: + UV_PYTHON: ${{ matrix.python }} + UV_PYTHON_DOWNLOADS: never + steps: + - name: Install node (Gitea actions are node programs) + run: apt-get update && apt-get install -y --no-install-recommends nodejs + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # setuptools_scm needs tags + - run: git config --global --add safe.directory "$PWD" + - run: pip install uv + - run: uv sync --locked + - run: uv run pytest + - name: Build, install and smoke-test the wheel and the sdist + run: | + rm -rf dist && uv build + for dist in dist/*.whl dist/*.tar.gz; do + rm -rf /tmp/smoke && uv venv /tmp/smoke + uv pip install --python /tmp/smoke "$dist" + (cd / && /tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/smoke_import.py") + done + + ci-ok: + needs: [lint, test] + # always(): a plain needs: aggregate would be *skipped* when a gate fails, + # and Gitea reports skipped as success. The WIP gate stays. + if: >- + always() && ( + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + ) + runs-on: ubuntu-latest + steps: + - name: Require every gate to have succeeded + run: | + echo "lint=${{ needs.lint.result }} test=${{ needs.test.result }}" + test "${{ needs.lint.result }}" = success + test "${{ needs.test.result }}" = success diff --git a/.gitignore b/.gitignore index 09f98a2..78d3a6a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist *.egg-info __pycache__ .idea - +.venv/ +app_skellington/_version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b668dc5..c315d4e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v6.0.0 hooks: - id: check-yaml - id: check-added-large-files @@ -9,23 +9,10 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace -# isort -- sorts imports -- repo: https://github.com/timothycrosley/isort - rev: 5.13.2 +# ruff replaces black, isort and flake8. Keep rev equal to the ruff pin in pyproject.toml. +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.10 hooks: - - id: isort - args: ["--profile", "black", "--filter-files"] - -# Flake8 -- repo: https://github.com/pycqa/flake8 - rev: '7.0.0' - hooks: - - id: flake8 - -# Black -# Using this mirror lets us use mypyc-compiled black, which is about 2x faster -- repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.2.0 - hooks: - - id: black - language_version: python3.8 + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/.python-version b/.python-version index 43a8e59..6324d40 100644 --- a/.python-version +++ b/.python-version @@ -1,2 +1 @@ -3.8.20 -3.8.8 +3.14 diff --git a/CHANGELOG.md b/CHANGELOG.md index c56bd98..e417404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,25 @@ # Changelog -## [0.2.2] (2020-07-19) +## [0.2.3] -Second release is focused on cleanup, documentation. +Hotfix. In 0.2.2, `import app_skellington` raised +`ImportError: cannot import name 'NoCommandSpecified' from partially initialized module` +(a circular import) for every install. + + * Fix the circular import between `cli` and `app_container`. The framework + exceptions now live in `_util`, and the public names are unchanged. + * Tested on Linux, CPython 3.8–3.14 (Gitea CI). Each version builds the wheel + and sdist, installs them and smoke-tests the import. + * Tooling: ruff replaces black/isort/flake8; uv with a committed lockfile. + * Removed the `[dev]` extra: `pip install app_skellington[dev]` no longer + installs dev tools. Contributors use `uv sync` (a PEP 735 dependency group). + * Metadata: license classifier corrected to MIT-0; Python version classifiers + added; "OS Independent" replaced by Linux (Windows and macOS untested). + * README: the debug variable is `APPSKELLINGTON_DEBUG`. + +## [0.2.2] (2024-11-17) + +Second release is focused on cleanup, documentation. * flake8, black, isort - all warnings and errors fixed. * Documentation improved. @@ -19,4 +36,3 @@ First release to PyPi. * Config through ConfigObj ini. Define spec file and input config.ini * Colored Logging * Services can be defined and provided to classes - diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9332627 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`app_skellington` is a small Python library (published to PyPI) for building CLI applications. It has nothing runnable on its own. It glues together argparse-based multi-level command menus, name-based dependency injection, ConfigObj INI config with spec validation, and colorlog logging. It targets Python >=3.8. Only Linux is tested; Windows and macOS are goals. Runtime deps are `appdirs`, `configobj` and `colorlog`. + +## Commands + +```bash +uv sync # .venv with the package (editable) + dev group: ruff, pytest, pre-commit, pyyaml +uv run pytest # full suite (testpaths = tests) +uv run pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults # single test +uv run pytest -k "" +uv run --isolated --python 3.8 pytest # any supported Python, without switching .venv + +uv run ruff check --fix # lint + import sorting (config in pyproject.toml) +uv run ruff format # formatter, line length 88 + +uv build # sdist + wheel into dist/; the version shows in the file names +uv lock # after any dependency change; commit uv.lock +``` + +- **Versioning is tag-driven.** setuptools_scm uses `release-branch-semver` and derives the version from the latest `vX.Y.Z` git tag. It generates `app_skellington/_version.py`, which is git-ignored, so never commit it. The release steps (tag on `main`, `uv build`, TestPyPI, verify, PyPI) are in the README's Publish section. +- **ruff** handles formatting (88 columns) and lint (`E`, `F`, `W`, `I`). E501 is ignored on purpose, since the formatter owns line length. Keep the `ruff==` pin in `pyproject.toml` equal to the `rev:` in `.pre-commit-config.yaml`. +- Tests cover only `cfg` (ConfigObj fixtures live in `tests/cfg/`) plus a CommandTree constructor smoke test. `tests/test_log.py` is empty. + +## Python support policy + +`requires-python` is `>=3.8`, and the claim should match what CI actually tests. When an end-of-life Python version starts failing, drop it: raise `requires-python` and remove its CI lane. Don't add compat shims, version branches or contorted code just to keep it working. Clean code matters more than supporting EOL runtimes. Users on old Pythons keep the older releases on PyPI. + +## CI + +`.gitea/workflows/ci.yaml` runs on PRs and on pushes to `develop`/`main`. PRs titled `WIP:` are skipped; removing the prefix starts the run. +- `lint`: ruff. +- `test`: a matrix over CPython 3.8–3.14 in `python:` containers. Each lane runs the locked suite, then builds the wheel and sdist, installs each into a clean venv, and runs `scripts/smoke_import.py` from outside the source tree. +- `ci-ok`: the only required check (`CI / ci-ok (pull_request)`). It uses `if: always()` and fails unless every gate succeeded, because Gitea reports a skipped job as success. + +`tests/test_ci_workflow.py` pins these invariants. To add or drop a Python version, change all four together: the matrix, the classifiers, `scripts/verify_index_release.sh`'s list, and `NEWEST_MINOR` (or `requires-python`). + +## Architecture + +Package layout: `_bootstrap.py`, `_util.py`, `cfg.py`, `log.py`, `cli.py` and `app_container.py`. `__init__.py` re-exports the public names explicitly. + +**`ApplicationContainer` (app_container.py) is the entry point applications subclass.** Its `__init__` does the following, in order: +1. builds `cfg.Config(configspec_filepath, configini_filepath)` +2. builds `log.LoggingLayer` and configures it from the config's `[logging]` section, or from `DEFAULT_LOG_SETTINGS` if there is none +3. creates `ApplicationContext` (`self.ctx`, which holds config, log and parsed argv) +4. registers `ctx` as a service and creates `self.cli = CommandTree()` +5. calls the optional subclass hooks **`_cli_options()`, `_services()`, `_command_menu()`**, in that order, but only if they are defined + +Applications then call `invoke_from_cli()`, which parses argv and dispatches. + +**Dependency injection works by parameter name.** +- Services are registered as *factories*: `app["db"] = lambda: Db(...)`. +- `app["db"]` calls the factory each time, so factories decide whether services are singletons. +- An unknown name raises `ServiceNotFound`. +- `_inject_service_dependencies(cls)` reads `cls.__init__`'s parameter names (skipping `self`) and returns a partial. When that partial is called, it resolves each name from the container and constructs the class. + +**Classes become commands.** `_util.register_class_as_commands(app, submenu, Cls)` registers every public function of `Cls` as a command. Each invocation constructs a *fresh* `Cls` instance with its dependencies injected, then calls the method. The method's signature (minus `self`) and its docstring are what argparse sees. You pass the class, not an instance. + +**`CommandTree` (cli.py) maps function signatures onto argparse.** +- Parameters with defaults become optional positionals (`nargs="?"`). Parameters without defaults become required positionals. Options can be added with `add_argument`. +- Help text is the first sentence of the docstring (`HelpGenerator`). +- It runs in one of two **mutually exclusive modes**. In submenu mode, `init_submenu()` → `SubMenu.create_submenu()` / `register_command()` fills nested subparsers, tracked in `entries`. In single-command mode, `CommandTree.register_command()` binds one function directly to the root parser. The two modes are guarded by asserts in `_lookup_command`. +- Dispatch walks the parsed namespace one submenu `var_name` at a time until it hits a `CommandEntry`. It then calls the callback with the args whose names match the signature. +- argparse placement matters: `prog --opt sub cmd` applies `--opt` at the root, while `prog sub cmd --opt` applies it to the command. + +**`ServiceNotFound` and `NoCommandSpecified` live in `_util.py`.** That placement avoids a circular import between `cli` and `app_container`. Don't move them back into `app_container`. + +**Config (cfg.py)** wraps `configobj.ConfigObj`. +- Setting `configspec_filepath` or `configini_filepath` reloads and revalidates immediately. +- Validation runs with `copy=True`, so spec defaults populate the config. +- Interpolation is `"template"` (`$var`, not `%(var)s`). +- Keys beyond the spec are allowed. +- A validation failure logs and returns `False`; it doesn't raise. The strict-validation flag is hard-coded off, and the `capabilities` constructor argument is ignored. +- `EnvironmentVariables` is an unimplemented stub. + +**Logging (log.py, _bootstrap.py).** +- The framework's own logs go to a private logger named `skell` (`_bootstrap_logger`). It is silenced to CRITICAL with `propagate=False`, unless the env var **`APPSKELLINGTON_DEBUG`** is set. The README's `APPSKELLINGTON_ENABLE_LOGGING` is stale; the code only reads `APPSKELLINGTON_DEBUG`. +- That env var is checked twice: once at import in `_bootstrap.py`, and again in `LoggingLayer._add_own_logconfig` when the logging config is (re)applied. +- `LoggingLayer.transform_config` adapts a ConfigObj-friendly dict for `logging.config.dictConfig`. It forces `version=1` and turns level strings (`"debug"`, `"all"`, ...) into ints. It renames the logger key `root` to `""`, because ConfigObj can't hold an empty key. It also resolves a relative `handlers.file.filename` into `appdirs.user_log_dir(appname, appauthor)`. +- `_bootstrap.py` also checks at import time that the third-party deps are installed, and raises `ImportError` if they're missing. + +## Branches + +`develop` is the integration branch: feature branches come off it, and PRs target it. Changes reach `main` through a `develop` → `main` PR. Release tags (`vX.Y.Z`) go on `main`. In 2026-10, `develop` was recreated from `main`; the 2024 `develop` and its PR #2 were superseded. Modernization roadmap: `docs/superpowers/specs/2026-10-04-modernization-design.md`. diff --git a/README.md b/README.md index 83db7b2..3150ee4 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,14 @@ Application framework for Python, features include: - Simple to define services and automatic dependency injection based on name (with custom invocation as an option). \*WIP - INI-style config and and validation (provided through ConfigObj). - Colored logging (provided through colorlog) - - Works on Linux, Windows, and Mac. + - Linux supported. Windows and macOS are goals, but untested. + +**Tested:** Linux, CPython 3.8–3.14 (Gitea CI on every PR). Windows and macOS: goals, untested. Principles: - Lend to creating beautiful, easy to read and understand code in the application. - Minimize coupling of applications to this framework. - - Compatible with Linux, Windows, and Mac. Try to be compatible as possible otherwise. + - Aim for Linux, Windows and macOS. Only Linux is tested today; full multi-platform CI is a long-term goal. - Try to be compatible with alternate Python runtimes such as PyPy and older python environments. \*WIP # Python Package Index (PyPi) - Installation Instruction @@ -70,7 +72,7 @@ argument. # Debug - Turn on Logging -Set 'APPSKELLINGTON_ENABLE_LOGGING' environment variable to any value which turns +Set the 'APPSKELLINGTON_DEBUG' environment variable to any value to turn on AppSkellington-level logging. For example, APPSKELLINGTON_DEBUG=1 @@ -82,83 +84,65 @@ or # Tests -Tests are a WIP and not fully built out yet. Recommendation is to run 'pytest' in the 'tests' directory. +``` +uv run pytest # full suite +uv run pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults +uv run pytest -k "" +uv run --isolated --python 3.8 pytest # another supported Python; --isolated keeps .venv as is +``` # Development -I recommend pyenv to install a reliable, controlled python of preferred version locally. +Install [uv](https://docs.astral.sh/uv/). It manages Python versions and the +virtual environment, so you don't need pyenv or a hand-made venv. ``` -curl https://pyenv.run | bash - -# Add to .bashrc or similar for different shells: -tee -a "$HOME"/.profile <<'EOF' - -export PYENV_ROOT="$HOME/.pyenv" -[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH" -eval "$(pyenv init -)" - -EOF -``` - * reference https://github.com/pyenv/pyenv - * Use pyenv to install desired python version, and/or create any virtual environments you desire - -Clone the repo: -```commandline git clone https://git-repos.zavage.net/zavage-software/app_skellington.git +cd app_skellington +uv sync # creates .venv with the package (editable) + dev tools +uv run pre-commit install # ruff lint + format on every commit ``` -Install pre-commit hooks: -```commandline -pre-commit install +Lint and format (ruff, line length 88): +``` +uv run ruff check --fix +uv run ruff format ``` -Build: +Build the sdist and wheel into `dist/`: ``` -python -m build +uv build ``` -Install: - -``` -pip install . -``` - -Formatting and Linters: -``` -black app_skellington -isort app_skellington -flake8 app_skellington -``` - -Publish: - -``` -# Push latest commit, or on commit ready to publish: -git push - -# Create a tag with the desired version number and push: -git tag -a v0.2.0 -m "0.2.0 provides modern pyproject.toml build with setuptools, versioning, and publishing" -git push origin v0.2.0 - -# Build the wheel: -python -m build - -# Publish to pypi: -twine check dist/* -twine upload dist/* -``` - * Reference https://packaging.python.org/en/latest/overview/ - +Dependencies are locked in `uv.lock`. After editing dependencies in +`pyproject.toml`, run `uv lock` and commit the result. # Version -setuptools_scm will infer the version based on the latest tag in your Git history. -Ensure you are tagging your commits with meaningful version numbers like v1.0.0, v1.1.0, etc. +setuptools_scm derives the version from the latest `vX.Y.Z` git tag. Between +tags you get development versions like `0.3.0.dev4+g1a2b3c4`. The generated +`app_skellington/_version.py` is git-ignored. -You can view the current version number with the command: + uv build # the version appears in the dist/ file names - python -m setuptools_scm +# Publish + +Publish only from a tagged commit on `main`, after CI is green, and only after +TestPyPI has been checked. + +``` +git switch main && git pull --ff-only +git tag -a v0.2.3 -m "0.2.3: " +git push origin v0.2.3 +rm -rf dist && uv build +ls dist # must be exactly app_skellington-0.2.3.tar.gz and app_skellington-0.2.3-py3-none-any.whl + +uv publish --publish-url https://test.pypi.org/legacy/ dist/* # TestPyPI first +scripts/verify_index_release.sh 0.2.3 # every supported Python +uv publish dist/* # then PyPI +scripts/verify_index_release.sh 0.2.3 https://pypi.org/simple/ +``` + * Reference https://packaging.python.org/en/latest/overview/ # License diff --git a/app_skellington/_version.py b/app_skellington/_version.py deleted file mode 100644 index 8ec8685..0000000 --- a/app_skellington/_version.py +++ /dev/null @@ -1,20 +0,0 @@ -# file generated by setuptools-scm -# don't change, don't track in version control - -__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"] - -TYPE_CHECKING = False -if TYPE_CHECKING: - from typing import Tuple, Union - - VERSION_TUPLE = Tuple[Union[int, str], ...] -else: - VERSION_TUPLE = object - -version: str -__version__: str -__version_tuple__: VERSION_TUPLE -version_tuple: VERSION_TUPLE - -__version__ = version = "0.3.0.dev7+gd155176.d20250223" -__version_tuple__ = version_tuple = (0, 3, 0, "dev7", "gd155176.d20250223") diff --git a/app_skellington/app_container.py b/app_skellington/app_container.py index 44c876a..5312300 100644 --- a/app_skellington/app_container.py +++ b/app_skellington/app_container.py @@ -4,13 +4,13 @@ import os import appdirs -from ._util import ServiceNotFound, NoCommandSpecified, get_root_asset from . import log -from .cfg import Config -from .cli import CommandTree # Application scaffolding: from ._bootstrap import _bootstrap_logger +from ._util import NoCommandSpecified, ServiceNotFound, get_root_asset +from .cfg import Config +from .cli import CommandTree # These two variables affect the directory paths for # config files and logging. @@ -189,5 +189,3 @@ class ApplicationContainer: def usage(self): pass # Applications need a default usage - - diff --git a/app_skellington/cfg.py b/app_skellington/cfg.py index 13b76dd..38ed5f7 100644 --- a/app_skellington/cfg.py +++ b/app_skellington/cfg.py @@ -217,9 +217,9 @@ class Config: try: _bootstrap_logger.info("cfg - Validating config file against spec") val = validate.Validator() - assert isinstance( - self._config_obj, configobj.ConfigObj - ), "expecting configobj.ConfigObj, received %s" % type(self._config_obj) + assert isinstance(self._config_obj, configobj.ConfigObj), ( + "expecting configobj.ConfigObj, received %s" % type(self._config_obj) + ) # NOTE(MG) copy arg below instructs configobj to use defaults from spec file test_results = self._config_obj.validate( val, copy=True, preserve_errors=True diff --git a/app_skellington/cli.py b/app_skellington/cli.py index 50d1c63..ababf17 100644 --- a/app_skellington/cli.py +++ b/app_skellington/cli.py @@ -3,8 +3,8 @@ import inspect import re import sys -from ._util import NoCommandSpecified from ._bootstrap import _bootstrap_logger +from ._util import NoCommandSpecified # If explicit fail is enabled, any command with at least one unknown # argument will be rejected entirely. If not enabled, unknown arguments @@ -233,22 +233,21 @@ class CommandTree: # the CommandTree with no SubMenu (submenu will be disabled # in this case): if self._cmd_tree_is_single_command: - assert ( - self._cmd_tree_is_single_command is True - ), "corrupt data structure in CommandMenu" + assert self._cmd_tree_is_single_command is True, ( + "corrupt data structure in CommandMenu" + ) assert self._entries is None, "corrupt data structure in CommandMenu" - assert isinstance( - self._single_command, CommandEntry - ), "corrupt data structure in CommandMenu" + assert isinstance(self._single_command, CommandEntry), ( + "corrupt data structure in CommandMenu" + ) return self._single_command # There is at least one submenu we need to go down: else: - assert self._single_command is None, "corrupt data structure in CommandMenu" - assert ( - self._cmd_tree_is_single_command is False - ), "corrupt data structure in CommandMenu" + assert self._cmd_tree_is_single_command is False, ( + "corrupt data structure in CommandMenu" + ) # Key or variable name used by argparse to store the submenu options argparse_param = self.submenu_param # e.g.: submenu_root diff --git a/docs/superpowers/plans/2026-10-04-modernization-hotfix-track.md b/docs/superpowers/plans/2026-10-04-modernization-hotfix-track.md new file mode 100644 index 0000000..8e4a1c5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-modernization-hotfix-track.md @@ -0,0 +1,1106 @@ +# Modernization: hotfix track (PRs 1–3 and the 0.2.3 release) — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship app_skellington 0.2.3. It fixes the 0.2.2 crash on `import app_skellington`, and every compatibility claim it makes is backed by a green CI matrix on Linux, CPython 3.8–3.14. + +**Architecture:** Three sequential PRs into `develop`. Each one is cut fresh from the latest `develop` after the previous one merges: +1. ruff replaces black, isort and flake8 (format-only diff). +2. uv: dependency groups, `uv.lock`, packaging cleanup, docs. +3. A Gitea Actions workflow: lint, a matrix of test lanes (each also builds the wheel and sdist and smoke-tests the installed artifact), and one aggregate `ci-ok` required check. + +Then `develop` → `main`, a tag, TestPyPI, verification across the matrix, and PyPI. + +**Tech Stack:** Python ≥3.8, setuptools + setuptools_scm (unchanged build backend), uv 0.12, ruff 0.16.10, pytest, pre-commit, Gitea Actions (act_runner, Linux Docker host). + +**Spec:** `docs/superpowers/specs/2026-10-04-modernization-design.md`. Out of scope here: the spec's PR 4 (characterization tests) and PR 5+ (hardening). They get their own plan after this track ships, because what they test depends on what the characterization tests uncover. + +## Global Constraints + +- `requires-python = ">=3.8"`. If an EOL version can't be made to work without contorting the code, drop it (raise the floor and remove its lane) instead. That's the CLAUDE.md policy. +- Line length is **88**. Reformatting happens only in PR 1, and PR 1 carries no logic changes. +- The CI matrix is exactly `3.8, 3.9, 3.10, 3.11, 3.12, 3.13, 3.14`. The Python classifiers in `pyproject.toml` and the list in `scripts/verify_index_release.sh` must match it. `tests/test_ci_workflow.py` enforces both. +- Platform claim: Linux only. Windows and macOS are "goal, untested". +- Build backend stays setuptools + setuptools_scm (`release-branch-semver`, `node-and-date`). Versions come from `vX.Y.Z` tags. +- Nothing is published to PyPI until CI is green on `main` and the TestPyPI artifact passes `scripts/verify_index_release.sh` on every matrix Python. +- No publish job in CI. Mathew publishes by hand with his own tokens. +- Branches: cut from the latest `develop` (`git fetch && git worktree add .worktrees/ -b origin/develop`), push after the first commit, open a WIP PR with `pr-flow open`, and finish each with `/branch-wrapup`. Mathew merges. Never rewrite history he has seen. +- Every subagent prompt must restate this: **never route around a guard, hook or permission denial. Report it.** + +## Review Focus + +These are the five conditions most likely to bite a user that no task's unit tests cover naturally. Each is pinned by a check in the task named. + +1. **Installing from the sdist** (pip falls back to it, or a distro packager uses it), outside a git checkout. The expected version must be the real one, not `0.0.0`. Pinned by the sdist loop in the CI smoke step (Task 5). +2. **CI checkout without tags** (a shallow clone). setuptools_scm would silently version the package `0.0.x`. Pinned by `smoke_import.py`'s `0.0` check (Task 4) and `test_checkout_fetches_tags` (Task 5). +3. **Importing with `APPSKELLINGTON_DEBUG=1`.** That runs a separate code path in `_bootstrap.py`. Pinned by the subprocess check in `smoke_import.py` (Task 4). +4. **A dev or dirty version uploaded to PyPI by accident** (`0.3.0.devN+g…`). Pinned by the exact-filename check before upload (Task 8, step 4). +5. **A new contributor following the README literally on a fresh clone.** Pinned by the fresh-clone check (Task 3, step 6). + +--- + +## Preconditions (check before Task 1) + +- PR #4 (CLAUDE.md + spec + this plan) has merged into `develop`. Every PR below edits CLAUDE.md. Run `git fetch && git log origin/develop --oneline -3`, and you should see the docs commits. +- `uv` and `tea` are on PATH (`/usr/bin/uv`, `/usr/bin/tea`). + +--- + +## PR 1 — `style/ruff` (format-only) + +### Task 1: Adopt ruff at 88, format-only + +**Files:** +- Modify: `pyproject.toml` (dev extras; add `[tool.ruff*]`) +- Modify: `.pre-commit-config.yaml` (full replacement) +- Delete: `.flake8` +- Modify: the formatted files `app_skellington/app_container.py`, `app_skellington/cfg.py`, `app_skellington/cli.py`, `tests/cfg/test_cfg.py` +- Modify: `CLAUDE.md` (Commands section and the two lint bullets) + +**Interfaces:** +- Produces: `[tool.ruff]` config that PR 3's `lint` job runs as `uv run ruff check` and `uv run ruff format --check`. The pinned `ruff==0.16.10` in the dev deps must match the pre-commit `rev: v0.16.10`. + +- [ ] **Step 1: Cut the branch** + +```bash +cd /home/golem/dev/zavage-software/app_skellington +git fetch origin +git worktree add .worktrees/style-ruff -b style/ruff origin/develop +``` +Then hand the session over with `EnterWorktree(path: "/home/golem/dev/zavage-software/app_skellington/.worktrees/style-ruff")`. + +- [ ] **Step 2: Record the baseline (the "failing test")** + +Run: `uvx ruff@0.16.10 check --line-length 88 --target-version py38 --select E,F,W,I --exclude app_skellington/_version.py app_skellington tests` +Expected: `Found 13 errors.` That's 2× I001 (app_container.py, cli.py), 9× E501, plus F841 and E712 in `tests/test_cli.py`. + +- [ ] **Step 3: Add the ruff config to `pyproject.toml`** + +Replace the `[project.optional-dependencies]` block with: + +```toml +[project.optional-dependencies] +dev = [ + "ruff==0.16.10", + "pre-commit", +] +``` + +Append: + +```toml +[tool.ruff] +line-length = 88 +target-version = "py38" +extend-exclude = ["app_skellington/_version.py"] + +[tool.ruff.lint] +# E/W = pycodestyle, F = pyflakes, I = isort: the old flake8 + isort coverage. +select = ["E", "F", "W", "I"] +# The formatter owns line length. Long strings and comments it can't wrap are allowed. +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +# Placeholder smoke test. The characterization-tests plan rewrites it. +"tests/test_cli.py" = ["F841", "E712"] +``` + +(PR 2 moves the dev deps into `[dependency-groups]`. They stay as extras here so this PR doesn't touch packaging.) + +- [ ] **Step 4: Replace `.pre-commit-config.yaml` and delete `.flake8`** + +```yaml +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-yaml + - id: check-added-large-files + args: ['--maxkb=10240'] + - id: check-merge-conflict + - id: end-of-file-fixer + - id: trailing-whitespace + +# ruff replaces black, isort and flake8. Keep rev equal to the ruff pin in pyproject.toml. +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.10 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format +``` + +```bash +git rm .flake8 +``` + +- [ ] **Step 5: Apply the formatter and the safe fixes** + +```bash +uvx ruff@0.16.10 check --fix app_skellington tests +uvx ruff@0.16.10 format app_skellington tests +``` + +- [ ] **Step 6: Verify lint is clean** + +Run: `uvx ruff@0.16.10 check app_skellington tests && uvx ruff@0.16.10 format --check app_skellington tests` +Expected: `All checks passed!` and `... files already formatted`. + +- [ ] **Step 7: Prove the diff changes no logic** + +Save this as a scratch file **outside the repo**, at `$CLAUDE_JOB_DIR/tmp/ast_same.py` (or `/tmp/ast_same.py`). It compares each changed `.py` file's AST against `origin/develop`. Import statements are normalized for order, since isort reorders them. + +```python +import ast +import subprocess +import sys + + +def norm(src): + tree = ast.parse(src) + imports, rest = [], [] + for node in tree.body: + (imports if isinstance(node, (ast.Import, ast.ImportFrom)) else rest).append( + node + ) + for node in imports: + node.names = sorted(node.names, key=lambda a: (a.name, a.asname or "")) + return sorted(ast.dump(n) for n in imports), [ast.dump(n) for n in rest] + + +files = subprocess.check_output( + ["git", "diff", "--name-only", "origin/develop", "--", "*.py"], text=True +).split() +changed = [ + f + for f in files + if norm(subprocess.check_output(["git", "show", "origin/develop:" + f], text=True)) + != norm(open(f).read()) +] +print("files:", files) +print("logic changed:", changed) +sys.exit(1 if changed else 0) +``` + +Run: `python3 $CLAUDE_JOB_DIR/tmp/ast_same.py` +Expected: `logic changed: []`, exit 0. If any file is listed, revert that hunk; it doesn't belong in a style PR. + +- [ ] **Step 8: Run the tests** + +Run: `uv run --no-project --isolated --python 3.8 --with . --with pytest -- python -m pytest -q tests`, then the same with `--python 3.14`. +Expected: `7 passed` on both. If the run regenerates `app_skellington/_version.py`, `git restore` it. + +- [ ] **Step 9: Update CLAUDE.md** + +In the `## Commands` block, replace these three lines + +``` +black app_skellington +isort --profile black app_skellington +flake8 app_skellington # .flake8: max-line-length 120, tests/ excluded +``` +with +``` +ruff check --fix # lint + import sorting (config in pyproject.toml) +ruff format # formatter, line length 88 +``` +and change the first line's comment to `# dev extras = ruff, pre-commit; pytest is NOT in the extras`. + +Delete the two bullets that start **"Formatter config is inconsistent."** and **"Lint is not currently clean on `main`."**. Add this bullet in their place: + +```markdown +- **ruff** handles formatting (88 columns) and lint (`E`, `F`, `W`, `I`). E501 is ignored on purpose, since the formatter owns line length. Keep the `ruff==` pin in `pyproject.toml` equal to the `rev:` in `.pre-commit-config.yaml`. +``` + +- [ ] **Step 10: Commit, push, open a WIP PR** + +```bash +git add -A +git status --short # expect: pyproject.toml, .pre-commit-config.yaml, .flake8 (D), 4 formatted .py files, CLAUDE.md; NOT _version.py +git commit -m "style: adopt ruff (format + lint) at 88, replacing black/isort/flake8 + +Format-only: AST-identical apart from import order. + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push -u origin style/ruff +pr-flow open --title='style: adopt ruff at 88, replacing black/isort/flake8' --body-file= +``` +Body: link the spec, state "format-only, AST-verified", and say that `tests/test_cli.py`'s ignores go away with the characterization tests. Then run `/branch-wrapup`, hand over the PR URL, and **wait for Mathew to merge** before PR 2. + +--- + +## PR 2 — `build/uv` + +### Task 2: uv, dependency groups, lockfile, packaging cleanup + +**Files:** +- Modify: `pyproject.toml` +- Create: `uv.lock` (generated) +- Modify: `.python-version`, `.gitignore` +- Delete: `setup.cfg`, `tests/pytest.ini` +- Untrack: `app_skellington/_version.py` + +**Interfaces:** +- Consumes: `[tool.ruff*]` from Task 1. +- Produces: `[dependency-groups] dev`, a committed `uv.lock`, and `[tool.pytest.ini_options] testpaths = ["tests"]`. PR 3's CI runs `uv sync --locked` and `uv run pytest`, and relies on all of these. + +- [ ] **Step 1: Cut the branch from the freshly merged develop** + +```bash +cd /home/golem/dev/zavage-software/app_skellington +git fetch origin +git worktree add .worktrees/build-uv -b build/uv origin/develop +``` +Then `EnterWorktree(path: ".../.worktrees/build-uv")`. Check that `pyproject.toml` contains `[tool.ruff]`. If it doesn't, PR 1 hasn't merged; stop. + +- [ ] **Step 2: Write the failing check** + +Run: `uv sync --locked` +Expected: FAIL. There's no `uv.lock` yet (`Unable to find lockfile`). + +- [ ] **Step 3: Edit `pyproject.toml`** + +Delete the whole `[project.optional-dependencies]` block. Change the license classifier from `"License :: OSI Approved :: MIT License"` to `"License :: OSI Approved :: MIT No Attribution License (MIT-0)"` (LICENSE.txt is MIT-0). Keep `license = {file = "LICENSE.txt"}`. The SPDX string form would need setuptools ≥77, which can't build on 3.8. + +Append: + +```toml +[dependency-groups] +dev = [ + "ruff==0.16.10", # keep equal to rev in .pre-commit-config.yaml + "pytest", + "pre-commit", + "pyyaml", # tests/test_ci_workflow.py +] + +[tool.setuptools] +packages = ["app_skellington"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = ["ignore::DeprecationWarning"] +``` + +(`packages` stops setuptools' flat-layout autodiscovery from ever picking up `tests/`, `scripts/` or `docs/`.) + +- [ ] **Step 4: Remove obsolete files and untrack the generated version file** + +```bash +git rm setup.cfg tests/pytest.ini +git rm --cached app_skellington/_version.py +printf '3.14\n' > .python-version +printf '.venv/\napp_skellington/_version.py\n' >> .gitignore +``` + +- [ ] **Step 5: Lock and sync** + +```bash +uv lock +uv sync --locked +``` +Expected: `Resolved N packages`, and the sync installs into `.venv/`. The lock forks per Python. Confirm the old lanes got compatible tools: `grep -A2 '^name = "pytest"' uv.lock` should show several versions, including an 8.3.x for 3.8. + +- [ ] **Step 6: Verify the gates and a clean tree** + +Run: +```bash +uv run pytest -q +uv run --isolated --python 3.8 pytest -q +uv run ruff check && uv run ruff format --check +git status --short +``` +Expected: +- `7 passed` on both Pythons +- ruff clean +- `git status` lists only this task's intended changes. `_version.py` must **not** appear: it's regenerated but ignored now. + +- [ ] **Step 7: Verify the built artifacts still carry the version module** + +```bash +rm -rf dist && uv build +python3 -c "import zipfile,glob; z=zipfile.ZipFile(glob.glob('dist/*.whl')[0]); print(sorted(n for n in z.namelist() if n.startswith('app_skellington/')))" +tar tzf dist/*.tar.gz | grep -E '_version.py|PKG-INFO$' +``` +Expected: +- the wheel lists `app_skellington/_version.py` next to the 7 modules, and nothing from `tests/` +- the sdist contains `_version.py` and `PKG-INFO` + +- [ ] **Step 8: Commit and push** + +```bash +git add -A +git commit -m "build: uv dependency groups + lockfile; untrack generated _version.py + +- dev tools move from a published extra to [dependency-groups] +- uv.lock resolves one lock across CPython 3.8-3.14 +- pytest config moves into pyproject (testpaths) +- explicit packages list; license classifier corrected to MIT-0 +- drop obsolete setup.cfg and tests/pytest.ini + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push -u origin build/uv +pr-flow open --title='build: uv dependency groups, lockfile, packaging cleanup' --body-file= +``` + +### Task 3: README and CLAUDE.md for the uv workflow (same PR) + +**Files:** +- Modify: `README.md` (Debug, Tests, Development, Version sections) +- Modify: `tests/README.md` +- Modify: `CLAUDE.md` (Commands section and the versioning bullet) + +**Interfaces:** +- Consumes: the commands Task 2 made work. + +- [ ] **Step 1: Fix the Debug section in `README.md`** + +Replace its first paragraph + +``` +Set 'APPSKELLINGTON_ENABLE_LOGGING' environment variable to any value which turns +on AppSkellington-level logging. For example, +``` +with +``` +Set the 'APPSKELLINGTON_DEBUG' environment variable to any value to turn +on AppSkellington-level logging. For example, +``` + +- [ ] **Step 2: Fix the platform claims in `README.md`** + +In the feature list, replace ` - Works on Linux, Windows, and Mac.` with ` - Linux supported. Windows and macOS are goals, but untested.` + +In Principles, replace ` - Compatible with Linux, Windows, and Mac. Try to be compatible as possible otherwise.` with ` - Aim for Linux, Windows and macOS. Only Linux is tested today; full multi-platform CI is a long-term goal.` + +- [ ] **Step 3: Replace the `# Tests`, `# Development` and `# Version` sections in `README.md`** + +Replace everything from `# Tests` up to (not including) `# License` with: + +````markdown +# Tests + +``` +uv run pytest # full suite +uv run pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults +uv run pytest -k "" +uv run --isolated --python 3.8 pytest # against another supported Python +``` + +# Development + +Install [uv](https://docs.astral.sh/uv/). It manages Python versions and the +virtual environment, so you don't need pyenv or a hand-made venv. + +``` +git clone https://git-repos.zavage.net/zavage-software/app_skellington.git +cd app_skellington +uv sync # creates .venv with the package (editable) + dev tools +uv run pre-commit install # ruff lint + format on every commit +``` + +Lint and format (ruff, line length 88): +``` +uv run ruff check --fix +uv run ruff format +``` + +Build the sdist and wheel into `dist/`: +``` +uv build +``` + +Dependencies are locked in `uv.lock`. After editing dependencies in +`pyproject.toml`, run `uv lock` and commit the result. + +# Version + +setuptools_scm derives the version from the latest `vX.Y.Z` git tag. Between +tags you get development versions like `0.3.0.dev4+g1a2b3c4`. The generated +`app_skellington/_version.py` is git-ignored. + + uv build # the version appears in the dist/ file names + +# Publish + +Publish only from a tagged commit on `main`, after CI is green, and only after +TestPyPI has been checked. + +``` +git switch main && git pull --ff-only +git tag -a v0.2.3 -m "0.2.3: " +git push origin v0.2.3 +rm -rf dist && uv build +ls dist # must be exactly app_skellington-0.2.3.tar.gz and app_skellington-0.2.3-py3-none-any.whl + +uv publish --publish-url https://test.pypi.org/legacy/ dist/* # TestPyPI first +scripts/verify_index_release.sh 0.2.3 # every supported Python +uv publish dist/* # then PyPI +scripts/verify_index_release.sh 0.2.3 https://pypi.org/simple/ +``` + * Reference https://packaging.python.org/en/latest/overview/ +```` + +(`scripts/verify_index_release.sh` lands in Task 7. This README section merges before that script exists, which is acceptable because publishing can't happen before Task 7 anyway.) + +- [ ] **Step 4: Update `tests/README.md` usage** + +Replace its `Usage` section body with: + +``` +Run all tests (from the repo root): + + uv run pytest + +Run test by keyword: + + uv run pytest -k "" + +Run test by directory: + + uv run pytest tests/ +``` + +- [ ] **Step 5: Update CLAUDE.md** + +Replace the whole `## Commands` code block with: + +```bash +uv sync # .venv with the package (editable) + dev group: ruff, pytest, pre-commit, pyyaml +uv run pytest # full suite (testpaths = tests) +uv run pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults # single test +uv run pytest -k "" +uv run --isolated --python 3.8 pytest # any supported Python; uv.lock forks per version + +uv run ruff check --fix # lint + import sorting (config in pyproject.toml) +uv run ruff format # formatter, line length 88 + +uv build # sdist + wheel into dist/; the version shows in the file names +uv lock # after any dependency change; commit uv.lock +``` + +Replace the **"Versioning is tag-driven."** bullet with: + +```markdown +- **Versioning is tag-driven.** setuptools_scm uses `release-branch-semver` and derives the version from the latest `vX.Y.Z` git tag. It generates `app_skellington/_version.py`, which is git-ignored, so never commit it. The release steps (tag on `main`, `uv build`, TestPyPI, verify, PyPI) are in the README's Publish section. +``` + +In `## What this is`, change `It targets Python >=3.8 on Linux, Windows and Mac.` to `It targets Python >=3.8. Only Linux is tested; Windows and macOS are goals.` + +- [ ] **Step 6: Fresh-clone check (Review Focus 5)** + +```bash +rm -rf $CLAUDE_JOB_DIR/tmp/fresh && git clone -q --branch build/uv . $CLAUDE_JOB_DIR/tmp/fresh +cd $CLAUDE_JOB_DIR/tmp/fresh && uv sync && uv run pytest -q && uv run ruff check && uv build && git status --short +``` +Expected: `7 passed`, ruff clean, a successful build, and empty `git status`. Return to the worktree afterwards. + +- [ ] **Step 7: Commit, push, wrap up** + +```bash +git add -A +git commit -m "doc: README and CLAUDE.md for the uv workflow; fix debug env var and platform claims + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push +``` +Run `/branch-wrapup` and hand over. **Wait for the merge** before PR 3. + +--- + +## PR 3 — `ci/gitea` + +### Task 4: `scripts/smoke_import.py`, a smoke test for an installed artifact + +**Files:** +- Create: `scripts/smoke_import.py` + +**Interfaces:** +- Produces: `python scripts/smoke_import.py [EXPECTED_VERSION]`. It must run from outside the source tree (`cd /`). It exits non-zero with a message on any failure, and on success prints `smoke ok: `. It's used by the CI test lanes (Task 5) and by `scripts/verify_index_release.sh` (Task 7). + +- [ ] **Step 1: Cut the branch** + +```bash +cd /home/golem/dev/zavage-software/app_skellington +git fetch origin +git worktree add .worktrees/ci-gitea -b ci/gitea origin/develop +``` +Then `EnterWorktree`. Check that `uv.lock` exists. If it doesn't, PR 2 hasn't merged; stop. + +- [ ] **Step 2: Write the script** + +```python +"""Smoke-test an *installed* app_skellington (wheel, sdist or index release). + +Run it from outside the source tree, so the installed copy is the one imported: + + cd / && python /path/to/scripts/smoke_import.py [EXPECTED_VERSION] +""" + +import importlib.metadata +import os +import pathlib +import subprocess +import sys + + +def fail(msg): + sys.exit("smoke FAILED: " + msg) + + +def main(argv): + expected = argv[1] if len(argv) > 1 else None + + import app_skellington + + location = pathlib.Path(app_skellington.__file__).resolve() + if "site-packages" not in location.parts: + fail("imported the source tree, not an installed copy: %s" % location) + + from app_skellington import ( # noqa: F401 (the public API must import) + ApplicationContainer, + CommandTree, + Config, + LoggingLayer, + NoCommandSpecified, + ServiceNotFound, + SubMenu, + ) + + version = importlib.metadata.version("app_skellington") + if version.startswith("0.0"): + fail("setuptools_scm found no tags (shallow clone?): %s" % version) + if expected is not None and version != expected: + fail("expected version %s, got %s" % (expected, version)) + + # _bootstrap.py takes a different code path when APPSKELLINGTON_DEBUG is set. + env = dict(os.environ, APPSKELLINGTON_DEBUG="1") + proc = subprocess.run([sys.executable, "-c", "import app_skellington"], env=env) + if proc.returncode != 0: + fail("import failed with APPSKELLINGTON_DEBUG=1") + + print("smoke ok:", version, location) + + +if __name__ == "__main__": + main(sys.argv) +``` + +- [ ] **Step 3: Prove it catches the real 0.2.2 bug (expected FAIL)** + +Run: `cd / && uv run --no-project --isolated --python 3.12 --with app_skellington==0.2.2 -- python /home/golem/dev/zavage-software/app_skellington/.worktrees/ci-gitea/scripts/smoke_import.py` +Expected: non-zero exit with `ImportError: cannot import name 'NoCommandSpecified' from partially initialized module 'app_skellington'`. + +- [ ] **Step 4: Prove it rejects the source tree (expected FAIL)** + +Run from the worktree root: `uv run python scripts/smoke_import.py` +Expected: `smoke FAILED: imported the source tree, not an installed copy`. The editable install resolves to the worktree, not site-packages. + +- [ ] **Step 5: Prove it passes on a built wheel and sdist (expected PASS)** + +```bash +rm -rf dist && uv build +for d in dist/*.whl dist/*.tar.gz; do + rm -rf /tmp/smoke-venv && uv venv -q --python 3.8 /tmp/smoke-venv + uv pip install -q --python /tmp/smoke-venv "$d" + (cd / && /tmp/smoke-venv/bin/python "$OLDPWD/scripts/smoke_import.py") +done +``` +Expected: two `smoke ok: 0.2.3.devN+g… /tmp/smoke-venv/lib/python3.8/site-packages/app_skellington/__init__.py` lines. (If the worktree isolation check refuses the loop, run the four commands once per artifact.) + +- [ ] **Step 6: Commit** + +```bash +git add scripts/smoke_import.py +git commit -m "test: smoke-test script for installed artifacts (catches the 0.2.2 import crash) + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push -u origin ci/gitea +pr-flow open --title='ci: Gitea checks — lint + CPython 3.8–3.14 matrix + artifact smoke tests' --body-file= +``` +The PR opens as `WIP:`, so CI stays skipped until Task 6 removes the prefix. + +### Task 5: Workflow, pinned by `tests/test_ci_workflow.py` + +**Files:** +- Create: `tests/test_ci_workflow.py` +- Create: `.gitea/workflows/ci.yaml` +- Modify: `pyproject.toml` (Python version classifiers) +- Modify: `README.md` (tested-platform line), `CLAUDE.md` (CI section) + +**Interfaces:** +- Consumes: `scripts/smoke_import.py` (Task 4); `uv sync --locked`, `uv run pytest`, `uv run ruff …` (PR 2). +- Produces: the required status context `CI / ci-ok (pull_request)` (branch protection, Task 6). `NEWEST_MINOR = 14` in the test is the single constant bumped when a new Python is added. Task 7's verify script is pinned against the same list. + +- [ ] **Step 1: Write the failing invariant tests** + +`tests/test_ci_workflow.py`: + +```python +"""Pins the CI workflow's invariants (spec: docs/superpowers/specs/ +2026-10-04-modernization-design.md). CI drift shows up as a red test here.""" + +import pathlib +import re + +import yaml + +ROOT = pathlib.Path(__file__).resolve().parent.parent +WORKFLOW = ROOT / ".gitea" / "workflows" / "ci.yaml" +VERIFY_SCRIPT = ROOT / "scripts" / "verify_index_release.sh" + +# Newest CPython the project claims. To add a version, bump this, the +# matrix, the classifiers and the verify script's list together. +NEWEST_MINOR = 14 + +WIP_CLAUSES = ( + "!startsWith(github.event.pull_request.title, 'WIP:')", + "!startsWith(github.event.pull_request.title, '[WIP]')", +) + + +def load(): + return yaml.safe_load(WORKFLOW.read_text()) + + +def triggers(wf): + # PyYAML follows YAML 1.1, where the bare key `on` parses as boolean True. + return wf["on"] if "on" in wf else wf[True] + + +def expected_versions(): + text = (ROOT / "pyproject.toml").read_text() + floor = int(re.search(r'requires-python\s*=\s*">=3\.(\d+)"', text).group(1)) + return ["3.%d" % minor for minor in range(floor, NEWEST_MINOR + 1)] + + +def flat(text): + return " ".join(text.split()) + + +def test_triggers(): + on = triggers(load()) + assert set(on["pull_request"]["types"]) == { + "opened", + "synchronize", + "reopened", + "edited", + } + assert on["push"]["branches"] == ["develop", "main"] + assert "workflow_dispatch" in on + + +def test_superseded_pr_runs_cancel_but_push_runs_never_do(): + assert ( + load()["concurrency"]["cancel-in-progress"] + == "${{ github.event_name == 'pull_request' }}" + ) + + +def test_every_job_is_wip_gated(): + for name, job in load()["jobs"].items(): + cond = flat(job.get("if", "")) + for clause in WIP_CLAUSES: + assert clause in cond, "%s lacks WIP gate clause %s" % (name, clause) + + +def test_matrix_matches_requires_python(): + test = load()["jobs"]["test"] + assert test["strategy"]["matrix"]["python"] == expected_versions() + assert test["strategy"]["fail-fast"] is False + + +def test_classifiers_match_matrix(): + text = (ROOT / "pyproject.toml").read_text() + claimed = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text) + assert claimed == expected_versions() + + +def test_verify_script_covers_matrix(): + match = re.search(r"^pythons=\(([^)]*)\)", VERIFY_SCRIPT.read_text(), re.M) + assert match.group(1).split() == expected_versions() + + +def test_checkout_fetches_full_history_for_setuptools_scm(): + for name, job in load()["jobs"].items(): + for step in job.get("steps", []): + if step.get("uses", "").startswith("actions/checkout"): + assert step.get("with", {}).get("fetch-depth") == 0, name + + +def test_lint_job_runs_ruff(): + runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["lint"]["steps"])) + assert "uv run ruff check" in runs + assert "uv run ruff format --check" in runs + + +def test_test_lanes_run_locked_suite_and_smoke_test_both_artifacts(): + runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["test"]["steps"])) + assert "uv sync --locked" in runs + assert "uv run pytest" in runs + assert "uv build" in runs + assert "dist/*.whl dist/*.tar.gz" in runs + assert "scripts/smoke_import.py" in runs + + +def test_ci_ok_fails_unless_every_gate_succeeded(): + jobs = load()["jobs"] + ci_ok = jobs["ci-ok"] + # Gitea reports a skipped job as success, so ci-ok must run always() + # and check each result explicitly. + assert set(ci_ok["needs"]) == set(jobs) - {"ci-ok"} + assert flat(ci_ok["if"]).startswith("always() &&") + runs = " ".join(s.get("run", "") for s in ci_ok["steps"]) + for needed in ci_ok["needs"]: + assert 'test "${{ needs.%s.result }}" = success' % needed in runs +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run pytest tests/test_ci_workflow.py -q` +Expected: FAIL, with `FileNotFoundError` for `.gitea/workflows/ci.yaml`. + +- [ ] **Step 3: Write `.gitea/workflows/ci.yaml`** + +```yaml +name: CI +on: + pull_request: + types: [opened, synchronize, reopened, edited] # edited: un-WIP starts CI + push: + branches: [develop, main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Cancel superseded PR runs. Never cancel a push run. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + lint: + if: >- + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + runs-on: ubuntu-latest + container: python:3.14 + env: + UV_PYTHON_DOWNLOADS: never + steps: + - name: Install node (Gitea actions are node programs) + run: apt-get update && apt-get install -y --no-install-recommends nodejs + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: git config --global --add safe.directory "$PWD" + - run: pip install uv + - run: uv sync --locked + - run: uv run ruff check + - run: uv run ruff format --check + + test: + if: >- + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + runs-on: ubuntu-latest + container: python:${{ matrix.python }} + strategy: + fail-fast: false + matrix: + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + env: + UV_PYTHON: ${{ matrix.python }} + UV_PYTHON_DOWNLOADS: never + steps: + - name: Install node (Gitea actions are node programs) + run: apt-get update && apt-get install -y --no-install-recommends nodejs + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # setuptools_scm needs tags + - run: git config --global --add safe.directory "$PWD" + - run: pip install uv + - run: uv sync --locked + - run: uv run pytest + - name: Build, install and smoke-test the wheel and the sdist + run: | + rm -rf dist && uv build + for dist in dist/*.whl dist/*.tar.gz; do + rm -rf /tmp/smoke && uv venv /tmp/smoke + uv pip install --python /tmp/smoke "$dist" + (cd / && /tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/smoke_import.py") + done + + ci-ok: + needs: [lint, test] + # always(): a plain needs: aggregate would be *skipped* when a gate fails, + # and Gitea reports skipped as success. The WIP gate stays. + if: >- + always() && ( + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + ) + runs-on: ubuntu-latest + steps: + - name: Require every gate to have succeeded + run: | + echo "lint=${{ needs.lint.result }} test=${{ needs.test.result }}" + test "${{ needs.lint.result }}" = success + test "${{ needs.test.result }}" = success +``` + +- [ ] **Step 4: Add the Python classifiers to `pyproject.toml`** + +In `classifiers`, after `"Programming Language :: Python :: 3",`, insert: + +```toml + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: POSIX :: Linux", +``` +and remove `"Operating System :: OS Independent"`. That classifier claims Windows and macOS, which nothing tests. + +- [ ] **Step 5: Run the invariant tests** + +Run: `uv run pytest tests/test_ci_workflow.py -q` +Expected: every test passes **except** `test_verify_script_covers_matrix`, which fails because the script lands in Task 7. Mark it so this PR is green on its own: + +```python +import pytest # add to the imports + + +@pytest.mark.skipif(not VERIFY_SCRIPT.exists(), reason="added with the release task") +def test_verify_script_covers_matrix(): ... +``` +Run again. Expected: all pass, 1 skipped. + +- [ ] **Step 6: Run every gate locally on every lane before CI sees it** + +```bash +uv run ruff check && uv run ruff format --check +uv run --isolated --python 3.8 pytest -q +uv run --isolated --python 3.9 pytest -q +uv run --isolated --python 3.10 pytest -q +uv run --isolated --python 3.11 pytest -q +uv run --isolated --python 3.12 pytest -q +uv run --isolated --python 3.13 pytest -q +uv run --isolated --python 3.14 pytest -q +``` +Expected: lint clean, and every lane `N passed, 1 skipped`. Also run Task 4 step 5 with `--python 3.14`, which repeats the smoke step on the newest lane. + +- [ ] **Step 7: Docs** + +`README.md`: right under the feature list, add: + +```markdown +**Tested:** Linux, CPython 3.8–3.14 (Gitea CI on every PR). Windows and macOS: goals, untested. +``` + +`CLAUDE.md`: add a section after `## Python support policy`: + +```markdown +## CI + +`.gitea/workflows/ci.yaml` runs on PRs and on pushes to `develop`/`main`. PRs titled `WIP:` are skipped; removing the prefix starts the run. +- `lint`: ruff. +- `test`: a matrix over CPython 3.8–3.14 in `python:` containers. Each lane runs the locked suite, then builds the wheel and sdist, installs each into a clean venv, and runs `scripts/smoke_import.py` from outside the source tree. +- `ci-ok`: the only required check (`CI / ci-ok (pull_request)`). It uses `if: always()` and fails unless every gate succeeded, because Gitea reports a skipped job as success. + +`tests/test_ci_workflow.py` pins these invariants. To add or drop a Python version, change all four together: the matrix, the classifiers, `scripts/verify_index_release.sh`'s list, and `NEWEST_MINOR` (or `requires-python`). +``` + +- [ ] **Step 8: Commit and push** + +```bash +git add tests/test_ci_workflow.py .gitea/workflows/ci.yaml pyproject.toml README.md CLAUDE.md +git commit -m "ci: Gitea checks — ruff, CPython 3.8–3.14 matrix, wheel+sdist smoke tests, ci-ok aggregate + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push +``` + +### Task 6: Roll out and prove each gate bites + +**Files:** none permanent. Canary commits are reverted with additive revert commits; no force-push. + +- [ ] **Step 1: Start the real run** + +Remove the `WIP: ` prefix from the PR title (Mathew, or `tea pulls edit` if golem's grant allows it; it got a 403 on closing PRs before, so expect to hand this to Mathew). Watch the run: `tea actions runs --login zavage --repo Zavage-Software/app_skellington`, then `tea actions runs view …`. +Expected: `lint` and all 7 `test` lanes succeed, and `ci-ok` succeeds. +- If `python:3.8` or `python:3.9` fails for image reasons (apt, nodejs), apply the policy and **don't build workarounds**. Report it to Mathew and propose dropping the lane: raise `requires-python`, update the matrix, the classifiers and the verify list. + +- [ ] **Step 2: Lint canary** + +Add `import json` as an unused import at the top of `app_skellington/_util.py` (F401). Don't use `os`: the file already imports it, so the canary would raise a different error or none. Run `uv run ruff check` locally first and confirm it reports F401. Commit with `--no-verify` (the pre-commit hook would catch it first) and push. +Expected: `lint` fails, every `test` lane passes, and **`CI / ci-ok (pull_request)` reports failure**. Then `git revert HEAD --no-edit && git push`, and expect everything green. + +- [ ] **Step 3: Test canary** + +Append to `tests/cfg/test_cfg.py`: +```python +def test_canary_must_fail(): + assert False +``` +Commit and push. +Expected: `lint` passes, all 7 `test` lanes fail, and **`ci-ok` reports failure**. Then `git revert HEAD --no-edit && git push`, and expect green. + +- [ ] **Step 4: Record the results in a PR comment** + +Post the run IDs for green → lint-red → green → test-red → green, and confirm that `ci-ok` went red in both canaries. Note whether `if: always()` and `needs..result` evaluated as expected on this act_runner; the pattern doc had never proven that. + +- [ ] **Step 5: Branch protection (Mathew, Gitea UI)** + +For `develop` and `main`: branch rule → **Enable Status Check** → type exactly `CI / ci-ok (pull_request)`. Require only that context. + +- [ ] **Step 6: Wrap up** + +Run `/branch-wrapup` and hand over. **Wait for the merge** before Task 7. + +--- + +## Release 0.2.3 + +### Task 7: Release tooling and changelog (PR `release/0.2.3-prep`) + +**Files:** +- Create: `scripts/verify_index_release.sh` +- Modify: `tests/test_ci_workflow.py` (remove the skipif) +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: `scripts/smoke_import.py [EXPECTED_VERSION]` (Task 4). +- Produces: `scripts/verify_index_release.sh VERSION [INDEX_URL]`. `INDEX_URL` defaults to TestPyPI. Exit 0 means every matrix Python installed and smoke-tested that exact version. + +- [ ] **Step 1: Cut the branch** (`release/0.2.3-prep` from the latest `origin/develop`, same worktree recipe) + +- [ ] **Step 2: Make the pinning test strict (failing test)** + +Delete the `@pytest.mark.skipif(...)` decorator from `test_verify_script_covers_matrix`, and delete the `import pytest` line Task 5 added. Nothing else uses it, and ruff's F401 would fail lint. +Run: `uv run pytest tests/test_ci_workflow.py::test_verify_script_covers_matrix -q` +Expected: FAIL (`FileNotFoundError` for `scripts/verify_index_release.sh`). + +- [ ] **Step 3: Write `scripts/verify_index_release.sh`** + +```bash +#!/usr/bin/env bash +# Install a published app_skellington from a package index into a clean venv +# for every supported Python, then run scripts/smoke_import.py against it. +# +# Usage: scripts/verify_index_release.sh VERSION [INDEX_URL] +# INDEX_URL defaults to TestPyPI. Dependencies always come from PyPI, +# because TestPyPI hosts junk uploads under real dependency names. +set -euo pipefail + +version="${1:?usage: $0 VERSION [INDEX_URL]}" +index="${2:-https://test.pypi.org/simple/}" +here="$(cd "$(dirname "$0")" && pwd)" +pythons=(3.8 3.9 3.10 3.11 3.12 3.13 3.14) + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +for py in "${pythons[@]}"; do + venv="$work/$py" + uv venv -q --python "$py" "$venv" + uv pip install -q --python "$venv" --no-cache --no-deps \ + --index-url "$index" "app_skellington==$version" + "$venv/bin/python" -c 'import importlib.metadata as m; print("\n".join(m.requires("app_skellington") or []))' \ + > "$work/requirements.txt" + uv pip install -q --python "$venv" -r "$work/requirements.txt" + (cd / && "$venv/bin/python" "$here/smoke_import.py" "$version") +done + +echo "verified app_skellington==$version from $index on: ${pythons[*]}" +``` + +```bash +chmod +x scripts/verify_index_release.sh +``` + +- [ ] **Step 4: Run the pin test (PASS), then prove the script catches 0.2.2 (expected FAIL)** + +Run: `uv run pytest -q` +Expected: everything passes, and nothing is skipped. + +Run: `scripts/verify_index_release.sh 0.2.2 https://pypi.org/simple/` +Expected: non-zero exit on the first lane (3.8) with the `NoCommandSpecified ... circular import` ImportError. That shows the script would have blocked 0.2.2. + +- [ ] **Step 5: Changelog** + +At the top of `CHANGELOG.md`, under `# Changelog`, insert: + +```markdown +## [0.2.3] + +Hotfix. In 0.2.2, `import app_skellington` raised +`ImportError: cannot import name 'NoCommandSpecified' from partially initialized module` +(a circular import) for every install. + + * Fix the circular import between `cli` and `app_container`. The framework + exceptions now live in `_util`, and the public names are unchanged. + * Tested on Linux, CPython 3.8–3.14 (Gitea CI). Each version builds the wheel + and sdist, installs them and smoke-tests the import. + * Tooling: ruff replaces black/isort/flake8; uv with a committed lockfile. + * Metadata: license classifier corrected to MIT-0; Python version classifiers + added; "OS Independent" replaced by Linux (Windows and macOS untested). + * README: the debug variable is `APPSKELLINGTON_DEBUG`. +``` + +Also correct the wrong date on the existing entry: `## [0.2.2] (2020-07-19)` becomes `## [0.2.2] (2024-11-17)` (the PyPI upload date). + +- [ ] **Step 6: Commit, push, PR, wrap up** + +```bash +git add scripts/verify_index_release.sh tests/test_ci_workflow.py CHANGELOG.md +git commit -m "release: verify_index_release.sh and 0.2.3 changelog + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push -u origin release/0.2.3-prep +pr-flow open --title='release: 0.2.3 prep — index verification script, changelog' --body-file= +``` +Run `/branch-wrapup`. **Wait for the merge.** + +### Task 8: Promote, tag, TestPyPI, verify, PyPI (Mathew runs the gated steps) + +The publish steps use Mathew's tokens, and only he merges. Claude prepares and verifies; each step is marked with who runs it. + +- [ ] **Step 1 (Mathew):** Open and merge a `develop` → `main` PR once `CI / ci-ok (pull_request)` is green. Then `git switch main && git pull --ff-only`, and confirm the push run on `main` is green. + +- [ ] **Step 2 (Mathew):** Tag and push. +```bash +git tag -a v0.2.3 -m "0.2.3: fix import-time circular import in 0.2.2; CI-tested on CPython 3.8-3.14" +git push origin v0.2.3 +``` + +- [ ] **Step 3 (either):** Build from a clean checkout of the tag. +```bash +rm -rf $CLAUDE_JOB_DIR/tmp/rel && git clone -q --branch v0.2.3 https://git-repos.zavage.net/zavage-software/app_skellington.git $CLAUDE_JOB_DIR/tmp/rel +cd $CLAUDE_JOB_DIR/tmp/rel && uv build +``` + +- [ ] **Step 4 (either): Exact-version gate (Review Focus 4)** + +Run: `ls dist` +Expected: **exactly** `app_skellington-0.2.3-py3-none-any.whl` and `app_skellington-0.2.3.tar.gz`. Any `.dev`, `+g…` or `+d…` suffix means the tag or the tree is wrong. Stop and don't upload. + +- [ ] **Step 5 (Mathew):** `uv publish --publish-url https://test.pypi.org/legacy/ dist/*` (TestPyPI token). + +- [ ] **Step 6 (either):** `scripts/verify_index_release.sh 0.2.3` (run from the repo). +Expected: `verified app_skellington==0.2.3 from https://test.pypi.org/simple/ on: 3.8 3.9 3.10 3.11 3.12 3.13 3.14`. On any failure, stop: fix forward as 0.2.4, and never re-upload a version. + +- [ ] **Step 7 (Mathew):** `uv publish dist/*` (PyPI token). + +- [ ] **Step 8 (either):** `scripts/verify_index_release.sh 0.2.3 https://pypi.org/simple/` +Expected: the same `verified …` line against PyPI. + +- [ ] **Step 9 (Mathew, optional):** Yank 0.2.2 on PyPI (its project page → Manage → Releases → 0.2.2 → Yank), with the reason "import fails; use 0.2.3". + +- [ ] **Step 10 (Claude):** Sync `develop` with `main` if the promotion created a merge commit (`git fetch`, compare `origin/develop` against `origin/main`, and tell Mathew if a `main` → `develop` back-merge PR is needed). Then hand over the next plan (characterization tests + hardening, spec PR 4/5+). diff --git a/docs/superpowers/specs/2026-10-04-modernization-design.md b/docs/superpowers/specs/2026-10-04-modernization-design.md new file mode 100644 index 0000000..aae7a32 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-modernization-design.md @@ -0,0 +1,113 @@ +# app_skellington modernization — design + +Date: 2026-10-04 · Status: draft for review + +## Goal + +Keep the library's existing behaviour while modernizing the tooling and backing every compatibility claim with CI. After that, harden the code. The first user-visible result is a **0.2.3 hotfix release**. Every install of the current PyPI release (0.2.2) is broken: `import app_skellington` raises `ImportError ... partially initialized module ... (most likely due to a circular import)`. The fix (`d155176`) is already on `main` but was never released. + +## Decisions (agreed in brainstorming) + +| Topic | Decision | +|---|---| +| Python floor | Keep `requires-python >=3.8`. When an EOL version starts failing, drop it rather than contort the code (policy recorded in CLAUDE.md). | +| Platforms | Claim only what's tested: **Linux**, CPython 3.8–3.14. Windows and macOS are a stated long-term goal for this repo, but out of scope until a runner exists. | +| Formatter/linter | **ruff** replaces black, isort and flake8. | +| Line length | **88**: the black and ruff default and the de facto modern standard. `main` is already formatted at 88. Measured churn: 4 files, cosmetic only (assert-message wrapping). | +| Reformatting | Goes in its **own style-only PR**. | +| Build system | Keep pyproject + setuptools + setuptools_scm (tag-driven versions). Add **uv** for envs, the lockfile, builds and publishing. Poetry is already gone from `main`. | +| PyPI | Nothing gets published until CI is green across the matrix. Each release goes to TestPyPI first. Publishing stays manual (Mathew's credentials); there's no publish job in CI for now. | +| Branches | Mathew recreates `develop` from `main`. All PRs below target `develop`, `develop` goes to `main` by PR, and release tags go on `main`. Stale PR #2 (develop → main, 2024) gets closed as superseded. | + +## Current state (verified 2026-10-04) + +- The suite passes on 3.8, 3.9, 3.10, 3.12, 3.13 and 3.14 from `main`, but it covers only `cfg` plus one CommandTree constructor smoke test. Passing it says little about CLI, DI or logging. +- `app_skellington/_version.py` is tracked, yet setuptools_scm rewrites it on every install or build, so every dev run leaves a dirty tree. (The generated file uses `from __future__ import annotations`, and running it on 3.8 was verified to work.) +- Lint isn't clean: black would reformat 2 files, isort flags 3, flake8 reports W391 and W503. +- The README names the wrong debug env var (`APPSKELLINGTON_ENABLE_LOGGING`; the code reads `APPSKELLINGTON_DEBUG`). It also claims Windows and Mac support that nothing tests. + +## Delivery: five PRs in order, then the release + +### PR 1: style (ruff, format only) + +- `pyproject.toml`: `[tool.ruff]` with `line-length = 88` and `target-version = "py38"`, excluding `app_skellington/_version.py`. Lint rules are `select = ["E", "F", "W", "I"]`, matching the old flake8 + isort coverage. +- Any existing violation that `ruff check --fix` can't fix safely gets a per-rule `ignore` with a comment. Each one is removed during hardening (PR 5). This PR changes no logic. +- Apply `ruff format` and the safe `ruff check --fix` (import order). +- Delete `.flake8`. In `.pre-commit-config.yaml`, replace the black, isort and flake8 hooks with `ruff-pre-commit` (`ruff-check --fix`, `ruff-format`). That also drops the `python3.8` pin, which breaks on machines without 3.8. +- Done when `ruff check` and `ruff format --check` are clean, tests pass, and the diff is whitespace and import order only. + +### PR 2: tooling (uv), packaging hygiene, docs + +- `[dependency-groups] dev = ["ruff", "pytest", "pre-commit", "pyyaml"]` (PEP 735) replaces `[project.optional-dependencies] dev`, so dev tools are no longer extras on the published package. Commit `uv.lock`. uv resolves one lock across all Pythons in range, so 3.8 lanes get compatible older pytest and other tools with no hand pinning. +- Move `tests/pytest.ini` into `[tool.pytest.ini_options]` (with `testpaths = ["tests"]`), so plain `uv run pytest` from the repo root picks up the config. +- `.python-version`: set the dev default to one current version (3.14). CI covers the rest. +- **Untrack `_version.py`** and add it to `.gitignore`. setuptools_scm still generates it at build time, so the sdist and wheel ship it. Nothing in the package imports it. +- Commands become `uv sync`, `uv run pytest`, `uv run ruff ...`, `uv build` and `uv publish`. +- README: + - fix the debug env var + - replace the pyenv and pip dev setup with uv + - drop the untested Windows/Mac claim; PR 3 adds the tested-platform line once CI backs it + - update the test and publish instructions + - leave the claim "config.ini is created if missing" until PR 4 confirms it; correct it there if the test disagrees +- Update CLAUDE.md to match: commands, the branch section after `develop` is recreated, and the lint and `_version.py` notes. + +### PR 3: Gitea CI (checks only) + +`.gitea/workflows/ci.yaml`, following `~/.claude/docs/gitea-ci-pattern.md`: + +- **Triggers:** `pull_request` (types `opened, synchronize, reopened, edited`), `push: [develop, main]`, `workflow_dispatch`. Concurrency cancels superseded PR runs only. +- **WIP gate** on every job that runs on `pull_request`: skip when the title starts with `WIP:` or `[WIP]`, and nothing else. +- **`lint`:** one Python. Runs `uv run ruff check` and `uv run ruff format --check`. +- **`test`:** matrix over `python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]`, `fail-fast: false`. Container `python:`. The first step installs `nodejs` (Gitea actions are node programs), then uv. Checkout uses `fetch-depth: 0`, because setuptools_scm needs tags. Then each lane: + 1. `uv sync --locked`, then `uv run pytest`. + 2. **Packaging smoke test:** `uv build`, install the built wheel into a fresh venv, then `cd /` (away from the source tree) and run `python -c "import app_skellington; ..."`. This is the check that would have caught 0.2.2. +- **`ci-ok`** aggregate job, `needs: [lint, test]`, with `if: always()` plus the WIP gate. It fails unless every needed job's result is `success`. + - Why it runs `always()`: Gitea reports a *skipped* job as a `success` status. A plain `needs:` aggregate would go green when a needed job failed. The `always()` + explicit check closes that hole. + - Branch protection then requires only `CI / ci-ok (pull_request)`, so one required check covers every matrix lane. +- **`tests/test_ci_workflow.py`** pins the invariants: triggers, WIP gate on each PR job, matrix versions equal to the `requires-python` floor through the newest, and the `ci-ok` logic. CI drift then shows up as a red test. +- README: add "Tested on Linux, CPython 3.8–3.14 (CI). Windows/macOS: goal, untested." +- **Rollout per the playbook:** run every gate locally first, then canary one gate at a time (a lint failure, then a test failure), watch it go red, revert, watch it go green. For each canary, confirm that **`CI / ci-ok (pull_request)` itself** reports failure, not just the broken lane. This proves `if: always()` and `needs..result` evaluate as expected on this act_runner, since the pattern doc never canaried them. Mathew then adds the branch-protection status check. + +### Release 0.2.3 (hotfix, after PR 3 is green on `main`) + +1. Promote `develop` → `main` by PR, with CI green. +2. Tag `v0.2.3` on `main`, then `uv build`. +3. `uv publish` to **TestPyPI**. Install it from TestPyPI on every matrix Python (a script under `scripts/` running `uv run --isolated --python X --index testpypi`) and import it. +4. `uv publish` to PyPI. Mathew does this step; whether to yank 0.2.2 afterwards is his call. +5. CHANGELOG entry. + +### PR 4: characterization tests + +These pin *current* behaviour before any hardening changes it. Known bugs get tests marked `xfail(strict=True)`, each with the bug named, so a fix flips the test and forces it to be updated. + +- **CLI:** single-command and submenu dispatch, nested submenus, defaults versus required positionals, help text from the first docstring sentence, `NoCommandSpecified`, option placement (root versus command). +- **Container and DI:** factory registration, `ServiceNotFound`, constructor-parameter-name injection, `register_class_as_commands` (fresh instance per call, public methods only), the hook order `_cli_options` → `_services` → `_command_menu`. +- **Logging:** `transform_config` (`root` → `""`, level strings to ints, `version=1`, relative file path → user log dir, using a tmp dir), `APPSKELLINGTON_DEBUG` on and off. +- **Config:** whether config.ini is created when missing (then correct the README to match), `$var` interpolation, spec defaults, validation failure returning `False`. +- **Public API:** everything `__init__.py` exports can be imported. + +### PR 5+: hardening, one fix per PR or small groups + +Each fix flips its `xfail` from PR 4. Known so far: +- `Config.__delitem__` recurses forever (it calls `del self[key]`). +- `_util.get_asset` has a broken `isinstance(module, module)` check. +- `cli._lookup_command` can stop at `input("")`. +- `logger.warn` is deprecated; use `warning`. +- `_load_config_files` swallows broad exceptions. +- `Config.__contains__` returns `None` on error. +- Remove the per-rule ruff ignores added in PR 1. +- Issue #3: appdirs → platformdirs, with a path-parity check. + +The public API (the names exported from `__init__.py`) stays stable. + +## Out of scope (recorded, not planned) + +- Windows and macOS CI runners. This is the long-term multi-platform goal and needs infrastructure. +- A PyPI publish job in CI (would need the deferred credential model in the CI pattern doc). +- A PyPy lane and type checking. Both can be revisited once the matrix is stable. + +## Risks + +- **Old Python images:** `python:3.8` images are frozen, since 3.8 is EOL. If apt or nodejs installs inside them break, the policy applies: drop the lane and raise the floor rather than build workarounds. +- **Lockfile resolution on 3.8:** some dev tools may have no 3.8-compatible release. uv's fork resolution should handle this; if it can't, that's the trigger to raise the floor. +- **setuptools_scm in CI** needs full history and tags (`fetch-depth: 0`, `safe.directory`). Without them the version falls back to `0.0.0`, and the smoke test should assert that it doesn't. diff --git a/pyproject.toml b/pyproject.toml index b9f2dd2..97e80d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,15 @@ authors = [ keywords = ["cli", "logging", "application"] classifiers = [ "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent" + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: POSIX :: Linux", + "License :: OSI Approved :: MIT No Attribution License (MIT-0)" ] @@ -31,15 +38,37 @@ repository = "https://git-repos.zavage.net/Zavage-Software/app_skellington" documentation = "https://git-repos.zavage.net/Zavage-Software/app_skellington" -[project.optional-dependencies] +[dependency-groups] dev = [ - "black", + "ruff==0.16.10", # keep equal to rev in .pre-commit-config.yaml + "pytest", "pre-commit", - "isort", - "flake8" + "pyyaml", # tests/test_ci_workflow.py ] +[tool.setuptools] +packages = ["app_skellington"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = ["ignore::DeprecationWarning"] + [tool.setuptools_scm] version_file = "app_skellington/_version.py" version_scheme = "release-branch-semver" local_scheme = "node-and-date" + +[tool.ruff] +line-length = 88 +target-version = "py38" +extend-exclude = ["app_skellington/_version.py"] + +[tool.ruff.lint] +# E/W = pycodestyle, F = pyflakes, I = isort: the old flake8 + isort coverage. +select = ["E", "F", "W", "I"] +# The formatter owns line length. Long strings and comments it can't wrap are allowed. +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +# Placeholder smoke test. The characterization-tests plan rewrites it. +"tests/test_cli.py" = ["F841", "E712"] diff --git a/scripts/smoke_import.py b/scripts/smoke_import.py new file mode 100644 index 0000000..c5ab3d9 --- /dev/null +++ b/scripts/smoke_import.py @@ -0,0 +1,85 @@ +"""Smoke-test an *installed* app_skellington (wheel, sdist or index release). + +Run it from outside the source tree: + + cd / && python /path/to/scripts/smoke_import.py [EXPECTED_VERSION] + +The script's own directory (scripts/) heads sys.path, so the main import +can't see the source tree. But the APPSKELLINGTON_DEBUG child process puts +its cwd first, which is why the cwd must be outside the repo. The child's +import location is checked too, so a wrong cwd fails instead of passing. +""" + +import importlib.metadata +import os +import pathlib +import re +import subprocess +import sys + +# Any real release is >= 0.2. With no tags reachable (a shallow clone), +# setuptools_scm's release-branch-semver scheme yields 0.1.0.devN, and other +# schemes yield 0.0.x. +MIN_RELEASE = (0, 2) + + +def fail(msg): + sys.exit("smoke FAILED: " + msg) + + +def check_version(version, expected): + major_minor = tuple(int(n) for n in re.match(r"(\d+)\.(\d+)", version).groups()) + if major_minor < MIN_RELEASE: + fail("setuptools_scm found no tags (shallow clone?): %s" % version) + if expected is not None and version != expected: + fail("expected version %s, got %s" % (expected, version)) + + +def main(argv): + expected = argv[1] if len(argv) > 1 else None + + import app_skellington + + location = pathlib.Path(app_skellington.__file__).resolve() + if "site-packages" not in location.parts: + fail("imported the source tree, not an installed copy: %s" % location) + + from app_skellington import ( # noqa: F401 (the public API must import) + ApplicationContainer, + CommandTree, + Config, + LoggingLayer, + NoCommandSpecified, + ServiceNotFound, + SubMenu, + ) + + check_version(importlib.metadata.version("app_skellington"), expected) + + # _bootstrap.py takes a different code path when APPSKELLINGTON_DEBUG is set. + env = dict(os.environ, APPSKELLINGTON_DEBUG="1") + proc = subprocess.run( + [ + sys.executable, + "-c", + "import app_skellington, pathlib;" + "print(pathlib.Path(app_skellington.__file__).resolve())", + ], + env=env, + stdout=subprocess.PIPE, + universal_newlines=True, + ) + if proc.returncode != 0: + fail("import failed with APPSKELLINGTON_DEBUG=1") + child_location = proc.stdout.strip().splitlines()[-1] + if child_location != str(location): + fail( + "APPSKELLINGTON_DEBUG import resolved %s, not the installed %s" + " (run from outside the source tree)" % (child_location, location) + ) + + print("smoke ok:", importlib.metadata.version("app_skellington"), location) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/scripts/verify_index_release.sh b/scripts/verify_index_release.sh new file mode 100755 index 0000000..5cc3ee1 --- /dev/null +++ b/scripts/verify_index_release.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Install a published app_skellington from a package index into a clean venv +# for every supported Python, then run scripts/smoke_import.py against it. +# +# Usage: scripts/verify_index_release.sh VERSION [INDEX_URL] +# INDEX_URL defaults to TestPyPI. Dependencies always come from PyPI, +# because TestPyPI hosts junk uploads under real dependency names. +set -euo pipefail + +version="${1:?usage: $0 VERSION [INDEX_URL]}" +index="${2:-https://test.pypi.org/simple/}" +here="$(cd "$(dirname "$0")" && pwd)" +pythons=(3.8 3.9 3.10 3.11 3.12 3.13 3.14) + +# The package must come from $index and nowhere else. Otherwise a local +# build could pass for the published release. --no-config ignores +# uv.toml/pyproject settings; these variables would still redirect, so refuse. +for var in UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL \ + UV_FIND_LINKS UV_INDEX_STRATEGY UV_NO_INDEX PIP_INDEX_URL \ + PIP_EXTRA_INDEX_URL PIP_FIND_LINKS; do + if [ -n "${!var:-}" ]; then + echo "refusing: $var is set and could redirect the install away from $index" >&2 + exit 2 + fi +done + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +for py in "${pythons[@]}"; do + venv="$work/$py" + uv venv -q --no-config --python "$py" "$venv" + # Wheel only: an sdist fallback would fetch its build backend from $index + # (TestPyPI). Not quiet, so the log shows what was installed. + uv pip install --no-config --python "$venv" --no-cache --no-deps \ + --only-binary :all: --index-url "$index" "app_skellington==$version" + "$venv/bin/python" -c 'import importlib.metadata as m; print("\n".join(m.requires("app_skellington") or []))' \ + > "$work/requirements.txt" + uv pip install -q --no-config --python "$venv" -r "$work/requirements.txt" + (cd / && "$venv/bin/python" "$here/smoke_import.py" "$version") +done + +echo "verified app_skellington==$version from $index on: ${pythons[*]}" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 8c28267..0000000 --- a/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[metadata] -description-file = README.md - diff --git a/tests/README.md b/tests/README.md index c08bb10..20f4492 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,15 +4,14 @@ Contained is the early stages of unit testing for app_skellington framework. Usage ----- -Run all tests (cwd is testing directory): +Run all tests (from the repo root): - pytest . + uv run pytest Run test by keyword: - pytest -k "" . + uv run pytest -k "" Run test by directory: - pytest - + uv run pytest tests/ diff --git a/tests/cfg/sample_config.ini b/tests/cfg/sample_config.ini index 1aa0d82..4120539 100644 --- a/tests/cfg/sample_config.ini +++ b/tests/cfg/sample_config.ini @@ -2,4 +2,3 @@ root_option = root_option_val [app] sub_option = sub_option_val - diff --git a/tests/cfg/sample_config.spec b/tests/cfg/sample_config.spec index ff7a49d..3b0ebd8 100644 --- a/tests/cfg/sample_config.spec +++ b/tests/cfg/sample_config.spec @@ -2,4 +2,3 @@ root_option = string(max=255, default='def_string') [app] sub_option = string(max=255, default='def_sub') - diff --git a/tests/cfg/sample_config_full.ini b/tests/cfg/sample_config_full.ini index 1aa0d82..4120539 100644 --- a/tests/cfg/sample_config_full.ini +++ b/tests/cfg/sample_config_full.ini @@ -2,4 +2,3 @@ root_option = root_option_val [app] sub_option = sub_option_val - diff --git a/tests/cfg/sample_config_full.spec b/tests/cfg/sample_config_full.spec index b448693..b8d30ee 100644 --- a/tests/cfg/sample_config_full.spec +++ b/tests/cfg/sample_config_full.spec @@ -3,4 +3,3 @@ int_option = integer(min=0, max=100) [app] sub_option = string(max=255, default='def_sub') - diff --git a/tests/cfg/sample_config_invalid.spec b/tests/cfg/sample_config_invalid.spec index e2b5f15..eb4c238 100644 --- a/tests/cfg/sample_config_invalid.spec +++ b/tests/cfg/sample_config_invalid.spec @@ -1,2 +1 @@ root_option = invalid(max=255, default='def_string') - diff --git a/tests/cfg/test_cfg.py b/tests/cfg/test_cfg.py index 219033f..ee2074b 100644 --- a/tests/cfg/test_cfg.py +++ b/tests/cfg/test_cfg.py @@ -32,18 +32,18 @@ def sample_invalid_configspec_filepath(): class TestConfig_e2e: def test_allows_reading_ini_and_no_spec(self, sample_configini_filepath): cfg = Config(configini_filepath=sample_configini_filepath) - assert ( - cfg["root_option"] == "root_option_val" - ), "expecting default from config.spec (didnt get)" - assert ( - cfg["app"]["sub_option"] == "sub_option_val" - ), "expecting default for sub option" + assert cfg["root_option"] == "root_option_val", ( + "expecting default from config.spec (didnt get)" + ) + assert cfg["app"]["sub_option"] == "sub_option_val", ( + "expecting default for sub option" + ) def test_allows_reading_spec_and_no_ini(self, sample_configspec_filepath): cfg = Config(configspec_filepath=sample_configspec_filepath) - assert ( - cfg["root_option"] == "def_string" - ), "expecting default from config.spec (didnt get)" + assert cfg["root_option"] == "def_string", ( + "expecting default from config.spec (didnt get)" + ) # NOTE(MG) Changed the functionality to not do it this way. # def test_constructor_fails_with_invalid_spec( @@ -71,9 +71,9 @@ class TestConfig_e2e: assert cfg["root_option"] == "newval" cfg["app"]["sub_option"] = "another_new_val" - assert ( - cfg["app"]["sub_option"] == "another_new_val" - ), "expecting default for sub option" + assert cfg["app"]["sub_option"] == "another_new_val", ( + "expecting default for sub option" + ) def test_can_set_option_without_config(self): cfg = Config() @@ -86,7 +86,7 @@ class TestConfig_e2e: def test_uses_spec_as_defaults(self, sample_configspec_filepath): cfg = Config(configspec_filepath=sample_configspec_filepath) - assert ( - cfg["root_option"] == "def_string" - ), "expecting default from config.spec (didnt get)" + assert cfg["root_option"] == "def_string", ( + "expecting default from config.spec (didnt get)" + ) assert cfg["app"]["sub_option"] == "def_sub", "expecting default for sub option" diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index 20daf7a..0000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -filterwarnings = - ignore::DeprecationWarning - diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py new file mode 100644 index 0000000..de7da1d --- /dev/null +++ b/tests/test_ci_workflow.py @@ -0,0 +1,133 @@ +"""Pins the CI workflow's invariants (spec: docs/superpowers/specs/ +2026-10-04-modernization-design.md). CI drift shows up as a red test here.""" + +import pathlib +import re + +import yaml + +ROOT = pathlib.Path(__file__).resolve().parent.parent +WORKFLOW = ROOT / ".gitea" / "workflows" / "ci.yaml" +VERIFY_SCRIPT = ROOT / "scripts" / "verify_index_release.sh" + +# Newest CPython the project claims. To add a version, bump this, the +# matrix, the classifiers and the verify script's list together. +NEWEST_MINOR = 14 + +WIP_CLAUSES = ( + "!startsWith(github.event.pull_request.title, 'WIP:')", + "!startsWith(github.event.pull_request.title, '[WIP]')", +) + + +def load(): + return yaml.safe_load(WORKFLOW.read_text()) + + +def triggers(wf): + # PyYAML follows YAML 1.1, where the bare key `on` parses as boolean True. + return wf["on"] if "on" in wf else wf[True] + + +def expected_versions(): + text = (ROOT / "pyproject.toml").read_text() + floor = int(re.search(r'requires-python\s*=\s*">=3\.(\d+)"', text).group(1)) + return ["3.%d" % minor for minor in range(floor, NEWEST_MINOR + 1)] + + +def flat(text): + return " ".join(text.split()) + + +def test_triggers(): + on = triggers(load()) + assert set(on["pull_request"]["types"]) == { + "opened", + "synchronize", + "reopened", + "edited", + } + assert on["push"]["branches"] == ["develop", "main"] + assert "workflow_dispatch" in on + + +def test_superseded_pr_runs_cancel_but_push_runs_never_do(): + assert ( + load()["concurrency"]["cancel-in-progress"] + == "${{ github.event_name == 'pull_request' }}" + ) + + +def test_every_job_is_wip_gated(): + # Compared whole, not by substring: `&&` turned into `||` must fail. + gate = "github.event_name != 'pull_request' || ( %s && %s )" % WIP_CLAUSES + for name, job in load()["jobs"].items(): + cond = flat(job.get("if", "")) + assert cond in (gate, "always() && ( %s )" % gate), name + + +def test_no_gate_can_be_softened_by_continue_on_error(): + # continue-on-error makes needs..result read success, so ci-ok + # would go green over a failure. + for name, job in load()["jobs"].items(): + assert "continue-on-error" not in job, name + for step in job.get("steps", []): + assert "continue-on-error" not in step, name + + +def test_each_lane_really_runs_its_matrix_python(): + test = load()["jobs"]["test"] + assert test["container"] == "python:${{ matrix.python }}" + assert test["env"]["UV_PYTHON"] == "${{ matrix.python }}" + assert test["env"]["UV_PYTHON_DOWNLOADS"] == "never" + + +def test_matrix_matches_requires_python(): + test = load()["jobs"]["test"] + assert test["strategy"]["matrix"]["python"] == expected_versions() + assert test["strategy"]["fail-fast"] is False + + +def test_classifiers_match_matrix(): + text = (ROOT / "pyproject.toml").read_text() + claimed = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text) + assert claimed == expected_versions() + + +def test_verify_script_covers_matrix(): + match = re.search(r"^pythons=\(([^)]*)\)", VERIFY_SCRIPT.read_text(), re.M) + assert match.group(1).split() == expected_versions() + + +def test_checkout_fetches_full_history_for_setuptools_scm(): + for name, job in load()["jobs"].items(): + for step in job.get("steps", []): + if step.get("uses", "").startswith("actions/checkout"): + assert step.get("with", {}).get("fetch-depth") == 0, name + + +def test_lint_job_runs_ruff(): + runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["lint"]["steps"])) + assert "uv run ruff check" in runs + assert "uv run ruff format --check" in runs + + +def test_test_lanes_run_locked_suite_and_smoke_test_both_artifacts(): + runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["test"]["steps"])) + assert "uv sync --locked" in runs + assert "uv run pytest" in runs + assert "uv build" in runs + assert "dist/*.whl dist/*.tar.gz" in runs + assert "scripts/smoke_import.py" in runs + + +def test_ci_ok_fails_unless_every_gate_succeeded(): + jobs = load()["jobs"] + ci_ok = jobs["ci-ok"] + # Gitea reports a skipped job as success, so ci-ok must run always() + # and check each result explicitly. + assert set(ci_ok["needs"]) == set(jobs) - {"ci-ok"} + assert flat(ci_ok["if"]).startswith("always() &&") + runs = " ".join(s.get("run", "") for s in ci_ok["steps"]) + for needed in ci_ok["needs"]: + assert 'test "${{ needs.%s.result }}" = success' % needed in runs diff --git a/tests/test_smoke_import.py b/tests/test_smoke_import.py new file mode 100644 index 0000000..2535bc5 --- /dev/null +++ b/tests/test_smoke_import.py @@ -0,0 +1,35 @@ +"""Unit tests for scripts/smoke_import.py's version checks.""" + +import importlib.util +import pathlib + +import pytest + +SCRIPT = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "smoke_import.py" +spec = importlib.util.spec_from_file_location("smoke_import", SCRIPT) +smoke_import = importlib.util.module_from_spec(spec) +spec.loader.exec_module(smoke_import) + + +@pytest.mark.parametrize( + "version", + [ + "0.0.0", + "0.0.1.dev3+gabc1234", + # release-branch-semver with no tags reachable (shallow clone): + "0.1.0.dev1+g6bd717f23", + ], +) +def test_tagless_versions_are_rejected(version): + with pytest.raises(SystemExit, match="no tags"): + smoke_import.check_version(version, expected=None) + + +@pytest.mark.parametrize("version", ["0.2.3", "0.3.0.dev23+gc361b286c", "1.0.0"]) +def test_tag_derived_versions_pass(version): + smoke_import.check_version(version, expected=None) + + +def test_expected_version_must_match_exactly(): + with pytest.raises(SystemExit, match="expected version 0.2.3"): + smoke_import.check_version("0.2.3.dev1", expected="0.2.3") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e8e0429 --- /dev/null +++ b/uv.lock @@ -0,0 +1,690 @@ +version = 1 +revision = 5 +requires-python = ">=3.8" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] + +[[package]] +name = "app-skellington" +source = { editable = "." } +dependencies = [ + { name = "appdirs" }, + { name = "colorlog" }, + { name = "configobj" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pre-commit", version = "4.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "appdirs" }, + { name = "colorlog" }, + { name = "configobj" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "ruff", specifier = "==0.16.10" }, +] + +[[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 = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[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 = "colorlog" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, +] + +[[package]] +name = "configobj" +version = "5.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/c4/c7f9e41bc2e5f8eeae4a08a01c91b2aea3dfab40a3e14b25e87e7db8d501/configobj-5.0.9.tar.gz", hash = "sha256:03c881bbf23aa07bccf1b837005975993c4ab4427ba57f959afdd9d1a2386848", size = 101518, upload-time = "2024-09-21T12:47:46.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/c4/0679472c60052c27efa612b4cd3ddd2a23e885dcdc73461781d2c802d39e/configobj-5.0.9-py2.py3-none-any.whl", hash = "sha256:1ba10c5b6ee16229c79a05047aeda2b55eb4e80d7c7d8ecf17ec1ca600c79882", size = 35615, upload-time = "2024-11-26T14:03:32.972Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' or python_full_version >= '3.11'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, +] +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.16.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037, upload-time = "2024-09-17T19:02:01.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163, upload-time = "2024-09-17T19:02:00.268Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "filelock" +version = "4.0.11" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/9c/925c4f62920e4200d573d2bfe5cfd1b993134c3aee5f9bd3b7c55e20297e/filelock-4.0.11.tar.gz", hash = "sha256:9dc453a1edd25626a6fc97c2cb1bd9f7e46345843d40b3181c071761fb9a5f7a", size = 253844, upload-time = "2026-10-05T00:57:32.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/4d/2983a9fc0a412a09151299d1ec9eb7511e3d577f7213e19395fabebf9db6/filelock-4.0.11-py3-none-any.whl", hash = "sha256:1cbdc4ace7fb11d2f6d073f5c3d91537a23b3d58d2bf2fdffc12c6d717a589c6", size = 111688, upload-time = "2026-10-05T00:57:30.896Z" }, +] + +[[package]] +name = "identify" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/29/bb/25024dbcc93516c492b75919e76f389bac754a3e4248682fba32b250c880/identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98", size = 99097, upload-time = "2024-09-14T23:50:32.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/0c/4ef72754c050979fdcc06c744715ae70ea37e734816bb6514f79df77a42f/identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", size = 98972, upload-time = "2024-09-14T23:50:30.747Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "identify" +version = "2.6.20" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285", size = 99681, upload-time = "2026-09-26T20:29:07.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506, upload-time = "2026-09-26T20:29:06.054Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +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 = "nodeenv" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +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 = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302, upload-time = "2024-09-17T19:06:50.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.12.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/42/23/4a86fc741c38c5b69792a4ef954b281afa69bea9f083f881de1b0d23bc07/platformdirs-4.12.3.tar.gz", hash = "sha256:427fc0bb321ae0c5b037fa03238ca74820437be162e78b4848c4d4055b9b766c", size = 59538, upload-time = "2026-10-03T23:40:46.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl", hash = "sha256:080f3b39423b5abfca9a23d84c4e9795f54d395cd8459867a8ded44084fcd5f8", size = 32493, upload-time = "2026-10-03T23:40:45.104Z" }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +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 = "pre-commit" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "identify", version = "2.6.1", source = { registry = "https://pypi.org/simple" } }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv", version = "21.4.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/b3/4ae08d21eb097162f5aad37f4585f8069a86402ed7f5362cc9ae097f9572/pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32", size = 177079, upload-time = "2023-10-13T15:57:48.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/75/526915fedf462e05eeb1c75ceaf7e3f9cde7b5ce6f62740fe5f7f19a0050/pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660", size = 203698, upload-time = "2023-10-13T15:57:46.378Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "identify", version = "2.6.15", source = { registry = "https://pypi.org/simple" } }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv", version = "21.14.5", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "cfgv", version = "3.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "identify", version = "2.6.20", source = { registry = "https://pypi.org/simple" } }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv", version = "21.14.5", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging", version = "26.2", source = { registry = "https://pypi.org/simple" } }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging", version = "26.3", source = { registry = "https://pypi.org/simple" } }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pygments" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging", version = "26.3", source = { registry = "https://pypi.org/simple" } }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { 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 = "python-discovery" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock", version = "3.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "filelock", version = "4.0.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/a2/09f67a3589cb4320fb5ce90d3fd4c9752636b8b6ad8f34b54d76c5a54693/PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", size = 186824, upload-time = "2025-09-29T20:27:35.918Z" }, + { url = "https://files.pythonhosted.org/packages/02/72/d972384252432d57f248767556ac083793292a4adf4e2d85dfe785ec2659/PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", size = 795069, upload-time = "2025-09-29T20:27:38.15Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3b/6c58ac0fa7c4e1b35e48024eb03d00817438310447f93ef4431673c24138/PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", size = 862585, upload-time = "2025-09-29T20:27:39.715Z" }, + { url = "https://files.pythonhosted.org/packages/25/a2/b725b61ac76a75583ae7104b3209f75ea44b13cfd026aa535ece22b7f22e/PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", size = 806018, upload-time = "2025-09-29T20:27:41.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/b2227677b2d1036d84f5ee95eb948e7af53d59fe3e4328784e4d290607e0/PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", size = 802822, upload-time = "2025-09-29T20:27:42.885Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/718a8ea22521e06ef19f91945766a892c5ceb1855df6adbde67d997ea7ed/PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", size = 143744, upload-time = "2025-09-29T20:27:44.487Z" }, + { url = "https://files.pythonhosted.org/packages/76/b2/2b69cee94c9eb215216fc05778675c393e3aa541131dc910df8e52c83776/PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", size = 160082, upload-time = "2025-09-29T20:27:46.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + +[[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 = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.4.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "distlib" }, + { name = "filelock", version = "3.16.1", source = { registry = "https://pypi.org/simple" } }, + { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-discovery" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/50/7564c805bb8966d9771caaba8a143fa5e57c848ce4e7fdf2d55a1feb2ead/virtualenv-21.4.3.tar.gz", hash = "sha256:938ff0fd3f4e0f0d3a025f67a3d2f25e3c3aabbcd5857ea6170619138d72d141", size = 7644454, upload-time = "2026-06-11T16:47:04.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/8d/84b0d07c6b5f685f85ddf6c87a59d3a8a895a3dfd89e759666fabe951b94/virtualenv-21.4.3-py3-none-any.whl", hash = "sha256:75f4127d4067397c64f38579ce918fec6bf9ca2cd4f48685e82952cc3c035840", size = 7625544, upload-time = "2026-06-11T16:47:01.78Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.14.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "distlib" }, + { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "filelock", version = "4.0.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", version = "26.3", source = { registry = "https://pypi.org/simple" } }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.12.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "python-discovery" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f9/f323b3b6058cff3853b31cf6a49c0425ed797bf9611572ec10d065e08bac/virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647", size = 5467127, upload-time = "2026-10-02T17:42:25.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl", hash = "sha256:b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da", size = 5487482, upload-time = "2026-10-02T17:42:23.38Z" }, +]