61 Commits
Author SHA1 Message Date
zavage 1fd190d036 Merge pull request 'release: develop → main for 0.2.3' (#18) from develop into main
Reviewed-on: https://git.zavage.net/Zavage-Software/app_skellington/pulls/18
2026-10-06 11:31:40 -06:00
zavage f37c07acf3 Merge pull request 'release: 0.2.3 prep — index verification script, changelog' (#15) from release/0.2.3-prep into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/app_skellington/pulls/15
2026-10-06 08:42:05 -06:00
golemandClaude Opus 5.5 63af95a769 fix(release): verify script installs only from the given index, wheel-only
Review findings:
- UV_FIND_LINKS / extra-index env vars or a uv.toml could redirect the
  install to a local build and false-pass the gate; now --no-config on
  every uv call and refuse to run when an index-redirecting var is set
- an sdist fallback would build with backends fetched from TestPyPI;
  install the package with --only-binary :all:
- show the package install (no -q) so the log records what was installed
- changelog: note the removed [dev] extra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 21:40:29 -06:00
golemandClaude Opus 5.5 d0e78f53fc release: verify_index_release.sh and 0.2.3 changelog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 21:35:52 -06:00
zavage 871dd45e57 Merge pull request 'ci: Gitea checks — lint + CPython 3.8–3.14 matrix + artifact smoke tests' (#13) from ci/gitea into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/app_skellington/pulls/13
2026-10-05 20:51:19 -06:00
golemandClaude Opus 5.5 41f91ce2b6 Revert canary: 3.14-only failure (proved one red lane turns ci-ok red, run 2685)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 15:32:34 -06:00
golemandClaude Opus 5.5 ee8dfe0bcc canary: fail only the 3.14 lane; ci-ok must still go red (reverted next)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:30:49 -06:00
golemandClaude Opus 5.5 509be4c6bc fix(ci): smoke guard catches tagless builds; DEBUG import must hit the install; pin more workflow invariants
Review findings on PR #13:
- release-branch-semver gives 0.1.0.devN without tags, so the 0.0 guard
  never fired; check_version() now requires >= 0.2 (unit-tested)
- the APPSKELLINGTON_DEBUG child import could resolve the source tree from
  the cwd and false-pass; it now reports its location and must match
- test_ci_workflow: no continue-on-error, matrix python drives container
  and UV_PYTHON, WIP gate compared whole (mutation-tested)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:30:41 -06:00
golemandClaude Opus 5.5 6bd717f231 Revert canary: failing test (test canary proved every lane + ci-ok go red, run 2683)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:25:35 -06:00
golemandClaude Opus 5.5 db78a21e5c canary: failing test must turn every test lane and ci-ok red (reverted next)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:24:06 -06:00
golemandClaude Opus 5.5 2e02acbbc1 Revert canary: unused import (lint canary proved lint + ci-ok go red, run 2682)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:24:03 -06:00
golemandClaude Opus 5.5 d79045d9aa canary: unused import must turn lint and ci-ok red (reverted next)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:22:04 -06:00
golemandClaude Opus 5.5 c361b286c9 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) <noreply@anthropic.com>
2026-10-05 14:20:10 -06:00
golemandClaude Opus 5.5 13aaa221ad test: smoke-test script for installed artifacts (catches the 0.2.2 import crash)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:18:43 -06:00
zavage 1c298e9be9 Merge pull request 'build: uv dependency groups, lockfile, packaging cleanup' (#8) from build/uv into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/app_skellington/pulls/8
2026-10-05 02:59:34 -06:00
golemandClaude Opus 5.5 bee5a691d9 doc: use uv run --isolated for other-Python test runs
A bare `uv run --python 3.8` rebuilds the project .venv on 3.8, after
which the pre-commit hook (pre-commit-hooks needs >=3.9) fails on commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:27:50 -06:00
golemandClaude Opus 5.5 7d80557a3e 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) <noreply@anthropic.com>
2026-10-05 00:21:58 -06:00
golemandClaude Opus 5.5 ecbdef0bbe 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) <noreply@anthropic.com>
2026-10-05 00:20:35 -06:00
zavage 46daadbaae Merge pull request 'style: adopt ruff at 88, replacing black/isort/flake8' (#5) from style/ruff into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/app_skellington/pulls/5
2026-10-05 00:18:02 -06:00
golemandClaude Opus 5.5 bca4c38f98 style: adopt ruff (format + lint) at 88, replacing black/isort/flake8
Format-only: Python files are AST-identical apart from import order.
pre-commit end-of-file/trailing-whitespace fixes applied repo-wide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 20:39:30 -06:00
zavage a6e09a5796 Merge pull request 'doc: CLAUDE.md and modernization spec' (#4) from docs/claude-md into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/app_skellington/pulls/4
2026-10-04 20:28:51 -06:00
golemandClaude Opus 5.5 1634ed154e doc: implementation plan for the modernization hotfix track (PRs 1-3 + 0.2.3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 20:17:24 -06:00
golemandClaude Opus 5.5 ae40f57e6b doc: spec review fixes; CLAUDE.md branch model is develop-integrates
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 20:06:40 -06:00
golemandClaude Opus 5.5 2ce8d75326 doc: modernization design spec (ruff, uv, Gitea CI matrix, 0.2.3 hotfix)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 20:04:34 -06:00
golemandClaude Opus 5.5 713c49b4c4 doc: Python support policy — drop EOL versions over contorting code
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:09:02 -06:00
golemandClaude Opus 5.5 da9568b4f9 doc: note editable install rewrites _version.py
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:53:41 -06:00
golemandClaude Opus 5.5 d02d38872c doc: add CLAUDE.md for Claude Code sessions
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:53:04 -06:00
zavage 3e8ad8fbb3 fix: python versions for windows and linux, and incrementing version 2025-02-22 20:58:58 -07:00
zavage d15517623d fix: moved some imports and code to fix an import error 2025-02-22 20:39:32 -07:00
Mathew Guest 10e873d2d6 Merge branch 'main' of git.zavage.net:Zavage-Software/app_skellington 2024-11-20 04:19:36 -07:00
Mathew Guest b743beb53a doc: changelog 2024-11-20 04:19:28 -07:00
Mathew Guest b2e082b7c6 doc: more cleanup and description, linking license 2024-11-19 23:59:42 -07:00
Mathew Guest f0fb18da71 doc: pypi instructions 2024-11-19 23:54:18 -07:00
Mathew Guest afd2b5eb42 doc: gitea toc 2024-11-19 23:49:03 -07:00
Mathew Guest 470514ba4b build: adding _version.py, small build config change, and doc note 2024-11-17 04:50:45 -07:00
Mathew Guest 55a9796806 doc: updating contact email 2024-11-16 22:05:18 -07:00
Mathew Guest 7647713498 build: removing dead code 2024-11-16 04:04:56 -07:00
Mathew Guest f0a4bdbced doc: updated git url, now using gitea over gogs 2024-11-16 03:16:04 -07:00
Mathew Guest adbae0074c build: turning on flake8 pre-commit hook 2024-11-16 03:03:35 -07:00
Mathew Guest fb0ef3d8f6 doc: more documentation on publishing 2024-11-16 03:02:32 -07:00
Mathew Guest 881a2db9dc doc: publish instructions 2024-11-16 02:06:00 -07:00
Mathew Guest ac4b765099 build: fixed flake8 warnings 2024-11-16 01:54:04 -07:00
Mathew Guest d76932bcb9 build: removing poetry from pre-commit 2024-11-16 01:10:34 -07:00
Mathew Guest 25ded9e2b3 style: black and isort 2024-11-16 00:57:00 -07:00
Mathew Guest 82543ce157 build: removed old pyproject.toml based on poetry 2024-11-16 00:37:19 -07:00
Mathew Guest 67a8e6e945 build: update pyproject.toml to conform to PEP 621 2024-11-16 00:32:10 -07:00
Mathew Guest 321f50b542 doc: updating url to project page
# Conflicts:
#	pyproject.toml
#	setup.py
2024-11-16 00:31:47 -07:00
Mathew Guest edb7cd346a doc: updating url to projectg page 2024-11-15 20:10:05 -07:00
Mathew Guest dab6154f33 feat: switched license to MIT no attribution 2024-11-15 19:44:11 -07:00
Mathew Guest 5c6a486913 build: added link to published pypi project 2024-11-15 06:06:30 -07:00
zavage 2b56b06f45 Merge pull request 'develop' (#1) from develop into master
Reviewed-on: https://git.zavage.net/Zavage-Software/app_skellington/pulls/1
2024-08-02 05:02:29 -06:00
Mathew Guest cb083cd38e doc: dev instructions for README.md 2024-08-02 05:00:58 -06:00
Mathew Guest 9a1999b8ab refactor: ran black and isort 2024-08-02 05:00:42 -06:00
Mathew Guest 586e7fa54d build: gitignore idea folder 2024-08-02 04:57:19 -06:00
Mathew Guest 0547b69b28 build: poetry, flake8, pre-commit, black, isort incl. in install 2024-08-02 04:57:01 -06:00
Mathew Guest fd5af59c59 fixed bug with incorrect variable used for logger_name 2022-04-20 17:44:39 -06:00
Mathew Guest 1f8013bd49 Merge branch 'master' of git.zavage.net:Zavage/app_skellington 2022-03-07 12:05:51 -07:00
Mathew Guest 6f5a6f5c91 backref to root app avail. in context 2022-03-07 12:05:38 -07:00
Mathew Guest 6805d18fad Merge branch 'master' of git.zavage.net:Zavage/app_skellington 2022-03-02 02:23:43 -07:00
Mathew Guest e7cfc394eb log configuration loads upon init if provided 2022-03-02 02:23:15 -07:00
Mathew Guest 78695ed62e renamed default logger and removed static reference to old name 2021-10-06 04:30:27 -06:00
34 changed files with 3095 additions and 552 deletions
+88
View File
@@ -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
+3 -1
View File
@@ -2,4 +2,6 @@ build
dist
*.egg-info
__pycache__
.idea
.venv/
app_skellington/_version.py
+18
View File
@@ -0,0 +1,18 @@
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
+1
View File
@@ -0,0 +1 @@
3.14
+38
View File
@@ -0,0 +1,38 @@
# Changelog
## [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.
* 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.
* Build and deploy process documented for developers.
* Poetry removed, replaced with modern pyproject.toml and setuptools.build_meta
for automatic version management.
## [0.1.0] (2020-07-19)
First release to PyPi.
* Basic functionality. Probably not the best code.
* Sub-menus supported, multi-level through CLI.
* Config through ConfigObj ini. Define spec file and input config.ini
* Colored Logging
* Services can be defined and provided to classes
+88
View File
@@ -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 "<keyword>"
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:<v>` 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`.
+15 -20
View File
@@ -1,25 +1,20 @@
App Skellington
Copyright (c) 2020 Mathew Guest
MIT No Attribution
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
Copyright (c) 2024 Mathew Guest
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
`NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+117 -27
View File
@@ -1,20 +1,47 @@
app_skellington
===============
---
gitea: none
include_toc: true
---
**app_skellington**
Application framework for Python, features include:
- Pain-free multi-level command menu: Expose public class methods as commands available to user.
- 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.
- Compatable 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
Application Configuration
-------------------------
# Python Package Index (PyPi) - Installation Instruction
This is the project page for PyPi: The Python Package Index for community third-party libraries
https://pypi.org/project/app-skellington/
```
pip install app_skellington # install
pip uninstall app_skellington # uninstall
pip download app_skellington # download .whl wheel files for redistributable install
pip install -U app_skellington # upgrade
pip list # list install packages in environment
pip index version app_skellington # enumerate available distributions in pypi for package
```
# Description
This is a library. There is nothing to run by itself. It would be helpful to have a sample application that uses
it or something, but I don't have that ready at the moment.
# Application Configuration
Site configurations are supported through ConfigObj. There is a config.spec
in the src directory which is a validation file; it contains the accepted
parameter names, types, and limits for configurable options in the
@@ -32,22 +59,20 @@ parameters are added into the config file.
Linux:
/home/\<user\>/.config/\<app_name\>/config.ini
/home/\<user\>/.cache/\<app_name\>/log/\<app_name\>.log
* /home/\<user\>/.config/\<app_name\>/config.ini
* /home/\<user\>/.cache/\<app_name\>/log/\<app_name\>.log
Windows:
C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\config.ini
C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\Logs\\\<app_name\>.log
* C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\config.ini
* C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\Logs\\\<app_name\>.log
Application configuration can be overridden ad-hoc through the --config <filename>
argument.
Debug - Turn on Logging
-----------------------
Set 'APPSKELLINGTON_ENABLE_LOGGING' environment variable to any value which turns
# Debug - Turn on Logging
Set the 'APPSKELLINGTON_DEBUG' environment variable to any value to turn
on AppSkellington-level logging. For example,
APPSKELLINGTON_DEBUG=1 <executable>
@@ -57,18 +82,83 @@ or
export APPSKELLINGTON_DEBUG=1
<executable>
Tests
-----
Tests are a WIP. Recommendation is to run 'pytest' in the 'tests' directory.
# Tests
License
-------
I'm releasing this software under one of the most permissive
licenses, the MIT software license. This applies to this source repository
and all files within it.
```
uv run pytest # full suite
uv run pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults
uv run pytest -k "<keyword>"
uv run --isolated --python 3.8 pytest # another supported Python; --isolated keeps .venv as is
```
Notes
-----
See official website: https://zavage-software.com
Please report bugs, improvements, or feedback!
# 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: <summary>"
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
See [license](LICENSE.txt)
MIT no attribution required - https://opensource.org/license/mit-0
* Allows commercial use.
* Allows modifications and closed-source derivatives.
* Fully interoperable with nearly all other open-source licenses, including GPL (when combined properly).
# See Also
* Project page: https://zavage-software.com/portfolio/app_skellington
* Please report bugs, improvements, or feedback!
* Contact: mat@zavage.net
* Packing and distribution conforms to PEP 621 https://peps.python.org/pep-0621/
* Reference https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/
+17 -6
View File
@@ -1,8 +1,19 @@
import logging
import sys
# flake8: noqa
from .app_container import *
from .cfg import *
from .cli import *
from .log import *
from .cfg import Config, EnvironmentVariables
from .app_container import (
DEFAULT_APP_AUTHOR,
DEFAULT_APP_NAME,
ApplicationContainer,
ApplicationContext,
)
from ._util import ServiceNotFound, NoCommandSpecified
from .cli import (
EXPLICIT_FAIL_ON_UNKNOWN_ARGS,
CommandEntry,
CommandTree,
HelpGenerator,
SubMenu,
)
from .log import LoggingLayer
+15 -10
View File
@@ -3,24 +3,28 @@ import os
import sys
# Check and gracefully fail if the user needs to install a 3rd-party dep.
libnames = ['appdirs', 'configobj', 'colorlog']
libnames = ["appdirs", "configobj", "colorlog"]
def check_env_has_dependencies(libnames):
rc = True
for libname in libnames:
try:
__import__(libname)
except ModuleNotFoundError as ex:
print('Missing third-party library: ', ex, file=sys.stderr)
print("Missing third-party library: ", ex, file=sys.stderr)
rc = False
return rc
if not check_env_has_dependencies(libnames):
print('Unable to load program without installed dependencies', file=sys.stderr)
raise ImportError('python environment needs third-party dependencies installed')
print("Unable to load program without installed dependencies", file=sys.stderr)
raise ImportError("python environment needs third-party dependencies installed")
# Logger for before the application and logging config is loaded
# - used to log before logging is configured
_log_fmt = '%(levelname)-7s:%(message)s'
_logger_name = 'app_skellington'
_log_fmt = "%(levelname)-7s:%(message)s"
_logger_name = "skell"
_bootstrap_logger = logging.getLogger(_logger_name)
# NOTE(MG) Logger monkey-patch:
@@ -29,13 +33,15 @@ _bootstrap_logger = logging.getLogger(_logger_name)
# configuration is reloaded. This catches APPSKELLINGTON_DEBUG
# environment variable the first time, as app_skellington module
# is imported. See cfg.py
if os.environ.get('APPSKELLINGTON_DEBUG', None):
_bootstrap_logger.setLevel(logging.DEBUG) # Don't filter any log levels
if os.environ.get("APPSKELLINGTON_DEBUG", None):
_bootstrap_logger.setLevel(logging.DEBUG) # Don't filter any log levels
fmt = logging.Formatter(_log_fmt)
handler = logging.StreamHandler()
handler.setFormatter(fmt)
_bootstrap_logger.addHandler(handler)
_bootstrap_logger.debug('log - APPSKELLINGTON_DEBUG set in environment: Enabling verbose logging.')
_bootstrap_logger.debug(
"log - APPSKELLINGTON_DEBUG set in environment: Enabling verbose logging."
)
# Logging is by default off, excepting CRITICAL
else:
@@ -45,4 +51,3 @@ _bootstrap_logger.propagate = False
# NOTE(MG) Pretty sure the logger has the default handler too at this point.
# It's been related to some issues with the logger double-printing messages.
_bootstrap_logger.addHandler(logging.NullHandler())
+27 -11
View File
@@ -1,9 +1,9 @@
from __future__ import print_function
import inspect
import os
import sys
from . import _util
def eprint(*args, **kwargs):
"""
@@ -11,6 +11,7 @@ def eprint(*args, **kwargs):
"""
print(*args, file=sys.stderr, **kwargs)
def filename_to_abspath(filename):
"""
Converts a filename to it's absolute path. If it's already an
@@ -18,6 +19,7 @@ def filename_to_abspath(filename):
"""
return os.path.abspath(filename)
def does_file_exist(filepath):
"""
Because the file can be deleted or created immediately after execution of
@@ -26,31 +28,31 @@ def does_file_exist(filepath):
instant in execution.
"""
try:
fp = open(filepath, 'r')
open(filepath, "r")
return True
except FileNotFoundError as ex:
except FileNotFoundError:
return False
def ensure_dir_exists(dirpath):
if dirpath is None:
return
if dirpath == '':
if dirpath == "":
return
os.makedirs(dirpath, exist_ok=True)
def get_root_asset(filepath):
"""
Attempts to locate a resource or asset shipped with the application.
Searches starting at the root module (__main__) which should be the
python file initially invoked.
"""
module_root =\
os.path.abspath(
os.path.dirname(
sys.modules['__main__'].__file__))
module_root = os.path.abspath(os.path.dirname(sys.modules["__main__"].__file__))
path = os.path.join(module_root, filepath)
return path
def get_asset(module, filepath):
"""
Attempts to locate a resource or asset shipped with the application.
@@ -75,7 +77,7 @@ def get_asset(module, filepath):
elif isinstance(module, module):
module_file = module.__file__
else:
raise Exception('Invalid Usage')
raise Exception("Invalid Usage")
try:
root = module_file
@@ -84,12 +86,13 @@ def get_asset(module, filepath):
root = os.path.realpath(root)
root = os.path.dirname(os.path.abspath(root))
except Exception as ex:
except Exception:
raise
path = os.path.join(root, filepath)
return path
def register_class_as_commands(app, submenu, cls_object):
"""
Registers commands for each class method. e.g.: pass in the CLI
@@ -105,7 +108,7 @@ def register_class_as_commands(app, submenu, cls_object):
for m in members:
name = m[0]
ref = m[1]
if inspect.isfunction(ref) and not name.startswith('_'):
if inspect.isfunction(ref) and not name.startswith("_"):
cls_method = ref
constructor = app._inject_service_dependencies(cls_constructor)
sig = inspect.signature(cls_method)
@@ -114,9 +117,22 @@ def register_class_as_commands(app, submenu, cls_object):
docstring = inspect.getdoc(cls_method)
submenu.register_command(func, name, sig, docstring)
def create_func(constructor, cls_method):
def func(*args, **kwargs):
cmd_class_instance = constructor()
return cls_method(cmd_class_instance, *args, **kwargs)
return func
class ServiceNotFound(Exception):
"""
Application framework error: unable to find and inject dependency.
"""
pass
class NoCommandSpecified(Exception):
pass
+50 -50
View File
@@ -1,32 +1,35 @@
import appdirs
import collections
import functools
import inspect
import os
import sys
import appdirs
from . import log
# Application scaffolding:
from ._bootstrap import _bootstrap_logger
from . import log
from . import _util
from . import cli
from . import cfg
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.
DEFAULT_APP_NAME = ''
DEFAULT_APP_AUTHOR = ''
DEFAULT_APP_NAME = ""
DEFAULT_APP_AUTHOR = ""
class ApplicationContext:
"""
Container for application-wide state; i.e. app configuration and loggers.
"""
def __init__(self, config, log):
self.config = config
self.log = log
self.parsed_argv = None
self.parsed_argv_unknown = None
class ApplicationContainer:
"""
Generalized application functionality. Used for linking components and modules of the application
@@ -38,35 +41,43 @@ class ApplicationContainer:
Override appname and appauthor arguments to direct config and log
directories.
"""
def __init__(
self,
configspec_filepath=None,
configini_filepath=None,
*args, **kwargs
self, configspec_filepath=None, configini_filepath=None, *args, **kwargs
):
self.appname = kwargs.get('appname') or DEFAULT_APP_NAME
self.appauthor = kwargs.get('appauthor') or DEFAULT_APP_AUTHOR
self.appname = kwargs.get("appname") or DEFAULT_APP_NAME
self.appauthor = kwargs.get("appauthor") or DEFAULT_APP_AUTHOR
# Instantiate application context which contains
# global state, configuration, loggers, and runtime args.
self._dependencies = {}
config = cfg.Config(configspec_filepath, configini_filepath)
config = Config(configspec_filepath, configini_filepath)
logger = log.LoggingLayer(self.appname, self.appauthor)
logger.configure_logging()
# Try and load logging configuration if provided
log_config = config.get("logging")
if log_config is not None:
logger.configure_logging(log_config)
else:
logger.configure_logging()
self.ctx = ApplicationContext(config, logger)
self['ctx'] = lambda: self.ctx
self.cli = cli.CommandTree() # Command-line interface
# Reference to root_app avail. in context
self.ctx.root_app = self
# Reference to context service avail. in root_app
self["ctx"] = lambda: self.ctx
self.cli = CommandTree() # Command-line interface
# Run methods if subclass implemented them:
if callable(getattr(self, '_cli_options', None)):
if callable(getattr(self, "_cli_options", None)):
self._cli_options()
if callable(getattr(self, '_services', None)):
if callable(getattr(self, "_services", None)):
self._services()
if callable(getattr(self, '_command_menu', None)):
if callable(getattr(self, "_command_menu", None)):
self._command_menu()
def __delitem__(self, service_name):
@@ -75,7 +86,7 @@ class ApplicationContainer:
"""
try:
del self._dependencies[service_name]
except KeyError as ex:
except KeyError:
pass
def __getitem__(self, service_name):
@@ -86,10 +97,12 @@ class ApplicationContainer:
app['datalayer'] => returns the made-up "datalayer" service.
"""
try:
service_factory = self._dependencies[service_name] # Retrieve factory function
return service_factory() # Call factory() to return instance of service
except KeyError as ex:
msg = 'failed to inject service: {}'.format(service_name)
service_factory = self._dependencies[
service_name
] # Retrieve factory function
return service_factory() # Call factory() to return instance of service
except KeyError:
msg = "failed to inject service: {}".format(service_name)
_bootstrap_logger.critical(msg)
raise ServiceNotFound
@@ -118,9 +131,7 @@ class ApplicationContainer:
dependencies.append(self[dep_name])
return model_constructor(*dependencies)
def _get_config_filepath(
self, app_name, app_author, config_filename='config.ini'
):
def _get_config_filepath(self, app_name, app_author, config_filename="config.ini"):
"""
Attempt to find config.ini in the user's config directory.
@@ -129,14 +140,14 @@ class ApplicationContainer:
"""
dirname = appdirs.user_config_dir(app_name, app_author)
filepath = os.path.join(dirname, config_filename)
_bootstrap_logger.info('default config filepath calculated to be: %s', filepath)
_bootstrap_logger.info("default config filepath calculated to be: %s", filepath)
return filepath
def _get_configspec_filepath(self, configspec_filename='config.spec'):
def _get_configspec_filepath(self, configspec_filename="config.spec"):
"""
Attempt to find config.spec inside the installed package directory.
"""
return _util.get_root_asset(configspec_filename)
return get_root_asset(configspec_filename)
def _inject_service_dependencies(self, constructor):
"""
@@ -148,8 +159,10 @@ class ApplicationContainer:
"""
sig = inspect.signature(constructor.__init__)
params = sig.parameters
params = [params[paramname].name for paramname in params] # Convert Param() type => str
cls_dependencies = params[1:] # Skip 'self' parameter on class methods.
params = [
params[paramname].name for paramname in params
] # Convert Param() type => str
cls_dependencies = params[1:] # Skip 'self' parameter on class methods.
return functools.partial(self._construct_model, constructor, *cls_dependencies)
@@ -167,11 +180,8 @@ class ApplicationContainer:
return False
try:
self.cli.run_command()
except NoCommandSpecified as ex:
print('Failure: No command specified.')
def interactive_shell(self):
pass
except NoCommandSpecified:
print("Failure: No command specified.")
def invoke_from_cli(self):
self.invoke_command()
@@ -179,13 +189,3 @@ class ApplicationContainer:
def usage(self):
pass
# Applications need a default usage
class ServiceNotFound(Exception):
"""
Application framework error: unable to find and inject dependency.
"""
pass
class NoCommandSpecified(Exception):
pass
+79 -53
View File
@@ -4,33 +4,32 @@
# ConfigObj module and it's recommended to use config.spec files to define
# your available configuration of the relevant application.
from . import _util
import configobj
import validate
from ._bootstrap import _bootstrap_logger
import appdirs
import argparse
import configobj
import os
import sys
import validate
class Config:
"""
Structure to store application runtime configuration. Also contains
functionality to load configuration from local site file.
Provide config.spec - specification file which defines allowed parameters and types.
Provide config.ini - configuration instance which contains values for any
configuration arguments.
"""
DEFAULT_CAPABILITIES = {
'allow_options_beyond_spec': True,
"allow_options_beyond_spec": True,
}
def __init__(
self,
configspec_filepath=None,
configini_filepath=None,
capabilities=None
self, configspec_filepath=None, configini_filepath=None, capabilities=None
):
self._config_obj = None # must be type configobj.ConfigObj()
self._config_obj = None # must be type configobj.ConfigObj()
self._configini_data = None
self._configini_filepath = None
self._configspec_data = None
@@ -78,9 +77,7 @@ class Config:
@configspec_filepath.setter
def configspec_filepath(self, filepath):
if filepath is None:
_bootstrap_logger.debug(
'cfg - Clearing configspec'
)
_bootstrap_logger.debug("cfg - Clearing configspec")
self._configspec_filepath = None
self._configspec_data = None
self._has_changed_internally = True
@@ -94,17 +91,15 @@ class Config:
self._configspec_data = data
self._has_changed_internally = True
_bootstrap_logger.debug(
'cfg - Set configspec and read contents: %s',
filepath
"cfg - Set configspec and read contents: %s", filepath
)
self.load_config()
return
except OSError as ex:
except OSError:
_bootstrap_logger.critical(
'cfg - Failed to find config.spec: file not found (%s)',
filepath
"cfg - Failed to find config.spec: file not found (%s)", filepath
)
raise OSError('Failed to read provided config.spec file')
raise OSError("Failed to read provided config.spec file")
self.load_config()
@@ -112,7 +107,7 @@ class Config:
try:
has_item = key in self._config_obj
return has_item
except KeyError as ex:
except KeyError:
pass
def __delitem__(self, key):
@@ -122,7 +117,7 @@ class Config:
"""
try:
del self[key]
except KeyError as ex:
except KeyError:
pass
def __getitem__(self, key):
@@ -136,7 +131,7 @@ class Config:
else:
# return self._config_obj[key].dict()
return self._config_obj[key]
except KeyError as ex:
except KeyError:
raise
def __setitem__(self, key, value):
@@ -146,9 +141,20 @@ class Config:
"""
self._config_obj[key] = value
def load_config(
self, configspec_filepath=None, configini_filepath=None
):
def get(self, key, default=None):
"""
Attempt to retrieve configuration item, otherwise return default
provided value.
Similar to Dictionary.get()
"""
try:
v = self.__getitem__(key)
return v
except KeyError:
return default
def load_config(self, configspec_filepath=None, configini_filepath=None):
# Set new arguments if were passed in:
if configspec_filepath is not None:
self.configspec_filepath = configspec_filepath
@@ -161,7 +167,7 @@ class Config:
rc = self._validate_config_against_spec()
if not rc:
if self._capability_enforce_strict_spec_validation:
raise RuntimeError('Failed to validate config.ini against spec.')
raise RuntimeError("Failed to validate config.ini against spec.")
return False
return True
@@ -178,83 +184,103 @@ class Config:
# options
configspec=config_spec,
# encoding
interpolation='template'
interpolation="template",
# raise_errors
)
_bootstrap_logger.debug(
'cfg - Parsed configuration. config.spec = %s, config.ini = %s',
config_spec, config_ini
"cfg - Parsed configuration. config.spec = %s, config.ini = %s",
config_spec,
config_ini,
)
return True
except configobj.ParseError as ex:
msg = 'cfg - Failed to load config: error in config.spec configuration: {}'.format(config_filepath)
except configobj.ParseError:
msg = f"cfg - Failed to load config: error in config.spec configuration: {self.configspec_filepath}"
_bootstrap_logger.error(msg)
return False
except OSError as ex:
msg = 'cfg - Failed to load config: config.spec file not found.'
except OSError:
msg = "cfg - Failed to load config: config.spec file not found."
_bootstrap_logger.error(msg)
return False
except Exception as ex:
print(ex)
_bootstrap_logger.error(ex)
return False
def _validate_config_against_spec(self):
config_spec = self.configspec_filepath
config_ini = self.configini_filepath
# Hack the configobj module to alter the interpolation for validate.py:
configobj.DEFAULT_INTERPOLATION = 'template'
configobj.DEFAULT_INTERPOLATION = "template"
# Validate config.ini against config.spec
try:
_bootstrap_logger.info('cfg - Validating config file against spec')
_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
)
if test_results is True:
_bootstrap_logger.info(
'cfg- Successfully validated configuration against spec. input = %s, validation spec = %s',
config_ini, config_spec
"cfg- Successfully validated configuration against spec. input = %s, validation spec = %s",
config_ini,
config_spec,
)
return True
elif test_results is False:
_bootstrap_logger.debug(
'cfg - Potentially discovered invalid config.spec'
"cfg - Potentially discovered invalid config.spec"
)
else:
self._validate_parse_errors(test_results)
return False
except ValueError as ex:
_bootstrap_logger.error('cfg - Failed while validating config against spec. ')
except ValueError:
_bootstrap_logger.error(
"cfg - Failed while validating config against spec. "
)
return False
def _validate_parse_errors(self, test_results):
_bootstrap_logger.critical('cfg - Config file failed validation.')
for (section_list, key, rslt) in configobj.flatten_errors(self._config_obj, test_results):
_bootstrap_logger.critical('cfg - Config error info: %s %s %s', section_list, key, rslt)
_bootstrap_logger.critical("cfg - Config file failed validation.")
for section_list, key, rslt in configobj.flatten_errors(
self._config_obj, test_results
):
_bootstrap_logger.critical(
"cfg - Config error info: %s %s %s", section_list, key, rslt
)
if key is not None:
_bootstrap_logger.critical('cfg - Config failed validation: [%s].%s appears invalid. msg = %s', '.'.join(section_list), key, rslt)
_bootstrap_logger.critical(
"cfg - Config failed validation: [%s].%s appears invalid. msg = %s",
".".join(section_list),
key,
rslt,
)
else:
_bootstrap_logger.critical("cfg - Config failed validation: missing section, name = '%s'. msg = %s", '.'.join(section_list), rslt)
_bootstrap_logger.critical(
"cfg - Config failed validation: missing section, name = '%s'. msg = %s",
".".join(section_list),
rslt,
)
def print_config(self):
"""
Print configuration to stdout.
"""
print('config:')
print("config:")
self._config_obj.walk(print)
for section in self._config_obj.sections:
print(section)
for key in self._config_obj[section]:
print(' ', self._config_obj[section][key])
print(" ", self._config_obj[section][key])
class EnvironmentVariables:
def __init__(self):
raise NotImplementedError
+115 -141
View File
@@ -1,18 +1,17 @@
import argparse
import inspect
import logging
import re
import sys
import app_skellington
from ._bootstrap import _bootstrap_logger
from . import app_container
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
# will be ignored.
EXPLICIT_FAIL_ON_UNKNOWN_ARGS = True
class CommandTree:
"""
Command-line interface to hold a menu of commands. You can register
@@ -42,20 +41,21 @@ class CommandTree:
./scriptname --option="value" [submenu] [command]
is different than
is different from
./scriptname [submenu] [command] --option="value"
in that option is being applied to the application in the first example and
applied to the refresh_datasets command (under the nhsn command group) in
applied to the command (under the submenu command group) in
the second. In the same way the -h, --help options print different docs
depending on where the help option was passed.
"""
def __init__(self):
self.root_parser = argparse.ArgumentParser()
self.submenu_param = None # submenu_param is the variable name
# of the root submenu argument, i.e. the arg
# in root_parser which selects the submenu.
self.submenu_param = None # submenu_param is the variable name
# of the root submenu argument, i.e. the arg
# in root_parser which selects the submenu.
self.entries = {}
# NOTE(MG) Implementation note:
# CommandTree uses only one of these internal structures (i.e. mutually exclusive),
@@ -66,13 +66,15 @@ class CommandTree:
self._single_command = None
def print_tree(self):
raise NotImplemented
raise NotImplementedError
def add_argument(self, *args, **kwargs):
"""
Adds an argument to the root parser.
"""
_bootstrap_logger.info('adding argument to root parser: %s and %s', args, kwargs)
_bootstrap_logger.info(
"adding argument to root parser: %s and %s", args, kwargs
)
self.root_parser.add_argument(*args, **kwargs)
def init_submenu(self, param_name, is_required=False):
@@ -80,41 +82,25 @@ class CommandTree:
Creates a root-level submenu with no entries. SubMenu node is
returned which can have submenus and commands attached to it.
"""
# NOTE(MG) Fix below strategizes whether to pass in 'required'
# paremter to ArgumentParser.add_subparsers()
# which was added in in Python3.7.
# Must also be written into SubMenu.create_submenu.
func_args = {
'dest': param_name,
'metavar': param_name,
'required': is_required
}
if (
sys.version_info.major == 3
and sys.version_info.minor <= 6
):
if is_required:
_bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7')
del func_args['required']
func_args = {"dest": param_name, "metavar": param_name, "required": is_required}
# Creates an argument as a slot in the underlying argparse.
subparsers = self.root_parser.add_subparsers(
**func_args
)
subparsers = self.root_parser.add_subparsers(**func_args)
submenu = SubMenu(self, subparsers, param_name)
submenu.submenu_path = ''
submenu.submenu_path = ""
submenu.var_name = param_name
_bootstrap_logger.info('Initialized root-level submenu: Parameter = \'%s\'', param_name)
_bootstrap_logger.info(
"Initialized root-level submenu: Parameter = '%s'", param_name
)
self.entries[param_name] = submenu
self.submenu_param = param_name
return submenu
def register_command(
self, func, cmd_name=None, func_signature=None,
docstring=None
self, func, cmd_name=None, func_signature=None, docstring=None
):
"""
When no submenu functionality is desired, this links a single
@@ -128,7 +114,7 @@ class CommandTree:
pass
# print('func is method')
else:
raise Exception('bad value passed in for function')
raise Exception("bad value passed in for function")
if not cmd_name:
# safe try/except
@@ -145,35 +131,30 @@ class CommandTree:
# help is displayed next to the command in the submenu enumeration or
# list of commands:
help_text = HelpGenerator.generate_help_from_sig(docstring)
HelpGenerator.generate_help_from_sig(docstring)
# description is displayed when querying help for the specific command:
description_text = HelpGenerator.generate_description_from_sig(docstring)
HelpGenerator.generate_description_from_sig(docstring)
# end copy-paste from SubMenu.register_command
# begin copy-paste then editted from SubMenu.register_command
# For each paramter in the function create an argparse argument in
# begin copy-paste then edited from SubMenu.register_command
# For each parameter in the function create an argparse argument in
# the child ArgumentParser created for this menu entry:
for key in params:
if key == 'self':
if key == "self":
continue
param = params[key]
if '=' in str(param):
if "=" in str(param):
if param.default is None:
helptext = 'default provided'
helptext = "default provided"
else:
helptext = "default = '{}'".format(param.default)
self.root_parser.add_argument(
key,
help=helptext,
nargs='?',
default=param.default
key, help=helptext, nargs="?", default=param.default
)
else:
helptext = 'required'
self.root_parser.add_argument(
key,
help=helptext)
helptext = "required"
self.root_parser.add_argument(key, help=helptext)
# Build the CommandEntry structure
cmd = CommandEntry()
@@ -184,8 +165,8 @@ class CommandTree:
cmd.callback = func
registered_name = cmd_name
_bootstrap_logger.info('registered command: %s', registered_name)
# end copy-paste then editted from SubMenu.register_command
_bootstrap_logger.info("registered command: %s", registered_name)
# end copy-paste then edited from SubMenu.register_command
self._cmd_tree_is_single_command = True
self._single_command = cmd
@@ -209,33 +190,39 @@ class CommandTree:
# 'failed to parse arguments: explicitly failing to be safe')
# return False, False
if hasattr(pargs, 'usage'):
if hasattr(pargs, "usage"):
pass
# print('found usage in app_skellington')
return pargs, unk, True
# Note: SystemExit is raised when '-h' argument is supplied.
except SystemExit as ex:
except SystemExit:
return None, None, False
def run_command(self, args=None):
"""
Args:
args (list[str]): Direct from STDIN
Raises:
NoCommandSpecified for invalid command.
"""
args, unk, success = self.parse(args)
if not success:
_bootstrap_logger.info('cli - SystemExit: Perhaps user invoked --help')
_bootstrap_logger.info("cli - SystemExit: Perhaps user invoked --help")
return
if args is False and unk is False:
_bootstrap_logger.error('cli - Failed parsing args.')
_bootstrap_logger.error("cli - Failed parsing args.")
return False
_bootstrap_logger.info('cli - Received args from shell: %s', args)
_bootstrap_logger.info("cli - Received args from shell: %s", args)
args = vars(args)
cmd = self._lookup_command(args)
if cmd is None:
_bootstrap_logger.critical('cli - Failed to find command.')
return False
_bootstrap_logger.critical("cli - Failed to find command.")
raise NoCommandSpecified
return self._invoke_command(cmd, args)
@@ -246,31 +233,40 @@ 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._entries is None, 'corrupt data structure in CommandMenu'
assert isinstance(self._single_command, CommandEntry), '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"
)
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 == False, 'corrupt data structure in CommandMenu'
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"
)
# Key or variable name used by argparse to store the submenu options
argparse_param = self.submenu_param # e.g.: submenu_root
argparse_param = self.submenu_param # e.g.: submenu_root
submenu = self.entries[argparse_param]
while True:
if argparse_param not in keys:
print('root menu parameter not found in args:', argparse_param)
input('<broken>')
print("root menu parameter not found in args:", argparse_param)
input("<broken>")
val = args.get(argparse_param)
_bootstrap_logger.debug('cli - argparse command is \'{}\' = {}'.format(argparse_param, val))
_bootstrap_logger.debug(
"cli - argparse command is '{}' = {}".format(argparse_param, val)
)
lookup = submenu.entries.get(val)
_bootstrap_logger.debug('cli - lookup, entries[{}] = {}'.format(val, lookup))
_bootstrap_logger.debug(
"cli - lookup, entries[{}] = {}".format(val, lookup)
)
# pop value
del args[argparse_param]
@@ -283,7 +279,7 @@ class CommandTree:
# return self._invoke_command(lookup, args)
else:
raise app_container.NoCommandSpecified('No command specified.')
raise NoCommandSpecified("No command specified.")
def _invoke_command(self, cmd, args):
command_to_be_invoked = cmd.callback
@@ -296,16 +292,17 @@ class CommandTree:
if param.name in args:
func_args.append(args[param.name])
_bootstrap_logger.info('cli - function: %s', func)
_bootstrap_logger.info('cli - function args: %s', func_args)
_bootstrap_logger.info("cli - function: %s", func)
_bootstrap_logger.info("cli - function args: %s", func_args)
return command_to_be_invoked(*func_args)
def _get_subparser(self):
return self.root_parser._subparsers._actions[1]
class SubMenu:
def __init__(self, parent, subparsers_obj, name):
self.parent = parent # Reference to root CommandTree
self.parent = parent # Reference to root CommandTree
self.subparsers_obj = subparsers_obj
self.name = name
self.submenu_path = None
@@ -313,8 +310,7 @@ class SubMenu:
self.entries = {}
def register_command(
self, func, cmd_name=None, func_signature=None,
docstring=None
self, func, cmd_name=None, func_signature=None, docstring=None
):
"""
Registers a command as an entry in this submenu. Provided function is
@@ -345,7 +341,7 @@ class SubMenu:
elif inspect.ismethod(func):
pass
else:
raise Exception('bad value passed in for function')
raise Exception("bad value passed in for function")
if not cmd_name:
# TODO(MG) Safer sanitation
@@ -369,38 +365,32 @@ class SubMenu:
# Entry in local argparse._SubParsersAction
# type = ArgumentParser
child_node = self.subparsers_obj.add_parser(
cmd_name, # Note: cmd_name here will be the VALUE
# passed into the argparse arg VARIABLE NAME
# created when the SubMenu/argparse.add_subparsers()
# was created.
cmd_name, # Note: cmd_name here will be the VALUE
# passed into the argparse arg VARIABLE NAME
# created when the SubMenu/argparse.add_subparsers()
# was created.
help=help_text,
description=description_text
description=description_text,
)
# For each paramter in the function create an argparse argument in
# the child ArgumentParser created for this menu entry:
for key in params:
if key == 'self':
if key == "self":
continue
param = params[key]
if '=' in str(param):
if "=" in str(param):
if param.default is None:
helptext = 'default provided'
helptext = "default provided"
else:
helptext = "default = '{}'".format(param.default)
child_node.add_argument(
key,
help=helptext,
nargs='?',
default=param.default
key, help=helptext, nargs="?", default=param.default
)
else:
helptext = 'required'
child_node.add_argument(
key,
help=helptext
)
helptext = "required"
child_node.add_argument(key, help=helptext)
# Build the CommandEntry structure
cmd = CommandEntry()
@@ -410,15 +400,11 @@ class SubMenu:
# cmd.func_ref = None
cmd.callback = func
registered_name = '{}.{}'.format(
self.submenu_path,
cmd_name)
_bootstrap_logger.info('cli - registered command: %s', registered_name)
registered_name = "{}.{}".format(self.submenu_path, cmd_name)
_bootstrap_logger.info("cli - registered command: %s", registered_name)
self.entries[cmd_name] = cmd
def create_submenu(
self, var_name, cmd_entry_name=None, is_required=False
):
def create_submenu(self, var_name, cmd_entry_name=None, is_required=False):
"""
Creates a child-submenu.
@@ -443,52 +429,39 @@ class SubMenu:
# Create an entry in self's submenu:
# type = ArgumentParser
entry_node = self.subparsers_obj.add_parser(
cmd_entry_name,
help='sub-submenu help',
description='sub-sub description')
cmd_entry_name, help="sub-submenu help", description="sub-sub description"
)
# NOTE(MG) Fix below strategizes whether to pass in 'required'
# paremter to ArgumentParser.add_subparsers()
# which was added in in Python3.7.
# NOTE(MG) Fix for Python>=3.7,
# argparse.ArgumentParser added 'required' argument.
# Must also be written into CommandTree.init_submenu
func_args = {
'dest': var_name,
'metavar': var_name,
'required': is_required
}
if (
sys.version_info.major == 3
and sys.version_info.minor <= 6
):
func_args = {"dest": var_name, "metavar": var_name, "required": is_required}
if sys.version_info.major == 3 and sys.version_info.minor < 7:
if is_required:
_bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7')
del func_args['required']
_bootstrap_logger.warn(
"Unable to enforce required submenu: Requires >= Python 3.7"
)
del func_args["required"]
# END fix for Python<3.7
# Turn entry into a submenu of it's own:
# type = _SubParsersAction
subp_node = entry_node.add_subparsers(
**func_args
)
subp_node = entry_node.add_subparsers(**func_args)
submenu = SubMenu(
self.parent,
subp_node,
cmd_entry_name
)
submenu = SubMenu(self.parent, subp_node, cmd_entry_name)
submenu.var_name = var_name
submenu.submenu_path = '{}.{}'.format(self.submenu_path, cmd_entry_name)
submenu.submenu_path = "{}.{}".format(self.submenu_path, cmd_entry_name)
submenu_name = submenu.submenu_path
_bootstrap_logger.info('cli - registered submenu: %s', submenu_name)
_bootstrap_logger.info("cli - registered submenu: %s", submenu_name)
self.entries[cmd_entry_name] = submenu
return submenu
def __repr__(self):
return 'SubMenu({})<{}>'.format(
self.name,
','.join(['cmds'])
)
return "SubMenu({})<{}>".format(self.name, ",".join(["cmds"]))
class CommandEntry:
"""
@@ -504,18 +477,20 @@ class CommandEntry:
arguments into argparse options (creating the documentation also). Similary,
it can convert from argparse options into a function call.
"""
def __init__(self):
self.argparse_node = None
self.cmd_name = None # Don't think we need. And needs to be changed
# from SubMenu
self.cmd_name = None # Don't think we need. And needs to be changed
# from SubMenu
self.menu_path = None
self.func_signature = None
self.func_ref = None
self.callback = None
def __repr__(self):
return 'CommandEntry<{}>'.format(self.cmd_name)
return "CommandEntry<{}>".format(self.cmd_name)
class HelpGenerator:
def __init__(self):
@@ -527,12 +502,12 @@ class HelpGenerator:
The 'help' text is displayed next to the command when enumerating
the submenu commands.
"""
if doctext == None:
if doctext is None:
return doctext
regex = '(.*?)[.?!]'
regex = "(.*?)[.?!]"
match = re.match(regex, doctext, re.MULTILINE | re.DOTALL)
if match:
return match.group(1) + '.'
return match.group(1) + "."
return doctext
@staticmethod
@@ -541,11 +516,10 @@ class HelpGenerator:
The 'description' paragraph is provided when the user requests help
on a specific command.
"""
if doctext == None:
if doctext is None:
return doctext
regex = '(.*?)[.?!]'
regex = "(.*?)[.?!]"
match = re.match(regex, doctext, re.MULTILINE | re.DOTALL)
if match:
return match.group(1) + '.'
return match.group(1) + "."
return doctext
+76 -70
View File
@@ -1,49 +1,47 @@
from ._bootstrap import _bootstrap_logger
from . import _util
import appdirs
import colorlog
import logging
import logging.config
import os
import appdirs
from . import _util
from ._bootstrap import _bootstrap_logger, _logger_name
DEFAULT_LOG_SETTINGS = {
'formatters': {
'colored': {
'class': 'colorlog.ColoredFormatter',
"formatters": {
"colored": {
"class": "colorlog.ColoredFormatter",
# 'format': '%(log_color)s%(levelname)-8s%(reset)s:%(log_color)s%(name)-5s%(reset)s:%(white)s%(message)s'
'format': '%(white)s%(name)7s%(reset)s|%(log_color)s%(message)s',
"format": "%(white)s%(name)7s%(reset)s|%(log_color)s%(message)s",
}
},
'handlers': {
'stderr': {
'class': 'logging.StreamHandler',
'level': 'debug',
'formatter': 'colored'
"handlers": {
"stderr": {
"class": "logging.StreamHandler",
"level": "debug",
"formatter": "colored",
}
},
'loggers': {
'root': {
'handlers': ['stderr',],
'level': 'debug'
"loggers": {
"root": {
"handlers": [
"stderr",
],
"level": "debug",
},
'app_skellington': {
"app_skellington": {
# 'handlers': ['stderr',],
'level': 'critical',
'propagate': 'false'
}
}
"level": "critical",
"propagate": "false",
},
},
}
class LoggingLayer:
def __init__(
self, appname=None, appauthor=None
):
self.appname = appname or ''
self.appauthor = appauthor or ''
def __init__(self, appname=None, appauthor=None):
self.appname = appname or ""
self.appauthor = appauthor or ""
self.loggers = {}
def __getitem__(self, k):
@@ -72,18 +70,19 @@ class LoggingLayer:
noise for typical operation.
"""
if config_dict is None:
_bootstrap_logger.debug('log - No application logging configuration provided. Using default')
_bootstrap_logger.debug(
"log - No application logging configuration provided. Using default"
)
config_dict = DEFAULT_LOG_SETTINGS
self.transform_config(config_dict)
try:
# TODO(MG) Pretty print
_bootstrap_logger.debug('log - Log configuration: %s', config_dict)
_bootstrap_logger.debug("log - Log configuration: %s", config_dict)
logging.config.dictConfig(config_dict)
_bootstrap_logger.debug('log - Configured all logging')
_bootstrap_logger.debug("log - Configured all logging")
except Exception as ex:
print('unable to configure logging:', ex, type(ex))
print("unable to configure logging:", ex, type(ex))
def transform_config(self, config_dict):
"""
@@ -91,45 +90,52 @@ class LoggingLayer:
parameters and the final config dictionary passed into the logging module.
"""
# Version should be hard-coded 1, per Python docs
if 'version' in config_dict:
if config_dict['version'] != 1:
if "version" in config_dict:
if config_dict["version"] != 1:
_bootstrap_logger.warn("logging['version'] must be '1' per Python docs")
config_dict['version'] = 1
config_dict["version"] = 1
self._add_own_logconfig(config_dict)
# Replace logger level strings with value integers from module
for handler in config_dict['handlers']:
d = config_dict['handlers'][handler]
self._convert_str_to_loglevel(d, 'level')
for handler in config_dict["handlers"]:
d = config_dict["handlers"][handler]
self._convert_str_to_loglevel(d, "level")
# Replace logger level strings with value integers from module
for logger in config_dict['loggers']:
d = config_dict['loggers'][logger]
self._convert_str_to_loglevel(d, 'level')
for logger in config_dict["loggers"]:
d = config_dict["loggers"][logger]
self._convert_str_to_loglevel(d, "level")
# Replace 'root' logger with '', logging module convention for root handler
# Note: '' is disallowed in ConfigObj (hence the reason for this replacement)
# Implementation note:
# app_skellington expects root logger configuration to be under 'root'
# instead of '' (python spec) because '' is not a valid name in ConfigObj.
try:
config_dict['loggers'][''] = config_dict['loggers']['root']
del config_dict['loggers']['root']
except Exception as ex:
_bootstrap.logger.warn('internal failure patching root logger')
if config_dict["loggers"].get("root") is not None:
config_dict["loggers"][""] = config_dict["loggers"]["root"]
del config_dict["loggers"]["root"]
except Exception:
_bootstrap_logger.warn(
"was not able to find and patch root logger configuration from arguments"
)
# Evaluate the full filepath of the file handler
if 'file' not in config_dict['handlers']:
if "file" not in config_dict["handlers"]:
return
if os.path.abspath(config_dict['handlers']['file']['filename']) ==\
config_dict['handlers']['file']['filename']:
if (
os.path.abspath(config_dict["handlers"]["file"]["filename"])
== config_dict["handlers"]["file"]["filename"]
):
# Path is already absolute
pass
else:
dirname = appdirs.user_log_dir(self.appname, self.appauthor)
_util.ensure_dir_exists(dirname)
log_filepath = os.path.join(dirname, config_dict['handlers']['file']['filename'])
config_dict['handlers']['file']['filename'] = log_filepath
log_filepath = os.path.join(
dirname, config_dict["handlers"]["file"]["filename"]
)
config_dict["handlers"]["file"]["filename"] = log_filepath
def _add_own_logconfig(self, config_dict):
# NOTE(MG) Monkey-patch logger
@@ -139,13 +145,14 @@ class LoggingLayer:
# variable the second time, when it's being reloaded as a
# logging configuration is read from config file.
# See _bootstrap.py
if os.environ.get('APPSKELLINGTON_DEBUG', None):
if 'app_skellington' not in config_dict['loggers']:
config_dict['loggers']['app_skellington'] = {
'level': 'debug', 'propagate': 'false'
if os.environ.get("APPSKELLINGTON_DEBUG", None):
if _logger_name not in config_dict["loggers"]:
config_dict["loggers"][_logger_name] = {
"level": "debug",
"propagate": "false",
}
else:
config_dict['loggers']['app_skellington']['level'] = 'debug'
config_dict["loggers"][_logger_name]["level"] = "debug"
def _convert_str_to_loglevel(self, dict_, key):
"""
@@ -161,18 +168,17 @@ class LoggingLayer:
"""
try:
s = dict_[key]
except KeyError as ex:
except KeyError:
raise
if s == 'critical':
if s == "critical":
dict_[key] = logging.CRITICAL
elif s == 'error':
elif s == "error":
dict_[key] = logging.ERROR
elif s == 'warning':
elif s == "warning":
dict_[key] = logging.WARNING
elif s == 'info':
elif s == "info":
dict_[key] = logging.INFO
elif s == 'debug':
elif s == "debug":
dict_[key] = logging.DEBUG
elif s == 'all':
elif s == "all":
dict_[key] = logging.NOTSET
File diff suppressed because it is too large Load Diff
@@ -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:<v>`. 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.<job>.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("<broken>")`.
- `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.
+74
View File
@@ -0,0 +1,74 @@
[build-system]
requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[project]
name = "app_skellington"
dynamic = ["version"]
license = {file = "LICENSE.txt"}
readme = "README.md"
description = "app_skellington CLI framework"
requires-python = ">=3.8"
dependencies = [
"appdirs",
"configobj",
"colorlog"
]
authors = [
{name = "Mathew Guest", email = "mat@zavage.net"}
]
keywords = ["cli", "logging", "application"]
classifiers = [
"Programming Language :: Python :: 3",
"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)"
]
[project.urls]
homepage = "https://zavage-software.com/portfolio/app_skellington"
repository = "https://git-repos.zavage.net/Zavage-Software/app_skellington"
documentation = "https://git-repos.zavage.net/Zavage-Software/app_skellington"
[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"]
[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"]
+85
View File
@@ -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)
+43
View File
@@ -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[*]}"
-3
View File
@@ -1,3 +0,0 @@
[metadata]
description-file = README.md
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env python
#
# Usage:
#
# First, enable the python environment you want to install to, or if installing
# system-wide then ensure you're logged in with sufficient permissions
# (admin or root to install to system directories)
#
# installation:
#
# $ ./setup.py install
#
# de-installation:
#
# $ pip uninstall app_skellington
from setuptools import setup
import os
__project__ = 'app_skellington'
__version__ = '0.1.1'
__description__ = 'A high-powered command line menu framework.'
long_description = __description__
readme_filepath = os.path.join(
os.path.abspath(os.path.dirname(__file__)),
'README.md'
)
with open(readme_filepath, encoding='utf-8') as fp:
long_description = fp.read()
setup(
name = __project__,
version = __version__,
description = 'A high-powered command line menu framework.',
long_description = long_description,
author = 'Mathew Guest',
author_email = 't3h.zavage@gmail.com',
url = 'https://git-mirror.zavage.net/Mirror/app_skellington',
license = 'MIT',
python_requires = '>=3',
classifiers = [
'Development Status :: 3 - Alpha',
'Environment :: Console',
'Framework :: Pytest',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: MIT License',
'Natural Language :: English',
'Operating System :: MacOS',
'Operating System :: Microsoft',
'Operating System :: Microsoft :: Windows',
'Operating System :: OS Independent',
'Operating System :: POSIX',
'Operating System :: POSIX :: Linux',
'Topic :: Software Development :: Libraries',
'Topic :: Utilities'
],
# Third-party dependencies; will be automatically installed
install_requires = (
'appdirs',
'configobj',
'colorlog',
),
# Local packages to be installed (our packages)
packages = (
'app_skellington',
),
)
+4 -5
View File
@@ -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 "<test keyword>" .
uv run pytest -k "<test keyword>"
Run test by directory:
pytest <dirname>
uv run pytest tests/<dirname>
-1
View File
@@ -2,4 +2,3 @@ root_option = root_option_val
[app]
sub_option = sub_option_val
-1
View File
@@ -2,4 +2,3 @@ root_option = string(max=255, default='def_string')
[app]
sub_option = string(max=255, default='def_sub')
-1
View File
@@ -2,4 +2,3 @@ root_option = root_option_val
[app]
sub_option = sub_option_val
-1
View File
@@ -3,4 +3,3 @@ int_option = integer(min=0, max=100)
[app]
sub_option = string(max=255, default='def_sub')
-1
View File
@@ -1,2 +1 @@
root_option = invalid(max=255, default='def_string')
+49 -54
View File
@@ -1,45 +1,49 @@
from app_skellington.cfg import Config
from app_skellington import _util
import pytest
from app_skellington import _util
from app_skellington.cfg import Config
@pytest.fixture
def sample_configspec_filepath():
return _util.get_asset(__name__, 'sample_config.spec')
return _util.get_asset(__name__, "sample_config.spec")
@pytest.fixture
def sample_configini_filepath():
return _util.get_asset(__name__, 'sample_config.ini')
return _util.get_asset(__name__, "sample_config.ini")
@pytest.fixture
def sample_full_configspec_filepath():
return _util.get_asset(__name__, 'sample_config_full.spec')
return _util.get_asset(__name__, "sample_config_full.spec")
@pytest.fixture
def sample_full_configini_filepath():
return _util.get_asset(__name__, 'sample_config_full.ini')
return _util.get_asset(__name__, "sample_config_full.ini")
@pytest.fixture
def sample_invalid_configspec_filepath():
return _util.get_asset(__name__, 'sample_config_invalid.spec')
return _util.get_asset(__name__, "sample_config_invalid.spec")
class TestConfig_e2e:
def test_allows_reading_ini_and_no_spec(
self, sample_configini_filepath
):
cfg = Config(
configini_filepath=sample_configini_filepath
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
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(
@@ -50,48 +54,39 @@ class TestConfig_e2e:
# configspec_filepath=sample_invalid_configspec_filepath
# )
def test_allows_options_beyond_spec(
self, sample_configspec_filepath
):
cfg = Config(
configspec_filepath=sample_configspec_filepath
)
cfg['foo'] = 'test my value'
assert cfg['foo'] == 'test my value'
def test_allows_options_beyond_spec(self, sample_configspec_filepath):
cfg = Config(configspec_filepath=sample_configspec_filepath)
cfg["foo"] = "test my value"
assert cfg["foo"] == "test my value"
cfg['app']['bar'] = 'another value'
assert cfg['app']['bar'] == 'another value'
cfg["app"]["bar"] = "another value"
assert cfg["app"]["bar"] == "another value"
# def test_can_read_config_file_mutiple_times(self):
# pass
def test_can_override_config_file_manually(
self, sample_configini_filepath
):
cfg = Config(
configini_filepath=sample_configini_filepath
)
cfg['root_option'] = 'newval'
assert cfg['root_option'] == 'newval'
def test_can_override_config_file_manually(self, sample_configini_filepath):
cfg = Config(configini_filepath=sample_configini_filepath)
cfg["root_option"] = "newval"
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'
cfg["app"]["sub_option"] = "another_new_val"
assert cfg["app"]["sub_option"] == "another_new_val", (
"expecting default for sub option"
)
def test_can_set_option_without_config(self):
cfg = Config()
cfg['foo'] = 'test my value'
assert cfg['foo'] == 'test my value'
cfg["foo"] = "test my value"
assert cfg["foo"] == "test my value"
cfg['app'] = {}
cfg['app']['bar'] = 'another value'
assert cfg['app']['bar'] == 'another value'
cfg["app"] = {}
cfg["app"]["bar"] = "another value"
assert cfg["app"]["bar"] == "another value"
def test_uses_spec_as_defaults(
self, sample_configspec_filepath
):
cfg = Config(
configspec_filepath=sample_configspec_filepath
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'
assert cfg["app"]["sub_option"] == "def_sub", "expecting default for sub option"
-4
View File
@@ -1,4 +0,0 @@
[pytest]
filterwarnings =
ignore::DeprecationWarning
+133
View File
@@ -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.<job>.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
+1 -2
View File
@@ -1,8 +1,7 @@
from app_skellington.cli import CommandTree
class TestCli_e2e:
def test_null_constructor_works(self):
x = CommandTree()
assert True == True
+35
View File
@@ -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")
Generated
+690
View File
@@ -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" },
]