diff --git a/.claude/skills/update-libtmux/SKILL.md b/.claude/skills/update-libtmux/SKILL.md new file mode 100644 index 0000000000..530ae6d9e1 --- /dev/null +++ b/.claude/skills/update-libtmux/SKILL.md @@ -0,0 +1,359 @@ +--- +name: update-libtmux +description: >- + Use when the user asks to "update libtmux", "bump libtmux", + "upgrade libtmux dependency", "check for new libtmux version", + or when investigating whether tmuxp needs a libtmux update. + Guides the full workflow: studying upstream changes, updating + the dependency, migrating code and tests, and producing + separate atomic commits with rich messages. +user-invocable: true +argument-hint: "[target-version] (optional, defaults to latest on PyPI)" +--- + +# Update libtmux Dependency + +Workflow for updating the libtmux dependency in tmuxp with separate, atomic commits. + +## Overview + +This skill produces up to four atomic commits on a dedicated branch, then opens a PR: + +1. **Package commit** — bump `pyproject.toml` + `uv.lock` +2. **Code commit(s)** — API migrations, new feature adoption (if needed) +3. **Test commit(s)** — test updates for changed/new APIs (if needed) +4. **CHANGES commit** — changelog entry documenting the bump + +Each commit stands alone, passes tests independently, and has a rich commit body. + +## Step 0: Preflight + +Gather current state before making any changes. + +### 0a. Current dependency + +Read `pyproject.toml` and find the `libtmux~=X.Y.Z` specifier in `[project] dependencies`. + +### 0b. Latest version on PyPI + +```bash +pip index versions libtmux +``` + +If the user provided a target version, use that. Otherwise use the latest from PyPI. + +### 0c. Short-circuit check + +If the current specifier already covers the target version, inform the user and stop. + +### 0d. Ensure local libtmux clone is current + +The local libtmux clone lives at `~/work/python/libtmux`. Fetch and check: + +```bash +cd ~/work/python/libtmux && git fetch --tags && git log --oneline -5 +``` + +Verify the target version tag exists. If not, the version may not be released yet — warn the user. + +## Step 1: Study upstream changes + +This is the most important step. Read the libtmux CHANGES file to understand what changed between the current pinned version and the target. + +### 1a. Read libtmux CHANGES + +Read `~/work/python/libtmux/CHANGES` from the section for the target version back through all versions since the current pin. + +Categorize changes into: + +| Category | Action needed in tmuxp | +|----------|----------------------| +| **Breaking changes** | Must fix code/tests | +| **Deprecations** | Should migrate away | +| **New APIs** | Optionally adopt | +| **Bug fixes** | Note for commit message | +| **Internal/docs** | Note for commit message only | + +### 1b. Check for API impact in tmuxp + +For each breaking change or deprecation, grep tmuxp source and tests: + +```bash +# Example: if Window.rename_window() changed signature +rg "rename_window" src/ tests/ +``` + +Search patterns to check (adapt based on actual changes): +- Method/function names that changed +- Constructor parameters that changed +- Import paths that moved +- Exception types that changed +- Return type changes + +### 1c. Check libtmux git log for details + +For breaking changes where the CHANGES entry is unclear, read the actual commits: + +```bash +cd ~/work/python/libtmux && git log --oneline v{CURRENT}..v{TARGET} -- src/ +``` + +### 1d. Summarize findings + +Present findings to the user before proceeding: +- Versions being skipped (e.g., "0.53.1, 0.54.0, 0.55.0") +- Breaking changes requiring code updates +- New APIs available for adoption +- Test impact assessment +- Estimated commit count + +Get user confirmation to proceed. + +## Step 2: Create branch + +```bash +git checkout -b deps/libtmux-{TARGET_VERSION} +``` + +Branch naming convention: `deps/libtmux-X.Y.Z` + +## Step 3: Package commit + +Update the dependency specifier and lock file. + +### 3a. Edit pyproject.toml + +Change the `libtmux~=X.Y.Z` line in `[project] dependencies`. + +### 3b. Update lock file + +```bash +uv lock +``` + +### 3c. Verify installation + +```bash +uv sync +``` + +### 3d. Run tests (smoke check) + +```bash +uv run py.test tests/ -x -q 2>&1 | tail -20 +``` + +Note any failures — these indicate code changes needed in Step 4. + +### 3e. Commit + +Commit message format (use heredoc for multiline): + +``` +deps(libtmux[~=X.Y.Z]): Bump from ~=A.B.C + +why: Pick up N libtmux release(s) (list versions) bringing +[brief summary of key changes]. + +what: +- Bump libtmux dependency specifier ~=A.B.C -> ~=X.Y.Z in pyproject.toml +- Update uv.lock + +libtmux X.Y.Z (date): +- [key change 1] +- [key change 2] + +[repeat for each intermediate version] + +Release: https://github.com/tmux-python/libtmux/releases/tag/vX.Y.Z +Changelog: https://libtmux.git-pull.com/history.html#libtmux-X-Y-Z-YYYY-MM-DD +``` + +Stage only `pyproject.toml` and `uv.lock`. + +## Step 4: Code commit(s) — if needed + +Skip this step if no breaking changes or API migrations are needed. + +### 4a. Fix breaking changes + +Address each breaking change identified in Step 1b. Make minimal, targeted fixes. + +### 4b. Adopt new APIs (optional) + +Only if the user requested it or it simplifies existing code significantly. + +### 4c. Run linting and type checking + +```bash +uv run ruff check . --fix --show-fixes +uv run ruff format . +uv run mypy +``` + +### 4d. Run tests + +```bash +uv run py.test tests/ -x -q +``` + +### 4e. Commit + +One commit per logical change. Format: + +``` +Scope(type[detail]): description of the migration + +why: libtmux X.Y.Z changed [what changed]. +what: +- [specific code change 1] +- [specific code change 2] +``` + +Use the project's standard scope conventions: +- `workspace/builder(fix[method])` for builder changes +- `cli/load(fix[feature])` for CLI changes +- `plugin(fix[hook])` for plugin changes + +## Step 5: Test commit(s) — if needed + +Skip if no test changes are required beyond what was fixed in Step 4. + +### 5a. Update tests for API changes + +Fix any tests that broke due to upstream changes. + +### 5b. Add tests for newly adopted APIs + +If Step 4 adopted new libtmux features, add tests. + +### 5c. Run full test suite + +```bash +uv run py.test +``` + +All tests must pass (doctests included — pytest is configured with `--doctest-modules`). + +### 5d. Commit + +``` +tests(scope[detail]): description + +why: Adapt tests for libtmux X.Y.Z [change]. +what: +- [specific test change 1] +- [specific test change 2] +``` + +## Step 6: CHANGES commit + +### 6a. Determine placement + +The CHANGES file has a placeholder section for the next unreleased version at the top. Add the entry below the placeholder comments. + +### 6b. Write the entry + +Add under `### Breaking Changes` if the bump changes minimum version, or `### Development` / `### Dependencies` for non-breaking bumps: + +For a breaking bump: + +```markdown +#### **libtmux** minimum bumped from `~=A.B.C` to `~=X.Y.Z` + + Picks up N releases: [version list with brief descriptions]. +``` + +For a non-breaking bump, use `### Dependencies`: + +```markdown +### Dependencies + +- Bump libtmux `~=A.B.C` -> `~=X.Y.Z` ([key changes summary]) +``` + +### 6c. Commit + +``` +docs(CHANGES): libtmux ~=A.B.C -> ~=X.Y.Z + +why: Document the dependency bump for the upcoming release. +what: +- Add entry for libtmux bump under [section name] +- Summarize key upstream changes +``` + +## Step 7: Push and open PR + +### 7a. Push the branch + +```bash +git push -u origin deps/libtmux-{TARGET_VERSION} +``` + +### 7b. Open PR + +```bash +gh pr create \ + --title "deps(libtmux[~=X.Y.Z]): Bump from ~=A.B.C" \ + --body "$(cat <<'EOF' +## Summary + +- Bump libtmux from `~=A.B.C` to `~=X.Y.Z` +- [N] upstream releases included +- [Breaking changes summary, or "No breaking changes"] + +## Upstream changes + +### libtmux X.Y.Z (date) +- [changes] + +[repeat for intermediate versions] + +## Changes in this PR + +- **Package**: pyproject.toml + uv.lock +- **Code**: [summary or "No code changes needed"] +- **Tests**: [summary or "No test changes needed"] +- **CHANGES**: Documented bump + +## Test plan + +- [ ] `uv run py.test` passes +- [ ] `uv run mypy` passes +- [ ] `uv run ruff check .` passes +EOF +)" +``` + +### 7c. Report to user + +Provide the PR URL and a summary of all commits created. + +## Reference: Past libtmux bumps + +These exemplar commits show the established patterns: + +| Version bump | Deps commit | CHANGES commit | PR | +|---|---|---|---| +| 0.53.0 → 0.55.0 | `ff52d0d2` | `094800f4` | #1019 | +| 0.52.1 → 0.53.0 | `5ff6400f` | `240d85fe` | #1003 | +| 0.51.0 → 0.52.1 | `fabd678f` | (in same commit) | #1001 | +| 0.50.1 → 0.51.0 | (in merge) | (in merge) | #999 | + +The 0.53→0.55 bump (`ff52d0d2`) is the gold standard for commit message richness — per-version changelogs, upstream links, and clear why/what structure. + +## Checklist + +Use this as a progress tracker: + +- [ ] Preflight: identify current and target versions +- [ ] Study upstream CHANGES and identify impact +- [ ] Summarize findings and get user confirmation +- [ ] Create branch `deps/libtmux-X.Y.Z` +- [ ] Package commit: pyproject.toml + uv.lock +- [ ] Code commit(s): API migrations (if needed) +- [ ] Test commit(s): test updates (if needed) +- [ ] CHANGES commit: changelog entry +- [ ] Push and open PR +- [ ] Report PR URL to user diff --git a/.github/contributing.md b/.github/contributing.md index 5712dcf03a..c0eddac2c2 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -3,25 +3,17 @@ When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the maintainers of this repository before making a change. -Please note we have a code of conduct, please follow it in all your interactions with the project. +See [developing](../docs/developing.md) for environment setup and [AGENTS.md](../AGENTS.md) for +detailed coding standards. ## Pull Request Process -1. Ensure any install or build dependencies are removed before the end of the layer when doing a - build. -2. This project uses flake8 to conform with common Python standards. Make sure - to run your code through linter using latest version of flake8, before pull request. -3. Bad documnentation is a Bug. If your change demands documentation update, please do so. If you - find an issue with documentation, take the time to improve or fix it. -4. pytest is used for automated testing. Please make sure to update tests that are needed, and to run - `make test` before submitting your pull request. This should prevent issues with TravisCI and - make the review and merging process easier and faster. -5. Update the README.md with details of changes to the interface, this includes new environment - variables, exposed ports, useful file locations and container parameters. -6. Increase the version numbers in any examples files and the README.md to the new version that this - Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). -7. You may merge the Pull Request in once you have the sign-off of one other developer. If you - do not have permission to do that, you may request reviewer to merge it for you. +1. **Format and lint**: `uv run ruff format .` then `uv run ruff check . --fix --show-fixes` +2. **Type check**: `uv run mypy` +3. **Test**: `uv run pytest` — all tests must pass before submitting +4. **Document**: Update docs if your change affects the public interface +5. You may merge the Pull Request once you have the sign-off of one other developer. If you + do not have permission to do that, you may request a reviewer to merge it for you. ## Decorum diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dd5f19f491..d9dbf6716b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -62,6 +62,15 @@ jobs: python -V uv run python -V + - name: Cache sphinx fonts + if: env.PUBLISH == 'true' + uses: actions/cache@v5 + with: + path: ~/.cache/sphinx-fonts + key: sphinx-fonts-${{ hashFiles('docs/conf.py') }} + restore-keys: | + sphinx-fonts- + - name: Build documentation if: env.PUBLISH == 'true' run: | diff --git a/.gitignore b/.gitignore index f82c6fba00..9dadb843bd 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,10 @@ doc/_build/ # MonkeyType monkeytype.sqlite3 +# Generated by sphinx_fonts extension (downloaded at build time) +docs/_static/fonts/ +docs/_static/css/fonts.css + # Claude code **/CLAUDE.local.md **/CLAUDE.*.md diff --git a/.tool-versions b/.tool-versions index 0c2c7c1d5d..88d422f528 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -just 1.46.0 -uv 0.10.9 +just 1.49 +uv 0.11.3 python 3.14 3.13 3.12 3.11 3.10 diff --git a/AGENTS.md b/AGENTS.md index 3fe760a6e0..0f87f4ce20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,55 @@ windows: - **Type imports**: Use `import typing as t` and access via namespace (e.g., `t.Optional`) - **Development workflow**: Format → Test → Commit → Lint/Type Check → Test → Final Commit +## Git Commit Standards + +Format commit messages as: +``` +Scope(type[detail]): concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic +``` + +Common commit types: +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting +- **py(deps)**: Dependencies +- **py(deps[dev])**: Dev Dependencies +- **ai(rules[AGENTS])**: AI rule updates +- **ai(claude[rules])**: Claude Code rules (CLAUDE.md) +- **ai(claude[command])**: Claude Code command changes + +Example: +``` +Pane(feat[send_keys]): Add support for literal flag + +why: Enable sending literal characters without tmux interpretation +what: +- Add literal parameter to send_keys method +- Update send_keys to pass -l flag when literal=True +- Add tests for literal key sending +``` +For multi-line commits, use heredoc to preserve formatting: +```bash +git commit -m "$(cat <<'EOF' +feat(Component[method]) add feature description + +why: Explanation of the change. +what: +- First change +- Second change +EOF +)" +``` + ## Logging Standards These rules guide future logging changes; existing code may not yet conform. @@ -206,12 +255,24 @@ Assert on `caplog.records` attributes, not string matching on `caplog.text`: - Assert on schema: `record.tmux_exit_code == 0` not `"exit code 0" in caplog.text` - `caplog.record_tuples` cannot access extra fields — always use `caplog.records` +### Output channels + +Two output channels serve different audiences: + +1. **Diagnostics** (`logger.*()` with `extra`): System events for log files, `caplog`, and aggregators. Never styled. +2. **User-facing output**: What the human sees. Styled via `Colors` class. + - Commands with output modes (`--json`/`--ndjson`): prefer `OutputFormatter.emit_text()` from `tmuxp.cli._output` — silenced in non-human modes. + - Human-only commands: use `tmuxp_echo()` from `tmuxp.log` (re-exported via `tmuxp.cli.utils`) for user-facing messages. + - **Undefined contracts:** Machine-output behavior for error and empty-result paths (e.g., `search` with no matches) is not yet defined. These paths currently emit styled text through `formatter.emit_text()`, which is a no-op in machine modes. + +Raw `print()` is forbidden in command/business logic. The `print()` call lives only inside the presenter layer (`_output.py`) or `tmuxp_echo`. + ### Avoid - f-strings/`.format()` in log calls - Unguarded logging in hot loops (guard with `isEnabledFor()`) - Catch-log-reraise without adding new context -- `print()` for diagnostics +- `print()` for debugging or internal diagnostics — use `logger.debug()` with structured `extra` instead - Logging secret env var values (log key names only) - Non-scalar ad-hoc objects in `extra` - Requiring custom `extra` fields in format strings without safe defaults (missing keys raise `KeyError`) @@ -270,7 +331,7 @@ Assert on `caplog.records` attributes, not string matching on `caplog.text`: When writing documentation (README, CHANGES, docs/), follow these rules for code blocks: -**One command per code block.** This makes commands individually copyable. +**One command per code block.** This makes commands individually copyable. For sequential commands, either use separate code blocks or chain them with `&&` or `;` and `\` continuations (keeping it one logical command). **Put explanations outside the code block**, not as comments inside. @@ -298,6 +359,42 @@ $ uv run pytest $ uv run pytest --cov ``` +### Shell Command Formatting + +These rules apply to shell commands in documentation (README, CHANGES, docs/), **not** to Python doctests. + +**Use `console` language tag with `$ ` prefix.** This distinguishes interactive commands from scripts and enables prompt-aware copy in many terminals. + +Good: + +```console +$ uv run pytest +``` + +Bad: + +```bash +uv run pytest +``` + +**Split long commands with `\` for readability.** Each flag or flag+value pair gets its own continuation line, indented. Positional parameters go on the final line. + +Good: + +```console +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' +``` + +Bad: + +```console +$ pipx install --suffix=@next --pip-args '\--pre' --force 'tmuxp' +``` + ## Important Notes - **QA every edit**: Run formatting and tests before committing diff --git a/CHANGES b/CHANGES index ffa1bf2d06..0330651353 100644 --- a/CHANGES +++ b/CHANGES @@ -23,11 +23,15 @@ $ uvx --from 'tmuxp' --prerelease allow tmuxp [pipx](https://pypa.github.io/pipx/docs/): ```console -$ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' // Usage: tmuxp@next load yoursession ``` -## tmuxp 1.65.0 (Yet to be released) +## tmuxp 1.68.0 (Yet to be released) @@ -35,6 +39,56 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +## tmuxp 1.67.0 (2026-03-08) + +### What's new + +#### Animated progress spinner for `tmuxp load` (#1020) + +The `load` command now shows an animated spinner with real-time build progress +as windows and panes are created. Five built-in presets control the display +format (`default`, `minimal`, `window`, `pane`, `verbose`), and custom format +strings are supported via `--progress-format` or `TMUXP_PROGRESS_FORMAT`. + +- `--progress-lines N` / `TMUXP_PROGRESS_LINES`: Control how many lines of + `before_script` output appear in the spinner panel (default: 3). Use `0` to + hide the panel, `-1` for unlimited (capped to terminal height). +- `--no-progress` / `TMUXP_PROGRESS=0`: Disable the spinner entirely. +- During `before_script` execution, the progress bar shows a marching animation + and ⏸ icon. + +## tmuxp 1.66.0 (2026-03-08) + +### Bug fixes + +- Fix default CLI log level from INFO to WARNING so normal usage is not noisy (#1017) +- Suppress raw Python tracebacks on workspace build failure; error details available via `--log-level debug` while the user sees only `[Error] ` (#1017) +- Fix `get_pane()` to match sibling methods: widen catch to `Exception`, preserve exception chain via `from e`, replace bare `print()` with structured debug log (#1017) +- Route `ls --json` and `debug-info --json` through `OutputFormatter` for consistent machine-readable output (#1017) + +### Development + +#### Structured logging with `extra` context across all modules (#1017) + +All modules now use `logging.getLogger(__name__)` with structured `extra` keys +(`tmux_session`, `tmux_window`, `tmux_pane`, `tmux_config_path`, etc.) for +filtering and aggregation. Library `__init__.py` adds `NullHandler` per Python +best practices. A new `TmuxpLoggerAdapter` provides persistent context for +objects with stable identity. + +- Remove `colorama` runtime and type-stub dependencies; replace with stdlib ANSI constants (#1017) +- Route all raw `print()` calls through `tmuxp_echo()` for consistent output channels (#1017) + +## tmuxp 1.65.0 (2026-03-08) + +### Breaking Changes + +#### **libtmux** minimum bumped from `~=0.53.0` to `~=0.55.0` (#1019) + + Picks up three releases: 0.53.1 (race condition fix in `new_session()`), + 0.54.0 (structured lifecycle logging, error propagation fixes), and + 0.55.0 (`Pane.set_title()`, `Server(tmux_bin=)`, pre-execution DEBUG logging). + ## tmuxp 1.64.2 (2026-03-08) ### Packaging @@ -403,8 +457,8 @@ _Maintenance only, no bug fixes or new features_ via ruff v0.8.4, all automated lint fixes, including unsafe and previews were applied for Python 3.9: - ```sh - ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . + ```console + $ ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . ``` ## tmuxp 1.49.0 (2024-11-26) @@ -510,14 +564,14 @@ _Maintenance only, no bug fixes or new features_ via ruff v0.3.4, all automated lint fixes, including unsafe and previews were applied: - ```sh - ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . + ```console + $ ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . ``` Branches were treated with: - ```sh - git rebase \ + ```console + $ git rebase \ --strategy-option=theirs \ --exec 'poetry run ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; poetry run ruff format .; git add src tests; git commit --amend --no-edit' \ origin/master diff --git a/README.md b/README.md index 1a1de04be9..359ae17202 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,9 @@ Name a session: tmuxp load -s session_name ./mysession.yaml ``` -[simple](http://tmuxp.git-pull.com/examples.html#short-hand-inline) and +[simple](https://tmuxp.git-pull.com/configuration/examples/#short-hand-inline-style) and [very -elaborate](http://tmuxp.git-pull.com/examples.html#super-advanced-dev-environment) +elaborate](https://tmuxp.git-pull.com/configuration/examples/#super-advanced-dev-environment) config examples # User-level configurations @@ -197,16 +197,14 @@ $ tmuxp shell -c 'print(window.name.upper())' MY_WINDOW ``` -Read more on [tmuxp shell](https://tmuxp.git-pull.com/cli/shell.html) in +Read more on [tmuxp shell](https://tmuxp.git-pull.com/cli/shell/) in the CLI docs. # Pre-load hook -Run custom startup scripts (such as installing project dependencies +Run custom startup scripts (such as installing project dependencies) before loading tmux. See the -[bootstrap_env.py](https://github.com/tmux-python/tmuxp/blob/master/bootstrap_env.py) -and -[before_script](http://tmuxp.git-pull.com/examples.html#bootstrap-project-before-launch) +[before_script](https://tmuxp.git-pull.com/configuration/examples/#bootstrap-project-before-launch) example # Load in detached state @@ -226,7 +224,7 @@ $ tmuxp freeze session-name ``` See more about [freezing -tmux](https://tmuxp.git-pull.com/cli/freeze.html) sessions. +tmux](https://tmuxp.git-pull.com/cli/freeze/) sessions. # Convert a session file @@ -249,7 +247,7 @@ $ tmuxp convert --yes filename # Plugin System tmuxp has a plugin system to allow for custom behavior. See more about -the [Plugin System](http://tmuxp.git-pull.com/plugin_system.html). +the [Plugin System](https://tmuxp.git-pull.com/topics/plugins/). # Debugging Helpers @@ -274,13 +272,13 @@ environment: # Docs / Reading material -See the [Quickstart](http://tmuxp.git-pull.com/quickstart.html). +See the [Quickstart](https://tmuxp.git-pull.com/quickstart/). -[Documentation](http://tmuxp.git-pull.com) homepage (also in +[Documentation](https://tmuxp.git-pull.com) homepage (also in [中文](http://tmuxp-zh.rtfd.org/)) Want to learn more about tmux itself? [Read The Tao of Tmux -online](http://tmuxp.git-pull.com/about_tmux.html). +online](https://tmuxp.git-pull.com/about_tmux/). # Donations @@ -297,8 +295,8 @@ See donation options at . - python support: >= 3.10, pypy, pypy3 - Source: - Docs: -- API: -- Changelog: +- API: +- Changelog: - Issues: - Test Coverage: - pypi: diff --git a/docs/_ext/aafig.py b/docs/_ext/aafig.py index 418dbbc028..6f90d51378 100644 --- a/docs/_ext/aafig.py +++ b/docs/_ext/aafig.py @@ -176,7 +176,8 @@ def render_aafigure( fname = "{}.{}".format(get_basename(text, options), options["format"]) if app.builder.format == "html": # HTML - imgpath = relative_uri(app.builder.env.docname, "_images") + target_uri = app.builder.get_target_uri(app.builder.env.docname) + imgpath = relative_uri(target_uri, "_images") relfn = posixpath.join(imgpath, fname) outfn = path.join(app.builder.outdir, "_images", fname) else: diff --git a/docs/_ext/argparse_exemplar.py b/docs/_ext/argparse_exemplar.py deleted file mode 100644 index a4a7e1fc8b..0000000000 --- a/docs/_ext/argparse_exemplar.py +++ /dev/null @@ -1,1305 +0,0 @@ -"""Transform argparse epilog "examples:" definition lists into documentation sections. - -This Sphinx extension post-processes sphinx_argparse_neo output to convert -specially-formatted "examples:" definition lists in argparse epilogs into -proper documentation sections with syntax-highlighted code blocks. - -The extension is designed to be generic and reusable across different projects. -All behavior can be customized via Sphinx configuration options. - -Purpose -------- -When documenting CLI tools with argparse, it's useful to include examples in -the epilog. This extension recognizes a specific definition list format and -transforms it into structured documentation sections that appear in the TOC. - -Input Format ------------- -Format your argparse epilog with definition lists where terms end with "examples:": - -.. code-block:: python - - parser = argparse.ArgumentParser( - epilog=textwrap.dedent(''' - examples: - myapp sync - myapp sync myrepo - - Machine-readable output examples: - myapp sync --json - myapp sync -F json myrepo - '''), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - -The epilog text will be parsed as a definition list by docutils, with: -- Terms: "examples:", "Machine-readable output examples:", etc. -- Definitions: The example commands (one per line) - -Output ------- -The extension transforms these into proper sections: - -- A base "examples:" term creates an "Examples" section -- Category-prefixed terms like "Machine-readable output examples:" create - subsections nested under the parent Examples section -- Each command line becomes a syntax-highlighted console code block - -Configuration -------------- -Configure via conf.py. All options have sensible defaults. - -**Term Detection:** - -``argparse_examples_term_suffix`` : str (default: "examples") - Term must end with this string to be treated as an examples header. - -``argparse_examples_base_term`` : str (default: "examples") - Exact match for the base examples section (case-insensitive). - -``argparse_examples_section_title`` : str (default: "Examples") - Title used for the base examples section. - -**Usage Detection:** - -``argparse_usage_pattern`` : str (default: "usage:") - Text must start with this to be treated as a usage block (case-insensitive). - -**Code Block Formatting:** - -``argparse_examples_command_prefix`` : str (default: "$ ") - Prefix added to each command line in examples code blocks. - -``argparse_examples_code_language`` : str (default: "console") - Language identifier for examples code blocks. - -``argparse_examples_code_classes`` : list[str] (default: ["highlight-console"]) - CSS classes added to examples code blocks. - -``argparse_usage_code_language`` : str (default: "cli-usage") - Language identifier for usage blocks. - -**Behavior:** - -``argparse_reorder_usage_before_examples`` : bool (default: True) - Whether to reorder nodes so usage appears before examples. - -Additional Features -------------------- -- Removes ANSI escape codes (useful when FORCE_COLOR is set) -- Applies syntax highlighting to usage blocks -- Reorders sections so usage appears before examples in the output -- Extracts sections from argparse_program containers for TOC visibility - -Project-Specific Setup ----------------------- -Projects using this extension should register their own lexers and CSS in -their conf.py setup() function. For example:: - - def setup(app): - from my_lexer import MyLexer - app.add_lexer("my-output", MyLexer) - app.add_css_file("css/my-highlight.css") -""" - -from __future__ import annotations - -import dataclasses -import typing as t - -from docutils import nodes -from sphinx_argparse_neo.directive import ArgparseDirective -from sphinx_argparse_neo.utils import strip_ansi - -if t.TYPE_CHECKING: - import sphinx.config - from sphinx.application import Sphinx - - -@dataclasses.dataclass -class ExemplarConfig: - """Configuration for argparse_exemplar transformation. - - This dataclass provides all configurable options for the argparse_exemplar - extension. Functions accept an optional config parameter with a factory - default, allowing them to work standalone with defaults or accept custom - config for full control. - - Attributes - ---------- - examples_term_suffix : str - Term must end with this string (case-insensitive) to be treated as an - examples header. Default: "examples". - examples_base_term : str - Exact match (case-insensitive, after stripping ":") for the base - examples section. Default: "examples". - examples_section_title : str - Title used for the base examples section. Default: "Examples". - usage_pattern : str - Text must start with this string (case-insensitive, after stripping - whitespace) to be treated as a usage block. Default: "usage:". - command_prefix : str - Prefix added to each command line in examples code blocks. - Default: "$ ". - code_language : str - Language identifier for examples code blocks. Default: "console". - code_classes : tuple[str, ...] - CSS classes added to examples code blocks. - Default: ("highlight-console",). - usage_code_language : str - Language identifier for usage blocks. Default: "cli-usage". - reorder_usage_before_examples : bool - Whether to reorder nodes so usage appears before examples. - Default: True. - - Examples - -------- - Using default configuration: - - >>> config = ExemplarConfig() - >>> config.examples_term_suffix - 'examples' - >>> config.command_prefix - '$ ' - - Custom configuration: - - >>> config = ExemplarConfig( - ... command_prefix="> ", - ... code_language="bash", - ... ) - >>> config.command_prefix - '> ' - >>> config.code_language - 'bash' - """ - - # Term detection - examples_term_suffix: str = "examples" - examples_base_term: str = "examples" - examples_section_title: str = "Examples" - - # Usage detection - usage_pattern: str = "usage:" - - # Code block formatting - command_prefix: str = "$ " - code_language: str = "console" - code_classes: tuple[str, ...] = ("highlight-console",) - usage_code_language: str = "cli-usage" - - # Behavior - reorder_usage_before_examples: bool = True - - @classmethod - def from_sphinx_config(cls, config: sphinx.config.Config) -> ExemplarConfig: - """Create ExemplarConfig from Sphinx configuration. - - Parameters - ---------- - config : sphinx.config.Config - The Sphinx configuration object. - - Returns - ------- - ExemplarConfig - Configuration populated from Sphinx config values. - - Examples - -------- - This is typically called from a directive's run() method: - - >>> # In CleanArgParseDirective.run(): - >>> # config = ExemplarConfig.from_sphinx_config(self.env.config) - """ - # Get code_classes as tuple (Sphinx stores lists) - code_classes_raw = getattr( - config, "argparse_examples_code_classes", ("highlight-console",) - ) - code_classes = ( - tuple(code_classes_raw) - if isinstance(code_classes_raw, list) - else code_classes_raw - ) - - return cls( - examples_term_suffix=getattr( - config, "argparse_examples_term_suffix", "examples" - ), - examples_base_term=getattr( - config, "argparse_examples_base_term", "examples" - ), - examples_section_title=getattr( - config, "argparse_examples_section_title", "Examples" - ), - usage_pattern=getattr(config, "argparse_usage_pattern", "usage:"), - command_prefix=getattr(config, "argparse_examples_command_prefix", "$ "), - code_language=getattr(config, "argparse_examples_code_language", "console"), - code_classes=code_classes, - usage_code_language=getattr( - config, "argparse_usage_code_language", "cli-usage" - ), - reorder_usage_before_examples=getattr( - config, "argparse_reorder_usage_before_examples", True - ), - ) - - -# Re-export for backwards compatibility and public API -__all__ = [ - "CleanArgParseDirective", - "ExemplarConfig", - "is_base_examples_term", - "is_examples_term", - "make_section_id", - "make_section_title", - "process_node", - "strip_ansi", - "transform_definition_list", -] - - -def is_examples_term(term_text: str, *, config: ExemplarConfig | None = None) -> bool: - """Check if a definition term is an examples header. - - Parameters - ---------- - term_text : str - The text content of a definition term. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - bool - True if this is an examples header. - - Examples - -------- - >>> is_examples_term("examples:") - True - >>> is_examples_term("Machine-readable output examples:") - True - >>> is_examples_term("Usage:") - False - - With custom configuration: - - >>> custom_config = ExemplarConfig(examples_term_suffix="demos") - >>> is_examples_term("demos:", config=custom_config) - True - >>> is_examples_term("examples:", config=custom_config) - False - """ - config = config or ExemplarConfig() - return term_text.lower().rstrip(":").endswith(config.examples_term_suffix) - - -def is_base_examples_term( - term_text: str, *, config: ExemplarConfig | None = None -) -> bool: - """Check if a definition term is a base "examples:" header (no prefix). - - Parameters - ---------- - term_text : str - The text content of a definition term. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - bool - True if this is just "examples:" with no category prefix. - - Examples - -------- - >>> is_base_examples_term("examples:") - True - >>> is_base_examples_term("Examples") - True - >>> is_base_examples_term("Field-scoped examples:") - False - - With custom configuration: - - >>> custom_config = ExemplarConfig(examples_base_term="demos") - >>> is_base_examples_term("demos:", config=custom_config) - True - >>> is_base_examples_term("examples:", config=custom_config) - False - """ - config = config or ExemplarConfig() - return term_text.lower().rstrip(":").strip() == config.examples_base_term - - -def make_section_id( - term_text: str, - counter: int = 0, - *, - is_subsection: bool = False, - page_prefix: str = "", - config: ExemplarConfig | None = None, -) -> str: - """Generate a section ID from an examples term. - - Parameters - ---------- - term_text : str - The examples term text (e.g., "Machine-readable output: examples:") - counter : int - Counter for uniqueness if multiple examples sections exist. - is_subsection : bool - If True, omit "-examples" suffix for cleaner nested IDs. - page_prefix : str - Optional prefix from the page name (e.g., "sync", "add") to ensure - uniqueness across different documentation pages. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - str - A normalized section ID. - - Examples - -------- - >>> make_section_id("examples:") - 'examples' - >>> make_section_id("examples:", page_prefix="sync") - 'sync-examples' - >>> make_section_id("Machine-readable output examples:") - 'machine-readable-output-examples' - >>> make_section_id("Field-scoped examples:", is_subsection=True) - 'field-scoped' - >>> make_section_id("examples:", counter=1) - 'examples-1' - - With custom configuration: - - >>> custom_config = ExemplarConfig(examples_term_suffix="demos") - >>> make_section_id("demos:", config=custom_config) - 'demos' - >>> make_section_id("Machine-readable output demos:", config=custom_config) - 'machine-readable-output-demos' - """ - config = config or ExemplarConfig() - term_suffix = config.examples_term_suffix - - # Extract prefix before the term suffix (e.g., "Machine-readable output") - lower_text = term_text.lower().rstrip(":") - if term_suffix in lower_text: - prefix = lower_text.rsplit(term_suffix, 1)[0].strip() - # Remove trailing colon from prefix (handles ": examples" pattern) - prefix = prefix.rstrip(":").strip() - if prefix: - normalized_prefix = prefix.replace(" ", "-") - # Subsections don't need "-examples" suffix - if is_subsection: - section_id = normalized_prefix - else: - section_id = f"{normalized_prefix}-{term_suffix}" - else: - # Plain "examples" - add page prefix if provided for uniqueness - section_id = f"{page_prefix}-{term_suffix}" if page_prefix else term_suffix - else: - section_id = term_suffix - - # Add counter suffix for uniqueness - if counter > 0: - section_id = f"{section_id}-{counter}" - - return section_id - - -def make_section_title( - term_text: str, - *, - is_subsection: bool = False, - config: ExemplarConfig | None = None, -) -> str: - """Generate a section title from an examples term. - - Parameters - ---------- - term_text : str - The examples term text (e.g., "Machine-readable output: examples:") - is_subsection : bool - If True, omit "Examples" suffix for cleaner nested titles. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - str - A proper title (e.g., "Machine-readable Output Examples" or just - "Machine-Readable Output" if is_subsection=True). - - Examples - -------- - >>> make_section_title("examples:") - 'Examples' - >>> make_section_title("Machine-readable output examples:") - 'Machine-Readable Output Examples' - >>> make_section_title("Field-scoped examples:", is_subsection=True) - 'Field-Scoped' - - With custom configuration: - - >>> custom_config = ExemplarConfig( - ... examples_base_term="demos", - ... examples_term_suffix="demos", - ... examples_section_title="Demos", - ... ) - >>> make_section_title("demos:", config=custom_config) - 'Demos' - >>> make_section_title("Machine-readable output demos:", config=custom_config) - 'Machine-Readable Output Demos' - """ - config = config or ExemplarConfig() - base_term = config.examples_base_term - term_suffix = config.examples_term_suffix - section_title = config.examples_section_title - - # Remove trailing colon and normalize - text = term_text.rstrip(":").strip() - # Handle base term case (e.g., "examples:") - if text.lower() == base_term: - return section_title - - # Extract the prefix (category name) before the term suffix - lower = text.lower() - colon_suffix = f": {term_suffix}" - space_suffix = f" {term_suffix}" - if lower.endswith(colon_suffix): - prefix = text[: -len(colon_suffix)] - elif lower.endswith(space_suffix): - prefix = text[: -len(space_suffix)] - else: - prefix = text - - # Title case the prefix - titled_prefix = prefix.title() - - # For subsections, just use the prefix (cleaner nested titles) - if is_subsection: - return titled_prefix - - # For top-level sections, append the section title - return f"{titled_prefix} {section_title}" - - -def _create_example_section( - term_text: str, - def_node: nodes.definition, - *, - is_subsection: bool = False, - page_prefix: str = "", - config: ExemplarConfig | None = None, -) -> nodes.section: - """Create a section node for an examples item. - - Parameters - ---------- - term_text : str - The examples term text. - def_node : nodes.definition - The definition node containing example commands. - is_subsection : bool - If True, create a subsection with simpler title/id. - page_prefix : str - Optional prefix from the page name for unique section IDs. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - nodes.section - A section node with title and code blocks. - - Examples - -------- - Create a section from a definition node containing example commands: - - >>> from docutils import nodes - >>> def_node = nodes.definition() - >>> def_node += nodes.paragraph(text="myapp sync") - >>> section = _create_example_section("examples:", def_node) - >>> section["ids"] - ['examples'] - >>> section[0].astext() - 'Examples' - - With a page prefix for uniqueness across documentation pages: - - >>> section = _create_example_section("examples:", def_node, page_prefix="sync") - >>> section["ids"] - ['sync-examples'] - - Category-prefixed examples create descriptive section IDs: - - >>> section = _create_example_section("Machine-readable output examples:", def_node) - >>> section["ids"] - ['machine-readable-output-examples'] - >>> section[0].astext() - 'Machine-Readable Output Examples' - """ - config = config or ExemplarConfig() - section_id = make_section_id( - term_text, is_subsection=is_subsection, page_prefix=page_prefix, config=config - ) - section_title = make_section_title( - term_text, is_subsection=is_subsection, config=config - ) - - section = nodes.section() - section["ids"] = [section_id] - section["names"] = [nodes.fully_normalize_name(section_title)] - - title = nodes.title(text=section_title) - section += title - - # Extract commands from definition and create separate code blocks - def_text = strip_ansi(def_node.astext()) - for line in def_text.split("\n"): - line = line.strip() - if line: - code_block = nodes.literal_block( - text=f"{config.command_prefix}{line}", - classes=list(config.code_classes), - ) - code_block["language"] = config.code_language - section += code_block - - return section - - -def transform_definition_list( - dl_node: nodes.definition_list, - *, - page_prefix: str = "", - config: ExemplarConfig | None = None, -) -> list[nodes.Node]: - """Transform a definition list, converting examples items to code blocks. - - If there's a base "examples:" item followed by category-specific examples - (e.g., "Field-scoped: examples:"), the categories are nested under the - parent Examples section for cleaner ToC structure. - - Parameters - ---------- - dl_node : nodes.definition_list - A definition list node. - page_prefix : str - Optional prefix from the page name for unique section IDs. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - list[nodes.Node] - Transformed nodes - code blocks for examples, original for others. - - Note - ---- - **Intentional reordering behavior:** This function always emits non-example - items (preamble text, descriptions, etc.) before example sections, regardless - of their original position in the definition list. This "flush first" approach - groups conceptually related content: introductory material appears before - examples, even if the source document interleaves them. This produces cleaner - documentation structure where descriptions introduce their examples. - - If you need to preserve the original interleaved order, you would need to - modify this function to track item positions during the first pass. - """ - config = config or ExemplarConfig() - - # First pass: collect examples and non-examples items separately - example_items: list[tuple[str, nodes.definition]] = [] # (term_text, def_node) - non_example_items: list[nodes.Node] = [] - base_examples_index: int | None = None - - for item in dl_node.children: - if not isinstance(item, nodes.definition_list_item): - continue - - # Get the term and definition - term_node = None - def_node = None - for child in item.children: - if isinstance(child, nodes.term): - term_node = child - elif isinstance(child, nodes.definition): - def_node = child - - if term_node is None or def_node is None: - non_example_items.append(item) - continue - - term_text = strip_ansi(term_node.astext()) - - if is_examples_term(term_text, config=config): - if is_base_examples_term(term_text, config=config): - base_examples_index = len(example_items) - example_items.append((term_text, def_node)) - else: - non_example_items.append(item) - - # Build result nodes - result_nodes: list[nodes.Node] = [] - - # Emit non-example items first (see docstring Note on reordering behavior) - if non_example_items: - new_dl = nodes.definition_list() - new_dl.extend(non_example_items) - result_nodes.append(new_dl) - - # Determine nesting strategy - # Nest if: there's a base "examples:" AND at least one other example category - should_nest = base_examples_index is not None and len(example_items) > 1 - - if should_nest and base_examples_index is not None: - # Create parent "Examples" section - base_term, base_def = example_items[base_examples_index] - parent_section = _create_example_section( - base_term, - base_def, - is_subsection=False, - page_prefix=page_prefix, - config=config, - ) - - # Add other examples as nested subsections - for i, (term_text, def_node) in enumerate(example_items): - if i == base_examples_index: - continue # Skip the base (already used as parent) - subsection = _create_example_section( - term_text, - def_node, - is_subsection=True, - page_prefix=page_prefix, - config=config, - ) - parent_section += subsection - - result_nodes.append(parent_section) - else: - # No nesting - create flat sections (backwards compatible) - for term_text, def_node in example_items: - section = _create_example_section( - term_text, - def_node, - is_subsection=False, - page_prefix=page_prefix, - config=config, - ) - result_nodes.append(section) - - return result_nodes - - -def process_node( - node: nodes.Node, - *, - page_prefix: str = "", - config: ExemplarConfig | None = None, -) -> nodes.Node | list[nodes.Node]: - """Process a node: strip ANSI codes and transform examples. - - Parameters - ---------- - node : nodes.Node - A docutils node to process. - page_prefix : str - Optional prefix from the page name for unique section IDs. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - nodes.Node | list[nodes.Node] - The processed node(s). - """ - config = config or ExemplarConfig() - - # Handle text nodes - strip ANSI - if isinstance(node, nodes.Text): - cleaned = strip_ansi(node.astext()) - if cleaned != node.astext(): - return nodes.Text(cleaned) - return node - - # Handle definition lists - transform examples - if isinstance(node, nodes.definition_list): - # Check if any items are examples - has_examples = False - for item in node.children: - if isinstance(item, nodes.definition_list_item): - for child in item.children: - if isinstance(child, nodes.term) and is_examples_term( - strip_ansi(child.astext()), config=config - ): - has_examples = True - break - if has_examples: - break - - if has_examples: - return transform_definition_list( - node, page_prefix=page_prefix, config=config - ) - - # Handle literal_block nodes - strip ANSI and apply usage highlighting - if isinstance(node, nodes.literal_block): - text = strip_ansi(node.astext()) - needs_update = text != node.astext() - - # Check if this is a usage block (starts with configured pattern) - is_usage = text.lstrip().lower().startswith(config.usage_pattern.lower()) - - if needs_update or is_usage: - new_block = nodes.literal_block(text=text) - # Preserve attributes - for attr in ("language", "classes"): - if attr in node: - new_block[attr] = node[attr] - # Apply configured language to usage blocks - if is_usage: - new_block["language"] = config.usage_code_language - return new_block - return node - - # Handle paragraph nodes - strip ANSI and lift sections out - if isinstance(node, nodes.paragraph): - # Process children and check if any become sections - processed_children: list[nodes.Node] = [] - changed = False - has_sections = False - - for child in node.children: - if isinstance(child, nodes.Text): - cleaned = strip_ansi(child.astext()) - if cleaned != child.astext(): - processed_children.append(nodes.Text(cleaned)) - changed = True - else: - processed_children.append(child) - else: - result = process_node(child, page_prefix=page_prefix, config=config) - if isinstance(result, list): - processed_children.extend(result) - changed = True - # Check if any results are sections - if any(isinstance(r, nodes.section) for r in result): - has_sections = True - elif result is not child: - processed_children.append(result) - changed = True - if isinstance(result, nodes.section): - has_sections = True - else: - processed_children.append(child) - - if not changed: - return node - - # If no sections, return a normal paragraph - if not has_sections: - new_para = nodes.paragraph() - new_para.extend(processed_children) - return new_para - - # Sections found - lift them out of the paragraph - # Return a list: [para_before, section1, section2, ..., para_after] - result_nodes: list[nodes.Node] = [] - current_para_children: list[nodes.Node] = [] - - for child in processed_children: - if isinstance(child, nodes.section): - # Flush current paragraph content - if current_para_children: - para = nodes.paragraph() - para.extend(current_para_children) - result_nodes.append(para) - current_para_children = [] - # Add section as a sibling - result_nodes.append(child) - else: - current_para_children.append(child) - - # Flush remaining paragraph content - if current_para_children: - para = nodes.paragraph() - para.extend(current_para_children) - result_nodes.append(para) - - return result_nodes - - # Recursively process children for other node types - if hasattr(node, "children"): - new_children: list[nodes.Node] = [] - children_changed = False - for child in node.children: - result = process_node(child, page_prefix=page_prefix, config=config) - if isinstance(result, list): - new_children.extend(result) - children_changed = True - elif result is not child: - new_children.append(result) - children_changed = True - else: - new_children.append(child) - if children_changed: - node[:] = new_children # type: ignore[index] - - return node - - -def _is_usage_block(node: nodes.Node, *, config: ExemplarConfig | None = None) -> bool: - """Check if a node is a usage literal block. - - Parameters - ---------- - node : nodes.Node - A docutils node to check. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - bool - True if this is a usage block (literal_block starting with usage pattern). - - Examples - -------- - >>> from docutils import nodes - >>> _is_usage_block(nodes.literal_block(text="usage: cmd [-h]")) - True - >>> _is_usage_block(nodes.literal_block(text="Usage: myapp sync")) - True - >>> _is_usage_block(nodes.literal_block(text=" usage: cmd")) - True - >>> _is_usage_block(nodes.literal_block(text="some other text")) - False - >>> _is_usage_block(nodes.paragraph(text="usage: cmd")) - False - >>> _is_usage_block(nodes.section()) - False - - With custom configuration: - - >>> custom_config = ExemplarConfig(usage_pattern="synopsis:") - >>> _is_usage_block(nodes.literal_block(text="synopsis: cmd"), config=custom_config) - True - >>> _is_usage_block(nodes.literal_block(text="usage: cmd"), config=custom_config) - False - """ - config = config or ExemplarConfig() - if not isinstance(node, nodes.literal_block): - return False - text = node.astext() - return text.lstrip().lower().startswith(config.usage_pattern.lower()) - - -def _is_usage_section(node: nodes.Node) -> bool: - """Check if a node is a usage section. - - Parameters - ---------- - node : nodes.Node - A docutils node to check. - - Returns - ------- - bool - True if this is a section with "usage" in its ID. - - Examples - -------- - >>> from docutils import nodes - >>> section = nodes.section() - >>> section["ids"] = ["usage"] - >>> _is_usage_section(section) - True - >>> section2 = nodes.section() - >>> section2["ids"] = ["sync-usage"] - >>> _is_usage_section(section2) - True - >>> section3 = nodes.section() - >>> section3["ids"] = ["options"] - >>> _is_usage_section(section3) - False - >>> _is_usage_section(nodes.paragraph()) - False - """ - if not isinstance(node, nodes.section): - return False - ids: list[str] = node.get("ids", []) - return any(id_str == "usage" or id_str.endswith("-usage") for id_str in ids) - - -def _is_examples_section( - node: nodes.Node, *, config: ExemplarConfig | None = None -) -> bool: - """Check if a node is an examples section. - - Parameters - ---------- - node : nodes.Node - A docutils node to check. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - bool - True if this is an examples section (section with term suffix in its ID). - - Examples - -------- - >>> from docutils import nodes - >>> section = nodes.section() - >>> section["ids"] = ["examples"] - >>> _is_examples_section(section) - True - >>> section2 = nodes.section() - >>> section2["ids"] = ["machine-readable-output-examples"] - >>> _is_examples_section(section2) - True - >>> section3 = nodes.section() - >>> section3["ids"] = ["positional-arguments"] - >>> _is_examples_section(section3) - False - >>> _is_examples_section(nodes.paragraph()) - False - >>> _is_examples_section(nodes.literal_block(text="examples")) - False - - With custom configuration: - - >>> custom_config = ExemplarConfig(examples_term_suffix="demos") - >>> section = nodes.section() - >>> section["ids"] = ["demos"] - >>> _is_examples_section(section, config=custom_config) - True - >>> section2 = nodes.section() - >>> section2["ids"] = ["examples"] - >>> _is_examples_section(section2, config=custom_config) - False - """ - config = config or ExemplarConfig() - if not isinstance(node, nodes.section): - return False - ids: list[str] = node.get("ids", []) - return any(config.examples_term_suffix in id_str.lower() for id_str in ids) - - -def _reorder_nodes( - processed: list[nodes.Node], *, config: ExemplarConfig | None = None -) -> list[nodes.Node]: - """Reorder nodes so usage sections/blocks appear before examples sections. - - This ensures the CLI usage synopsis appears above examples in the - documentation, making it easier to understand command syntax before - seeing example invocations. - - The function handles both: - - Usage as literal_block (legacy format from older renderer) - - Usage as section#usage (new format with TOC support) - - Parameters - ---------- - processed : list[nodes.Node] - List of processed docutils nodes. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - list[nodes.Node] - Reordered nodes with usage before examples (if enabled). - - Examples - -------- - >>> from docutils import nodes - - Create test nodes: - - >>> desc = nodes.paragraph(text="Description") - >>> examples = nodes.section() - >>> examples["ids"] = ["examples"] - >>> usage = nodes.literal_block(text="usage: cmd [-h]") - >>> args = nodes.section() - >>> args["ids"] = ["arguments"] - - When usage appears after examples, it gets moved before: - - >>> result = _reorder_nodes([desc, examples, usage, args]) - >>> [type(n).__name__ for n in result] - ['paragraph', 'literal_block', 'section', 'section'] - - When no examples exist, order is unchanged: - - >>> result = _reorder_nodes([desc, usage, args]) - >>> [type(n).__name__ for n in result] - ['paragraph', 'literal_block', 'section'] - - When usage already before examples, order is preserved: - - >>> result = _reorder_nodes([desc, usage, examples, args]) - >>> [type(n).__name__ for n in result] - ['paragraph', 'literal_block', 'section', 'section'] - - Empty list returns empty: - - >>> _reorder_nodes([]) - [] - - Usage sections (with TOC heading) are also handled: - - >>> usage_section = nodes.section() - >>> usage_section["ids"] = ["usage"] - >>> result = _reorder_nodes([desc, examples, usage_section, args]) - >>> [n.get("ids", []) for n in result if isinstance(n, nodes.section)] - [['usage'], ['examples'], ['arguments']] - - Reordering can be disabled via config: - - >>> no_reorder_config = ExemplarConfig(reorder_usage_before_examples=False) - >>> result = _reorder_nodes([desc, examples, usage, args], config=no_reorder_config) - >>> [type(n).__name__ for n in result] - ['paragraph', 'section', 'literal_block', 'section'] - """ - config = config or ExemplarConfig() - - # If reordering is disabled, return as-is - if not config.reorder_usage_before_examples: - return processed - - # First pass: check if there are any examples sections - has_examples = any(_is_examples_section(node, config=config) for node in processed) - if not has_examples: - # No examples, preserve original order - return processed - - usage_nodes: list[nodes.Node] = [] - examples_sections: list[nodes.Node] = [] - other_before_examples: list[nodes.Node] = [] - other_after_examples: list[nodes.Node] = [] - - seen_examples = False - for node in processed: - # Check for both usage block (literal_block) and usage section - if _is_usage_block(node, config=config) or _is_usage_section(node): - usage_nodes.append(node) - elif _is_examples_section(node, config=config): - examples_sections.append(node) - seen_examples = True - elif not seen_examples: - other_before_examples.append(node) - else: - other_after_examples.append(node) - - # Order: before_examples → usage → examples → after_examples - return ( - other_before_examples + usage_nodes + examples_sections + other_after_examples - ) - - -def _extract_sections_from_container( - container: nodes.Node, -) -> tuple[nodes.Node, list[nodes.section]]: - """Extract section nodes from a container, returning modified container. - - This function finds any section nodes that are children of the container - (typically argparse_program), removes them from the container, and returns - them separately so they can be made siblings. - - This is needed because Sphinx's TocTreeCollector only discovers sections - that are direct children of the document or properly nested in the section - hierarchy - sections inside arbitrary div containers are invisible to TOC. - - Parameters - ---------- - container : nodes.Node - A container node (typically argparse_program) that may contain sections. - - Returns - ------- - tuple[nodes.Node, list[nodes.section]] - A tuple of (modified_container, extracted_sections). - - Examples - -------- - >>> from docutils import nodes - >>> from sphinx_argparse_neo.nodes import argparse_program - >>> container = argparse_program() - >>> para = nodes.paragraph(text="Description") - >>> examples = nodes.section() - >>> examples["ids"] = ["examples"] - >>> container += para - >>> container += examples - >>> modified, extracted = _extract_sections_from_container(container) - >>> len(modified.children) - 1 - >>> len(extracted) - 1 - >>> extracted[0]["ids"] - ['examples'] - """ - if not hasattr(container, "children"): - return container, [] - - extracted_sections: list[nodes.section] = [] - remaining_children: list[nodes.Node] = [] - - for child in container.children: - if isinstance(child, nodes.section): - extracted_sections.append(child) - else: - remaining_children.append(child) - - # Update container with remaining children only - container[:] = remaining_children # type: ignore[index] - - return container, extracted_sections - - -class CleanArgParseDirective(ArgparseDirective): # type: ignore[misc] - """ArgParse directive that strips ANSI codes and formats examples.""" - - def run(self) -> list[nodes.Node]: - """Run the directive, clean output, format examples, and reorder. - - The processing pipeline: - 1. Run base directive to get initial nodes - 2. Load configuration from Sphinx config - 3. Process each node (strip ANSI, transform examples definition lists) - 4. Extract sections from inside argparse_program containers - 5. Reorder so usage appears before examples (if enabled) - """ - result = super().run() - - # Load configuration from Sphinx - config = ExemplarConfig.from_sphinx_config(self.env.config) - - # Extract page name for unique section IDs across different CLI pages - page_prefix = "" - if hasattr(self.state, "document"): - settings = self.state.document.settings - if hasattr(settings, "env") and hasattr(settings.env, "docname"): - # docname is like "cli/sync" - extract "sync" - docname = settings.env.docname - page_prefix = docname.split("/")[-1] - - processed: list[nodes.Node] = [] - for node in result: - processed_node = process_node(node, page_prefix=page_prefix, config=config) - if isinstance(processed_node, list): - processed.extend(processed_node) - else: - processed.append(processed_node) - - # Extract sections from inside argparse_program containers - # This is needed because sections inside divs are invisible to Sphinx TOC - flattened: list[nodes.Node] = [] - for node in processed: - # Check if this is an argparse_program (or similar container) - # that might have sections inside - node_class_name = type(node).__name__ - if node_class_name == "argparse_program": - modified, extracted = _extract_sections_from_container(node) - flattened.append(modified) - flattened.extend(extracted) - else: - flattened.append(node) - - # Reorder: usage sections/blocks before examples sections - return _reorder_nodes(flattened, config=config) - - -def setup(app: Sphinx) -> dict[str, t.Any]: - """Register the clean argparse directive, lexers, and CLI roles. - - Configuration Options - --------------------- - The following configuration options can be set in conf.py: - - ``argparse_examples_term_suffix`` : str (default: "examples") - Term must end with this string to be treated as examples header. - - ``argparse_examples_base_term`` : str (default: "examples") - Exact match for the base examples section. - - ``argparse_examples_section_title`` : str (default: "Examples") - Title used for the base examples section. - - ``argparse_usage_pattern`` : str (default: "usage:") - Text must start with this to be treated as a usage block. - - ``argparse_examples_command_prefix`` : str (default: "$ ") - Prefix added to each command line in examples code blocks. - - ``argparse_examples_code_language`` : str (default: "console") - Language identifier for examples code blocks. - - ``argparse_examples_code_classes`` : list[str] (default: ["highlight-console"]) - CSS classes added to examples code blocks. - - ``argparse_usage_code_language`` : str (default: "cli-usage") - Language identifier for usage blocks. - - ``argparse_reorder_usage_before_examples`` : bool (default: True) - Whether to reorder nodes so usage appears before examples. - - Parameters - ---------- - app : Sphinx - The Sphinx application object. - - Returns - ------- - dict - Extension metadata. - """ - # Load the base sphinx_argparse_neo extension first - app.setup_extension("sphinx_argparse_neo") - - # Register configuration options - app.add_config_value("argparse_examples_term_suffix", "examples", "html") - app.add_config_value("argparse_examples_base_term", "examples", "html") - app.add_config_value("argparse_examples_section_title", "Examples", "html") - app.add_config_value("argparse_usage_pattern", "usage:", "html") - app.add_config_value("argparse_examples_command_prefix", "$ ", "html") - app.add_config_value("argparse_examples_code_language", "console", "html") - app.add_config_value( - "argparse_examples_code_classes", ["highlight-console"], "html" - ) - app.add_config_value("argparse_usage_code_language", "cli-usage", "html") - app.add_config_value("argparse_reorder_usage_before_examples", True, "html") - - # Override the argparse directive with our enhanced version - app.add_directive("argparse", CleanArgParseDirective, override=True) - - # Register CLI usage lexer for usage block highlighting - from cli_usage_lexer import CLIUsageLexer - - app.add_lexer("cli-usage", CLIUsageLexer) - - # Register argparse lexers for help output highlighting - from argparse_lexer import ( - ArgparseHelpLexer, - ArgparseLexer, - ArgparseUsageLexer, - ) - - app.add_lexer("argparse", ArgparseLexer) - app.add_lexer("argparse-usage", ArgparseUsageLexer) - app.add_lexer("argparse-help", ArgparseHelpLexer) - - # Register CLI inline roles for documentation - from argparse_roles import register_roles - - register_roles() - - return {"version": "4.0", "parallel_read_safe": True} diff --git a/docs/_ext/argparse_lexer.py b/docs/_ext/argparse_lexer.py deleted file mode 100644 index 14aed55649..0000000000 --- a/docs/_ext/argparse_lexer.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Pygments lexers for argparse help output. - -This module provides custom Pygments lexers for highlighting argparse-generated -command-line help text, including usage lines, section headers, and full help output. - -Three lexer classes are provided: -- ArgparseUsageLexer: For usage lines only -- ArgparseHelpLexer: For full -h output (delegates usage to ArgparseUsageLexer) -- ArgparseLexer: Smart auto-detecting wrapper -""" - -from __future__ import annotations - -from pygments.lexer import RegexLexer, bygroups, include -from pygments.token import Generic, Name, Operator, Punctuation, Text, Whitespace - - -class ArgparseUsageLexer(RegexLexer): - """Lexer for argparse usage lines only. - - Handles patterns like: - - usage: PROG [-h] [--foo FOO] bar {a,b,c} - - Mutually exclusive: [-a | -b], (--foo | --bar) - - Choices: {json,yaml,table} - - Variadic: FILE ..., [FILE ...], [--foo [FOO]] - - Examples - -------- - >>> from pygments.token import Token - >>> lexer = ArgparseUsageLexer() - >>> tokens = list(lexer.get_tokens("usage: cmd [-h]")) - >>> tokens[0] - (Token.Generic.Heading, 'usage:') - >>> tokens[2] - (Token.Name.Label, 'cmd') - """ - - name = "Argparse Usage" - aliases = ["argparse-usage"] # noqa: RUF012 - filenames: list[str] = [] # noqa: RUF012 - mimetypes = ["text/x-argparse-usage"] # noqa: RUF012 - - tokens = { # noqa: RUF012 - "root": [ - # "usage:" at start of line - then look for program name - ( - r"^(usage:)(\s+)", - bygroups(Generic.Heading, Whitespace), # type: ignore[no-untyped-call] - "after_usage", - ), - # Continuation lines (leading whitespace for wrapped usage) - (r"^(\s+)(?=\S)", Whitespace), - include("inline"), - ], - "after_usage": [ - # Whitespace - (r"\s+", Whitespace), - # Program name (first lowercase word after usage:) - (r"\b[a-z][-a-z0-9_]*\b", Name.Label, "usage_body"), - # Fallback to inline if something unexpected - include("inline"), - ], - "usage_body": [ - # Whitespace - (r"\s+", Whitespace), - # Ellipsis for variadic args (before other patterns) - (r"\.\.\.", Punctuation), - # Long options with = value (e.g., --log-level=VALUE) - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with space-separated value (e.g., -S socket-path) - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # Opening brace - enter choices state - (r"\{", Punctuation, "choices"), - # Opening bracket - enter optional state - (r"\[", Punctuation, "optional"), - # Closing bracket (fallback for unmatched) - (r"\]", Punctuation), - # Opening paren - enter required mutex state - (r"\(", Punctuation, "required"), - # Closing paren (fallback for unmatched) - (r"\)", Punctuation), - # Choice separator (pipe) for mutex groups - (r"\|", Operator), - # UPPERCASE meta-variables (COMMAND, FILE, PATH) - (r"\b[A-Z][A-Z0-9_]*\b", Name.Variable), - # Subcommand/positional names (Name.Function for distinct styling) - (r"\b[a-z][-a-z0-9_]*\b", Name.Function), - # Catch-all for any other text - (r"[^\s\[\]|(){},]+", Text), - ], - "inline": [ - # Whitespace - (r"\s+", Whitespace), - # Ellipsis for variadic args (before other patterns) - (r"\.\.\.", Punctuation), - # Long options with = value (e.g., --log-level=VALUE) - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with space-separated value (e.g., -S socket-path) - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # Opening brace - enter choices state - (r"\{", Punctuation, "choices"), - # Opening bracket - enter optional state - (r"\[", Punctuation, "optional"), - # Closing bracket (fallback for unmatched) - (r"\]", Punctuation), - # Opening paren - enter required mutex state - (r"\(", Punctuation, "required"), - # Closing paren (fallback for unmatched) - (r"\)", Punctuation), - # Choice separator (pipe) for mutex groups - (r"\|", Operator), - # UPPERCASE meta-variables (COMMAND, FILE, PATH) - (r"\b[A-Z][A-Z0-9_]*\b", Name.Variable), - # Positional/command names (lowercase with dashes) - (r"\b[a-z][-a-z0-9_]*\b", Name.Label), - # Catch-all for any other text - (r"[^\s\[\]|(){},]+", Text), - ], - "optional": [ - # Nested optional bracket - (r"\[", Punctuation, "#push"), - # End optional - (r"\]", Punctuation, "#pop"), - # Contents use usage_body rules (subcommands are green) - include("usage_body"), - ], - "required": [ - # Nested required paren - (r"\(", Punctuation, "#push"), - # End required - (r"\)", Punctuation, "#pop"), - # Contents use usage_body rules (subcommands are green) - include("usage_body"), - ], - "choices": [ - # Choice values (comma-separated inside braces) - (r"[a-zA-Z0-9][-a-zA-Z0-9_]*", Name.Constant), - # Comma separator - (r",", Punctuation), - # End choices - (r"\}", Punctuation, "#pop"), - # Whitespace - (r"\s+", Whitespace), - ], - } - - -class ArgparseHelpLexer(RegexLexer): - """Lexer for full argparse -h help output. - - Handles: - - Usage lines (delegates to ArgparseUsageLexer patterns) - - Section headers (positional arguments:, options:, etc.) - - Option entries with help text - - Indented descriptions - - Examples - -------- - >>> from pygments.token import Token - >>> lexer = ArgparseHelpLexer() - >>> tokens = list(lexer.get_tokens("positional arguments:")) - >>> any(t[0] == Token.Generic.Subheading for t in tokens) - True - >>> tokens = list(lexer.get_tokens(" -h, --help show help")) - >>> any(t[0] == Token.Name.Attribute for t in tokens) - True - """ - - name = "Argparse Help" - aliases = ["argparse-help"] # noqa: RUF012 - filenames: list[str] = [] # noqa: RUF012 - mimetypes = ["text/x-argparse-help"] # noqa: RUF012 - - tokens = { # noqa: RUF012 - "root": [ - # "usage:" line - switch to after_usage to find program name - ( - r"^(usage:)(\s+)", - bygroups(Generic.Heading, Whitespace), # type: ignore[no-untyped-call] - "after_usage", - ), - # Section headers (e.g., "positional arguments:", "options:") - (r"^([a-zA-Z][-a-zA-Z0-9_ ]*:)\s*$", Generic.Subheading), - # Option entry lines (indented with spaces/tabs, not just newlines) - (r"^([ \t]+)", Whitespace, "option_line"), - # Continuation of usage (leading spaces/tabs followed by content) - (r"^([ \t]+)(?=\S)", Whitespace), - # Anything else (must match at least one char to avoid infinite loop) - (r".+\n?", Text), - # Standalone newlines - (r"\n", Whitespace), - ], - "after_usage": [ - # Whitespace - (r"\s+", Whitespace), - # Program name (first lowercase word after usage:) - (r"\b[a-z][-a-z0-9_]*\b", Name.Label, "usage"), - # Fallback to usage if something unexpected - include("usage_inline"), - ], - "usage": [ - # End of usage on blank line or section header - (r"\n(?=[a-zA-Z][-a-zA-Z0-9_ ]*:\s*$)", Text, "#pop:2"), - (r"\n(?=\n)", Text, "#pop:2"), - # Usage content - use usage_inline rules (subcommands are green) - include("usage_inline"), - # Line continuation - (r"\n", Text), - ], - "usage_inline": [ - # Whitespace - (r"\s+", Whitespace), - # Ellipsis for variadic args - (r"\.\.\.", Punctuation), - # Long options with = value - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with value - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # Choices in braces - (r"\{", Punctuation, "choices"), - # Optional brackets - (r"\[", Punctuation, "optional"), - (r"\]", Punctuation), - # Required parens (mutex) - (r"\(", Punctuation, "required"), - (r"\)", Punctuation), - # Pipe for mutex - (r"\|", Operator), - # UPPERCASE metavars - (r"\b[A-Z][A-Z0-9_]*\b", Name.Variable), - # Subcommand/positional names (Name.Function for distinct styling) - (r"\b[a-z][-a-z0-9_]*\b", Name.Function), - # Other text - (r"[^\s\[\]|(){},\n]+", Text), - ], - "option_line": [ - # Short option with comma (e.g., "-h, --help") - ( - r"(-[a-zA-Z0-9])(,)(\s*)(--[a-zA-Z0-9][-a-zA-Z0-9]*)", - bygroups(Name.Attribute, Punctuation, Whitespace, Name.Tag), # type: ignore[no-untyped-call] - ), - # Long options with = value - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options with space-separated metavar - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(\s+)([A-Z][A-Z0-9_]+)", - bygroups(Name.Tag, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with metavar - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]+)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # Choices in braces - (r"\{", Punctuation, "option_choices"), - # Help text (everything after double space or large gap) - (r"([ \t]{2,})(.+)$", bygroups(Whitespace, Text)), # type: ignore[no-untyped-call] - # End of line - MUST come before \s+ to properly pop on newlines - (r"\n", Text, "#pop"), - # Other whitespace (spaces/tabs only, not newlines) - (r"[ \t]+", Whitespace), - # UPPERCASE metavars - (r"\b[A-Z][A-Z0-9_]*\b", Name.Variable), - # Anything else on the line - (r"[^\s\n]+", Text), - ], - "optional": [ - (r"\[", Punctuation, "#push"), - (r"\]", Punctuation, "#pop"), - include("usage_inline"), - ], - "required": [ - (r"\(", Punctuation, "#push"), - (r"\)", Punctuation, "#pop"), - include("usage_inline"), - ], - "choices": [ - (r"[a-zA-Z0-9][-a-zA-Z0-9_]*", Name.Constant), - (r",", Punctuation), - (r"\}", Punctuation, "#pop"), - (r"\s+", Whitespace), - ], - "option_choices": [ - (r"[a-zA-Z0-9][-a-zA-Z0-9_]*", Name.Constant), - (r",", Punctuation), - (r"\}", Punctuation, "#pop"), - (r"\s+", Whitespace), - ], - } - - -class ArgparseLexer(ArgparseHelpLexer): - """Smart auto-detecting lexer for argparse output. - - Inherits from ArgparseHelpLexer to properly handle Pygments' metaclass - token processing. Using inheritance (not token dict copying) avoids - shared mutable state that causes memory corruption. - - This is the recommended lexer for general argparse highlighting. - - Examples - -------- - >>> from pygments.token import Token - >>> lexer = ArgparseLexer() - - Usage line detection: - - >>> tokens = list(lexer.get_tokens("usage: cmd [-h]")) - >>> tokens[0] - (Token.Generic.Heading, 'usage:') - - Section header detection (Pygments appends newline to input): - - >>> tokens = list(lexer.get_tokens("positional arguments:")) - >>> any(t[0] == Token.Generic.Subheading for t in tokens) - True - - Option highlighting in option line context: - - >>> tokens = list(lexer.get_tokens(" -h, --help show help")) - >>> any(t[0] == Token.Name.Attribute for t in tokens) - True - """ - - name = "Argparse" - aliases = ["argparse"] # noqa: RUF012 - filenames: list[str] = [] # noqa: RUF012 - mimetypes = ["text/x-argparse"] # noqa: RUF012 - - # Tokens inherited from ArgparseHelpLexer - do NOT redefine or copy - - -def tokenize_argparse(text: str) -> list[tuple[str, str]]: - """Tokenize argparse text and return list of (token_type, value) tuples. - - Parameters - ---------- - text : str - Argparse help or usage text to tokenize. - - Returns - ------- - list[tuple[str, str]] - List of (token_type_name, text_value) tuples. - - Examples - -------- - >>> result = tokenize_argparse("usage: cmd [-h]") - >>> result[0] - ('Token.Generic.Heading', 'usage:') - >>> result[2] - ('Token.Name.Label', 'cmd') - - >>> result = tokenize_argparse("positional arguments:") - >>> any('Token.Generic.Subheading' in t[0] for t in result) - True - """ - lexer = ArgparseLexer() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] - - -def tokenize_usage(text: str) -> list[tuple[str, str]]: - """Tokenize usage text and return list of (token_type, value) tuples. - - Parameters - ---------- - text : str - CLI usage text to tokenize. - - Returns - ------- - list[tuple[str, str]] - List of (token_type_name, text_value) tuples. - - Examples - -------- - >>> result = tokenize_usage("usage: cmd [-h]") - >>> result[0] - ('Token.Generic.Heading', 'usage:') - >>> result[2] - ('Token.Name.Label', 'cmd') - >>> result[4] - ('Token.Punctuation', '[') - >>> result[5] - ('Token.Name.Attribute', '-h') - """ - lexer = ArgparseUsageLexer() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] diff --git a/docs/_ext/argparse_roles.py b/docs/_ext/argparse_roles.py deleted file mode 100644 index 86e5459a28..0000000000 --- a/docs/_ext/argparse_roles.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Docutils inline roles for CLI/argparse highlighting. - -This module provides custom docutils roles for inline highlighting of CLI -elements in reStructuredText and MyST documentation. - -Available roles: -- :cli-option: - CLI options (--verbose, -h) -- :cli-metavar: - Metavar placeholders (FILE, PATH) -- :cli-command: - Command names (sync, add) -- :cli-default: - Default values (None, "default") -- :cli-choice: - Choice values (json, yaml) -""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes -from docutils.parsers.rst import roles - -if t.TYPE_CHECKING: - from docutils.parsers.rst.states import Inliner - - -def normalize_options(options: dict[str, t.Any] | None) -> dict[str, t.Any]: - """Normalize role options, converting None to empty dict. - - Parameters - ---------- - options : dict | None - Options passed to the role. - - Returns - ------- - dict - Normalized options dict (never None). - - Examples - -------- - >>> normalize_options(None) - {} - >>> normalize_options({"class": "custom"}) - {'class': 'custom'} - """ - return options if options is not None else {} - - -def cli_option_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI options like --foo or -h. - - Generates a literal node with appropriate CSS classes for styling. - Long options (--foo) get 'cli-option-long', short options (-h) get - 'cli-option-short'. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role (has .reporter, .document). - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_option_role( - ... "cli-option", ":cli-option:`--verbose`", "--verbose", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-option', 'cli-option-long'] - - >>> node_list, messages = cli_option_role( - ... "cli-option", ":cli-option:`-h`", "-h", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-option', 'cli-option-short'] - - >>> node_list, messages = cli_option_role( - ... "cli-option", ":cli-option:`--no-color`", "--no-color", - ... 1, None - ... ) - >>> node_list[0].astext() - '--no-color' - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-option"]) - - if text.startswith("--"): - node["classes"].append("cli-option-long") - elif text.startswith("-"): - node["classes"].append("cli-option-short") - - return [node], [] - - -def cli_metavar_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI metavar placeholders like FILE or PATH. - - Generates a literal node with 'cli-metavar' CSS class for styling. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role. - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_metavar_role( - ... "cli-metavar", ":cli-metavar:`FILE`", "FILE", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-metavar'] - >>> node_list[0].astext() - 'FILE' - - >>> node_list, messages = cli_metavar_role( - ... "cli-metavar", ":cli-metavar:`PATH`", "PATH", - ... 1, None - ... ) - >>> "cli-metavar" in node_list[0]["classes"] - True - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-metavar"]) - return [node], [] - - -def cli_command_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI command names like sync or add. - - Generates a literal node with 'cli-command' CSS class for styling. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role. - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_command_role( - ... "cli-command", ":cli-command:`sync`", "sync", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-command'] - >>> node_list[0].astext() - 'sync' - - >>> node_list, messages = cli_command_role( - ... "cli-command", ":cli-command:`myapp`", "myapp", - ... 1, None - ... ) - >>> "cli-command" in node_list[0]["classes"] - True - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-command"]) - return [node], [] - - -def cli_default_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI default values like None or "default". - - Generates a literal node with 'cli-default' CSS class for styling. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role. - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_default_role( - ... "cli-default", ":cli-default:`None`", "None", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-default'] - >>> node_list[0].astext() - 'None' - - >>> node_list, messages = cli_default_role( - ... "cli-default", ':cli-default:`"auto"`', '"auto"', - ... 1, None - ... ) - >>> "cli-default" in node_list[0]["classes"] - True - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-default"]) - return [node], [] - - -def cli_choice_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI choice values like json or yaml. - - Generates a literal node with 'cli-choice' CSS class for styling. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role. - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_choice_role( - ... "cli-choice", ":cli-choice:`json`", "json", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-choice'] - >>> node_list[0].astext() - 'json' - - >>> node_list, messages = cli_choice_role( - ... "cli-choice", ":cli-choice:`yaml`", "yaml", - ... 1, None - ... ) - >>> "cli-choice" in node_list[0]["classes"] - True - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-choice"]) - return [node], [] - - -def register_roles() -> None: - """Register all CLI roles with docutils. - - This function registers the following roles: - - cli-option: For CLI options (--verbose, -h) - - cli-metavar: For metavar placeholders (FILE, PATH) - - cli-command: For command names (sync, add) - - cli-default: For default values (None, "default") - - cli-choice: For choice values (json, yaml) - - Examples - -------- - >>> register_roles() - >>> # Roles are now available in docutils RST parsing - """ - roles.register_local_role("cli-option", cli_option_role) # type: ignore[arg-type] - roles.register_local_role("cli-metavar", cli_metavar_role) # type: ignore[arg-type] - roles.register_local_role("cli-command", cli_command_role) # type: ignore[arg-type] - roles.register_local_role("cli-default", cli_default_role) # type: ignore[arg-type] - roles.register_local_role("cli-choice", cli_choice_role) # type: ignore[arg-type] diff --git a/docs/_ext/cli_usage_lexer.py b/docs/_ext/cli_usage_lexer.py deleted file mode 100644 index 40170e3178..0000000000 --- a/docs/_ext/cli_usage_lexer.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Pygments lexer for CLI usage/help output. - -This module provides a custom Pygments lexer for highlighting command-line -usage text typically generated by argparse, getopt, or similar libraries. -""" - -from __future__ import annotations - -from pygments.lexer import RegexLexer, bygroups, include -from pygments.token import Generic, Name, Operator, Punctuation, Text, Whitespace - - -class CLIUsageLexer(RegexLexer): - """Lexer for CLI usage/help text (argparse, etc.). - - Highlights usage patterns including options, arguments, and meta-variables. - - Examples - -------- - >>> from pygments.token import Token - >>> lexer = CLIUsageLexer() - >>> tokens = list(lexer.get_tokens("usage: cmd [-h]")) - >>> tokens[0] - (Token.Generic.Heading, 'usage:') - >>> tokens[2] - (Token.Name.Label, 'cmd') - """ - - name = "CLI Usage" - aliases = ["cli-usage", "usage"] # noqa: RUF012 - filenames: list[str] = [] # noqa: RUF012 - mimetypes = ["text/x-cli-usage"] # noqa: RUF012 - - tokens = { # noqa: RUF012 - "root": [ - # "usage:" at start of line - (r"^(usage:)(\s+)", bygroups(Generic.Heading, Whitespace)), # type: ignore[no-untyped-call] - # Continuation lines (leading whitespace for wrapped usage) - (r"^(\s+)(?=\S)", Whitespace), - include("inline"), - ], - "inline": [ - # Whitespace - (r"\s+", Whitespace), - # Long options with = value (e.g., --log-level=VALUE) - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with space-separated value (e.g., -S socket-path) - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # UPPERCASE meta-variables (COMMAND, FILE, PATH) - (r"\b[A-Z][A-Z0-9_]+\b", Name.Constant), - # Opening bracket - enter optional state - (r"\[", Punctuation, "optional"), - # Closing bracket (fallback for unmatched) - (r"\]", Punctuation), - # Choice separator (pipe) - (r"\|", Operator), - # Parentheses for grouping - (r"[()]", Punctuation), - # Positional/command names (lowercase with dashes) - (r"\b[a-z][-a-z0-9]*\b", Name.Label), - # Catch-all for any other text - (r"[^\s\[\]|()]+", Text), - ], - "optional": [ - # Nested optional bracket - (r"\[", Punctuation, "#push"), - # End optional - (r"\]", Punctuation, "#pop"), - # Contents use inline rules - include("inline"), - ], - } - - -def tokenize_usage(text: str) -> list[tuple[str, str]]: - """Tokenize usage text and return list of (token_type, value) tuples. - - Parameters - ---------- - text : str - CLI usage text to tokenize. - - Returns - ------- - list[tuple[str, str]] - List of (token_type_name, text_value) tuples. - - Examples - -------- - >>> result = tokenize_usage("usage: cmd [-h]") - >>> result[0] - ('Token.Generic.Heading', 'usage:') - >>> result[2] - ('Token.Name.Label', 'cmd') - >>> result[4] - ('Token.Punctuation', '[') - >>> result[5] - ('Token.Name.Attribute', '-h') - >>> result[6] - ('Token.Punctuation', ']') - """ - lexer = CLIUsageLexer() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] diff --git a/docs/_ext/sphinx_argparse_neo/__init__.py b/docs/_ext/sphinx_argparse_neo/__init__.py deleted file mode 100644 index 5fa8dd94fe..0000000000 --- a/docs/_ext/sphinx_argparse_neo/__init__.py +++ /dev/null @@ -1,101 +0,0 @@ -"""sphinx_argparse_neo - Modern sphinx-argparse replacement. - -A Sphinx extension for documenting argparse-based CLI tools that: -- Works with Sphinx 8.x AND 9.x (no autodoc.mock dependency) -- Fixes long-standing sphinx-argparse issues (TOC pollution, heading levels) -- Provides configurable output (rubrics vs sections, flattened subcommands) -- Supports extensibility via renderer classes -- Text processing utilities (ANSI stripping) -""" - -from __future__ import annotations - -import typing as t - -from sphinx_argparse_neo.directive import ArgparseDirective -from sphinx_argparse_neo.nodes import ( - argparse_argument, - argparse_group, - argparse_program, - argparse_subcommand, - argparse_subcommands, - argparse_usage, - depart_argparse_argument_html, - depart_argparse_group_html, - depart_argparse_program_html, - depart_argparse_subcommand_html, - depart_argparse_subcommands_html, - depart_argparse_usage_html, - visit_argparse_argument_html, - visit_argparse_group_html, - visit_argparse_program_html, - visit_argparse_subcommand_html, - visit_argparse_subcommands_html, - visit_argparse_usage_html, -) -from sphinx_argparse_neo.utils import strip_ansi - -__all__ = [ - "ArgparseDirective", - "strip_ansi", -] - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - -__version__ = "1.0.0" - - -def setup(app: Sphinx) -> dict[str, t.Any]: - """Register the argparse directive and configuration options. - - Parameters - ---------- - app : Sphinx - The Sphinx application object. - - Returns - ------- - dict[str, t.Any] - Extension metadata. - """ - # Configuration options - app.add_config_value("argparse_group_title_prefix", "", "html") - app.add_config_value("argparse_show_defaults", True, "html") - app.add_config_value("argparse_show_choices", True, "html") - app.add_config_value("argparse_show_types", True, "html") - - # Register custom nodes - app.add_node( - argparse_program, - html=(visit_argparse_program_html, depart_argparse_program_html), - ) - app.add_node( - argparse_usage, - html=(visit_argparse_usage_html, depart_argparse_usage_html), - ) - app.add_node( - argparse_group, - html=(visit_argparse_group_html, depart_argparse_group_html), - ) - app.add_node( - argparse_argument, - html=(visit_argparse_argument_html, depart_argparse_argument_html), - ) - app.add_node( - argparse_subcommands, - html=(visit_argparse_subcommands_html, depart_argparse_subcommands_html), - ) - app.add_node( - argparse_subcommand, - html=(visit_argparse_subcommand_html, depart_argparse_subcommand_html), - ) - - # Register directive - app.add_directive("argparse", ArgparseDirective) - - return { - "version": __version__, - "parallel_read_safe": True, - "parallel_write_safe": True, - } diff --git a/docs/_ext/sphinx_argparse_neo/compat.py b/docs/_ext/sphinx_argparse_neo/compat.py deleted file mode 100644 index 15816d574c..0000000000 --- a/docs/_ext/sphinx_argparse_neo/compat.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Compatibility utilities for module loading. - -This module provides utilities for loading Python modules safely, -including mock handling for imports that may fail during documentation -builds. - -Unlike sphinx-argparse, this module does NOT depend on autodoc's mock -functionality, which moved in Sphinx 9.x. -""" - -from __future__ import annotations - -import contextlib -import importlib -import sys -import typing as t - -if t.TYPE_CHECKING: - import argparse - from collections.abc import Iterator - - -class MockModule: - """Simple mock for unavailable imports. - - This class provides a minimal mock that can be used as a placeholder - for modules that aren't available during documentation builds. - - Parameters - ---------- - name : str - The module name being mocked. - - Examples - -------- - >>> mock = MockModule("mypackage.submodule") - >>> mock.__name__ - 'mypackage.submodule' - >>> child = mock.child_attr - >>> child.__name__ - 'mypackage.submodule.child_attr' - >>> callable(mock.some_function) - True - >>> mock.some_function() - - """ - - def __init__(self, name: str) -> None: - """Initialize the mock module.""" - self.__name__ = name - self._name = name - - def __repr__(self) -> str: - """Return string representation.""" - return f"" - - def __getattr__(self, name: str) -> MockModule: - """Return a child mock for any attribute access. - - Parameters - ---------- - name : str - The attribute name. - - Returns - ------- - MockModule - A new mock for the child attribute. - """ - return MockModule(f"{self._name}.{name}") - - def __call__(self, *args: t.Any, **kwargs: t.Any) -> MockModule: - """Return self when called as a function. - - Parameters - ---------- - *args : t.Any - Positional arguments (ignored). - **kwargs : t.Any - Keyword arguments (ignored). - - Returns - ------- - MockModule - Self. - """ - return self - - -@contextlib.contextmanager -def mock_imports(modules: list[str]) -> Iterator[None]: - """Context manager to mock missing imports. - - This provides a simple way to temporarily add mock modules to - sys.modules, allowing imports to succeed during documentation builds - even when the actual modules aren't available. - - Parameters - ---------- - modules : list[str] - List of module names to mock. - - Yields - ------ - None - Context manager yields nothing. - - Examples - -------- - >>> import sys - >>> "fake_module" in sys.modules - False - >>> with mock_imports(["fake_module", "fake_module.sub"]): - ... import fake_module - ... fake_module.__name__ - 'fake_module' - >>> "fake_module" in sys.modules - False - """ - mocked: dict[str, MockModule] = {} - - for name in modules: - if name not in sys.modules: - mocked[name] = MockModule(name) - sys.modules[name] = mocked[name] # type: ignore[assignment] - - try: - yield - finally: - for name in mocked: - del sys.modules[name] - - -def import_module(module_name: str) -> t.Any: - """Import a module by name. - - Parameters - ---------- - module_name : str - The fully qualified module name. - - Returns - ------- - t.Any - The imported module. - - Raises - ------ - ImportError - If the module cannot be imported. - - Examples - -------- - >>> mod = import_module("argparse") - >>> hasattr(mod, "ArgumentParser") - True - """ - return importlib.import_module(module_name) - - -def get_parser_from_module( - module_name: str, - func_name: str, - mock_modules: list[str] | None = None, -) -> argparse.ArgumentParser: - """Import a module and call a function to get an ArgumentParser. - - Parameters - ---------- - module_name : str - The module containing the parser factory function. - func_name : str - The name of the function that returns an ArgumentParser. - Can be a dotted path like "Class.method". - mock_modules : list[str] | None - Optional list of module names to mock during import. - - Returns - ------- - argparse.ArgumentParser - The argument parser returned by the function. - - Raises - ------ - ImportError - If the module cannot be imported. - AttributeError - If the function is not found. - TypeError - If the function doesn't return an ArgumentParser. - - Examples - -------- - Load tmuxp's parser factory: - - >>> parser = get_parser_from_module("tmuxp.cli", "create_parser") - >>> parser.prog - 'tmuxp' - >>> hasattr(parser, 'parse_args') - True - """ - ctx = mock_imports(mock_modules) if mock_modules else contextlib.nullcontext() - - with ctx: - module = import_module(module_name) - - # Handle dotted paths like "Class.method" - obj = module - for part in func_name.split("."): - obj = getattr(obj, part) - - # Call the function if it's callable - parser = obj() if callable(obj) else obj - - # Validate the return type at runtime - import argparse as argparse_module - - if not isinstance(parser, argparse_module.ArgumentParser): - msg = ( - f"{module_name}:{func_name} returned {type(parser).__name__}, " - f"expected ArgumentParser" - ) - raise TypeError(msg) - - return parser - - -def get_parser_from_entry_point( - entry_point: str, - mock_modules: list[str] | None = None, -) -> argparse.ArgumentParser: - """Get an ArgumentParser from a setuptools-style entry point string. - - Parameters - ---------- - entry_point : str - Entry point in the format "module:function" or "module:Class.method". - mock_modules : list[str] | None - Optional list of module names to mock during import. - - Returns - ------- - argparse.ArgumentParser - The argument parser. - - Raises - ------ - ValueError - If the entry point format is invalid. - - Examples - -------- - Load tmuxp's parser using entry point syntax: - - >>> parser = get_parser_from_entry_point("tmuxp.cli:create_parser") - >>> parser.prog - 'tmuxp' - - Invalid format raises ValueError: - - >>> get_parser_from_entry_point("no_colon") - Traceback (most recent call last): - ... - ValueError: Invalid entry point format: 'no_colon'. Expected 'module:function' - """ - if ":" not in entry_point: - msg = f"Invalid entry point format: {entry_point!r}. Expected 'module:function'" - raise ValueError(msg) - - module_name, func_name = entry_point.split(":", 1) - return get_parser_from_module(module_name, func_name, mock_modules) diff --git a/docs/_ext/sphinx_argparse_neo/directive.py b/docs/_ext/sphinx_argparse_neo/directive.py deleted file mode 100644 index 80d6d155ab..0000000000 --- a/docs/_ext/sphinx_argparse_neo/directive.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Sphinx directive for argparse documentation. - -This module provides the ArgparseDirective class that integrates -with Sphinx to generate documentation from ArgumentParser instances. -""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes -from docutils.parsers.rst import directives -from sphinx.util.docutils import SphinxDirective -from sphinx_argparse_neo.compat import get_parser_from_module -from sphinx_argparse_neo.parser import extract_parser -from sphinx_argparse_neo.renderer import ArgparseRenderer, RenderConfig - -if t.TYPE_CHECKING: - import argparse - - -class ArgparseDirective(SphinxDirective): - """Sphinx directive for documenting argparse-based CLI tools. - - Usage - ----- - .. argparse:: - :module: myapp.cli - :func: create_parser - :prog: myapp - - Options - ------- - :module: - The Python module containing the parser factory function. - :func: - The function name that returns an ArgumentParser. - Can be a dotted path like "Class.method". - :prog: - Override the program name (optional). - :path: - Navigate to a specific subparser by path (e.g., "sync pull"). - :no-defaults: - Don't show default values (flag). - :no-description: - Don't show parser description (flag). - :no-epilog: - Don't show parser epilog (flag). - :mock-modules: - Comma-separated list of modules to mock during import. - - Examples - -------- - In RST documentation:: - - .. argparse:: - :module: myapp.cli - :func: create_parser - :prog: myapp - - :path: subcommand - """ - - has_content = True - required_arguments = 0 - optional_arguments = 0 - - option_spec: t.ClassVar[dict[str, t.Any]] = { - "module": directives.unchanged_required, - "func": directives.unchanged_required, - "prog": directives.unchanged, - "path": directives.unchanged, - "no-defaults": directives.flag, - "no-description": directives.flag, - "no-epilog": directives.flag, - "no-choices": directives.flag, - "no-types": directives.flag, - "mock-modules": directives.unchanged, - # sphinx-argparse compatibility options - "nosubcommands": directives.flag, - "nodefault": directives.flag, - "noepilog": directives.flag, - "nodescription": directives.flag, - } - - def run(self) -> list[nodes.Node]: - """Execute the directive and return docutils nodes. - - Returns - ------- - list[nodes.Node] - List of docutils nodes representing the CLI documentation. - """ - # Get required options - module_name = self.options.get("module") - func_name = self.options.get("func") - - if not module_name or not func_name: - error = self.state_machine.reporter.error( - "argparse directive requires :module: and :func: options", - line=self.lineno, - ) - return [error] - - # Parse mock modules - mock_modules: list[str] | None = None - if "mock-modules" in self.options: - mock_modules = [m.strip() for m in self.options["mock-modules"].split(",")] - - # Load the parser - try: - parser = get_parser_from_module(module_name, func_name, mock_modules) - except Exception as e: - error = self.state_machine.reporter.error( - f"Failed to load parser from {module_name}:{func_name}: {e}", - line=self.lineno, - ) - return [error] - - # Override prog if specified - if "prog" in self.options: - parser.prog = self.options["prog"] - - # Navigate to subparser if path specified - if "path" in self.options: - parser = self._navigate_to_subparser(parser, self.options["path"]) - if parser is None: - error = self.state_machine.reporter.error( - f"Subparser path not found: {self.options['path']}", - line=self.lineno, - ) - return [error] - - # Build render config from directive options and Sphinx config - config = self._build_render_config() - - # Extract parser info - parser_info = extract_parser(parser) - - # Apply directive-level overrides - # Handle both new-style and sphinx-argparse compatibility options - if "no-description" in self.options or "nodescription" in self.options: - parser_info = parser_info.__class__( - prog=parser_info.prog, - usage=parser_info.usage, - bare_usage=parser_info.bare_usage, - description=None, - epilog=parser_info.epilog, - argument_groups=parser_info.argument_groups, - subcommands=parser_info.subcommands, - subcommand_dest=parser_info.subcommand_dest, - ) - if "no-epilog" in self.options or "noepilog" in self.options: - parser_info = parser_info.__class__( - prog=parser_info.prog, - usage=parser_info.usage, - bare_usage=parser_info.bare_usage, - description=parser_info.description, - epilog=None, - argument_groups=parser_info.argument_groups, - subcommands=parser_info.subcommands, - subcommand_dest=parser_info.subcommand_dest, - ) - if "nosubcommands" in self.options: - parser_info = parser_info.__class__( - prog=parser_info.prog, - usage=parser_info.usage, - bare_usage=parser_info.bare_usage, - description=parser_info.description, - epilog=parser_info.epilog, - argument_groups=parser_info.argument_groups, - subcommands=None, - subcommand_dest=None, - ) - - # Render to nodes - renderer = ArgparseRenderer(config=config, state=self.state) - return t.cast(list[nodes.Node], renderer.render(parser_info)) - - def _build_render_config(self) -> RenderConfig: - """Build RenderConfig from directive and Sphinx config options. - - Returns - ------- - RenderConfig - Configuration for the renderer. - """ - # Start with Sphinx config defaults - config = RenderConfig.from_sphinx_config(self.config) - - # Override with directive options - # Handle both new-style and sphinx-argparse compatibility options - if "no-defaults" in self.options or "nodefault" in self.options: - config.show_defaults = False - if "no-choices" in self.options: - config.show_choices = False - if "no-types" in self.options: - config.show_types = False - - return config - - def _navigate_to_subparser( - self, parser: argparse.ArgumentParser, path: str - ) -> argparse.ArgumentParser | None: - """Navigate to a nested subparser by path. - - Parameters - ---------- - parser : argparse.ArgumentParser - The root parser. - path : str - Space-separated path to the subparser (e.g., "sync pull"). - - Returns - ------- - argparse.ArgumentParser | None - The subparser, or None if not found. - """ - import argparse as argparse_module - - current = parser - for name in path.split(): - # Find subparsers action - subparser_action = None - for action in current._actions: - if isinstance(action, argparse_module._SubParsersAction): - subparser_action = action - break - - if subparser_action is None: - return None - - # Find the named subparser - choices = subparser_action.choices or {} - if name not in choices: - return None - - current = choices[name] - - return current diff --git a/docs/_ext/sphinx_argparse_neo/nodes.py b/docs/_ext/sphinx_argparse_neo/nodes.py deleted file mode 100644 index 468b5876a5..0000000000 --- a/docs/_ext/sphinx_argparse_neo/nodes.py +++ /dev/null @@ -1,647 +0,0 @@ -"""Custom docutils node types for argparse documentation. - -This module defines custom node types that represent the structure of -CLI documentation, along with HTML visitor functions for rendering. -""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes - -if t.TYPE_CHECKING: - from sphinx.writers.html5 import HTML5Translator - -# Import the lexer - use absolute import from parent package -import pathlib -import sys - -# Add parent directory to path for lexer import -_ext_dir = pathlib.Path(__file__).parent.parent -if str(_ext_dir) not in sys.path: - sys.path.insert(0, str(_ext_dir)) - -from argparse_lexer import ArgparseUsageLexer # noqa: E402 -from sphinx_argparse_neo.utils import strip_ansi # noqa: E402 - - -def _generate_argument_id(names: list[str], id_prefix: str = "") -> str: - """Generate unique ID for an argument based on its names. - - Creates a slug-style ID suitable for HTML anchors by: - 1. Stripping leading dashes from option names - 2. Joining multiple names with hyphens - 3. Prepending optional prefix for namespace isolation - - Parameters - ---------- - names : list[str] - List of argument names (e.g., ["-L", "--socket-name"]). - id_prefix : str - Optional prefix for uniqueness (e.g., "shell" -> "shell-L-socket-name"). - - Returns - ------- - str - A slug-style ID suitable for HTML anchors. - - Examples - -------- - >>> _generate_argument_id(["-L"]) - 'L' - >>> _generate_argument_id(["--help"]) - 'help' - >>> _generate_argument_id(["-v", "--verbose"]) - 'v-verbose' - >>> _generate_argument_id(["-L"], "shell") - 'shell-L' - >>> _generate_argument_id(["filename"]) - 'filename' - >>> _generate_argument_id([]) - '' - """ - clean_names = [name.lstrip("-") for name in names if name.lstrip("-")] - if not clean_names: - return "" - name_part = "-".join(clean_names) - return f"{id_prefix}-{name_part}" if id_prefix else name_part - - -def _token_to_css_class(token_type: t.Any) -> str: - """Map a Pygments token type to its CSS class abbreviation. - - Pygments uses hierarchical token names like Token.Name.Attribute. - These map to CSS classes using abbreviations of the last two parts: - - Token.Name.Attribute → 'na' (Name.Attribute) - - Token.Generic.Heading → 'gh' (Generic.Heading) - - Token.Punctuation → 'p' (just Punctuation) - - Parameters - ---------- - token_type : Any - A Pygments token type (from pygments.token). - - Returns - ------- - str - CSS class abbreviation, or empty string if not mappable. - - Examples - -------- - >>> from pygments.token import Token - >>> _token_to_css_class(Token.Name.Attribute) - 'na' - >>> _token_to_css_class(Token.Generic.Heading) - 'gh' - >>> _token_to_css_class(Token.Punctuation) - 'p' - >>> _token_to_css_class(Token.Text.Whitespace) - 'tw' - """ - type_str = str(token_type) - # Token string looks like "Token.Name.Attribute" or "Token.Punctuation" - parts = type_str.split(".") - - if len(parts) >= 3: - # Token.Name.Attribute -> "na" (first char of each of last two parts) - return parts[-2][0].lower() + parts[-1][0].lower() - elif len(parts) == 2: - # Token.Punctuation -> "p" (first char of last part) - return parts[-1][0].lower() - return "" - - -def _highlight_usage(usage_text: str, encode: t.Callable[[str], str]) -> str: - """Tokenize usage text and wrap tokens in highlighted span elements. - - Uses ArgparseUsageLexer to tokenize the usage string, then wraps each - token in a with the appropriate CSS class for styling. - - Parameters - ---------- - usage_text : str - The usage string to highlight (should include "usage: " prefix). - encode : Callable[[str], str] - HTML encoding function (typically translator.encode). - - Returns - ------- - str - HTML string with tokens wrapped in styled elements. - - Examples - -------- - >>> def mock_encode(s: str) -> str: - ... return s.replace("&", "&").replace("<", "<") - >>> html = _highlight_usage("usage: cmd [-h]", mock_encode) - >>> 'usage:' in html - True - >>> 'cmd' in html - True - >>> '-h' in html - True - """ - lexer = ArgparseUsageLexer() - parts: list[str] = [] - - for tok_type, tok_value in lexer.get_tokens(usage_text): - if not tok_value: - continue - - css_class = _token_to_css_class(tok_type) - escaped = encode(tok_value) - type_str = str(tok_type).lower() - - # Skip wrapping for whitespace and plain text tokens - if css_class and "whitespace" not in type_str and "text" not in type_str: - parts.append(f'{escaped}') - else: - parts.append(escaped) - - return "".join(parts) - - -def _highlight_argument_names( - names: list[str], metavar: str | None, encode: t.Callable[[str], str] -) -> str: - """Highlight argument names and metavar with appropriate CSS classes. - - Short options (-h) get class 'na' (Name.Attribute). - Long options (--help) get class 'nt' (Name.Tag). - Positional arguments get class 'nl' (Name.Label). - Metavars get class 'nv' (Name.Variable). - - Parameters - ---------- - names : list[str] - List of argument names (e.g., ["-v", "--verbose"]). - metavar : str | None - Optional metavar (e.g., "FILE", "PATH"). - encode : Callable[[str], str] - HTML encoding function. - - Returns - ------- - str - HTML string with highlighted argument signature. - - Examples - -------- - >>> def mock_encode(s: str) -> str: - ... return s - >>> html = _highlight_argument_names(["-h", "--help"], None, mock_encode) - >>> '-h' in html - True - >>> '--help' in html - True - >>> html = _highlight_argument_names(["--output"], "FILE", mock_encode) - >>> 'FILE' in html - True - >>> html = _highlight_argument_names(["sync"], None, mock_encode) - >>> 'sync' in html - True - """ - sig_parts: list[str] = [] - - for name in names: - escaped = encode(name) - if name.startswith("--"): - sig_parts.append(f'{escaped}') - elif name.startswith("-"): - sig_parts.append(f'{escaped}') - else: - # Positional argument or subcommand - sig_parts.append(f'{escaped}') - - result = ", ".join(sig_parts) - - if metavar: - escaped_metavar = encode(metavar) - result = f'{result} {escaped_metavar}' - - return result - - -class argparse_program(nodes.General, nodes.Element): - """Root node for an argparse program documentation block. - - Attributes - ---------- - prog : str - The program name. - - Examples - -------- - >>> node = argparse_program() - >>> node["prog"] = "myapp" - >>> node["prog"] - 'myapp' - """ - - pass - - -class argparse_usage(nodes.General, nodes.Element): - """Node for displaying program usage. - - Contains the usage string as a literal block. - - Examples - -------- - >>> node = argparse_usage() - >>> node["usage"] = "myapp [-h] [--verbose] command" - >>> node["usage"] - 'myapp [-h] [--verbose] command' - """ - - pass - - -class argparse_group(nodes.General, nodes.Element): - """Node for an argument group (positional, optional, or custom). - - Attributes - ---------- - title : str - The group title. - description : str | None - Optional group description. - - Examples - -------- - >>> node = argparse_group() - >>> node["title"] = "Output Options" - >>> node["title"] - 'Output Options' - """ - - pass - - -class argparse_argument(nodes.Part, nodes.Element): - """Node for a single CLI argument. - - Attributes - ---------- - names : list[str] - Argument names/flags. - help : str | None - Help text. - default : str | None - Default value string. - choices : list[str] | None - Available choices. - required : bool - Whether the argument is required. - metavar : str | None - Metavar for display. - - Examples - -------- - >>> node = argparse_argument() - >>> node["names"] = ["-v", "--verbose"] - >>> node["names"] - ['-v', '--verbose'] - """ - - pass - - -class argparse_subcommands(nodes.General, nodes.Element): - """Container node for subcommands section. - - Examples - -------- - >>> node = argparse_subcommands() - >>> node["title"] = "Commands" - >>> node["title"] - 'Commands' - """ - - pass - - -class argparse_subcommand(nodes.General, nodes.Element): - """Node for a single subcommand. - - Attributes - ---------- - name : str - Subcommand name. - aliases : list[str] - Subcommand aliases. - help : str | None - Subcommand help text. - - Examples - -------- - >>> node = argparse_subcommand() - >>> node["name"] = "sync" - >>> node["aliases"] = ["s"] - >>> node["name"] - 'sync' - """ - - pass - - -# HTML Visitor Functions - - -def visit_argparse_program_html(self: HTML5Translator, node: argparse_program) -> None: - """Visit argparse_program node - start program container. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_program - The program node being visited. - """ - prog = node.get("prog", "") - self.body.append(f'
\n') - - -def depart_argparse_program_html(self: HTML5Translator, node: argparse_program) -> None: - """Depart argparse_program node - close program container. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_program - The program node being departed. - """ - self.body.append("
\n") - - -def visit_argparse_usage_html(self: HTML5Translator, node: argparse_usage) -> None: - """Visit argparse_usage node - render usage block with syntax highlighting. - - The usage text is tokenized using ArgparseUsageLexer and wrapped in - styled elements for semantic highlighting of options, metavars, - commands, and punctuation. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_usage - The usage node being visited. - """ - usage = strip_ansi(node.get("usage", "")) - # Add both argparse-usage class and highlight class for CSS targeting - self.body.append('
')
-    # Prepend "usage: " and highlight the full usage string
-    highlighted = _highlight_usage(f"usage: {usage}", self.encode)
-    self.body.append(highlighted)
-
-
-def depart_argparse_usage_html(self: HTML5Translator, node: argparse_usage) -> None:
-    """Depart argparse_usage node - close usage block.
-
-    Parameters
-    ----------
-    self : HTML5Translator
-        The Sphinx HTML translator.
-    node : argparse_usage
-        The usage node being departed.
-    """
-    self.body.append("
\n") - - -def visit_argparse_group_html(self: HTML5Translator, node: argparse_group) -> None: - """Visit argparse_group node - start argument group. - - The title is now rendered by the parent section node, so this visitor - only handles the group container and description. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_group - The group node being visited. - """ - title = node.get("title", "") - group_id = title.lower().replace(" ", "-") if title else "arguments" - self.body.append(f'
\n') - # Title rendering removed - parent section now provides the heading - description = node.get("description") - if description: - self.body.append( - f'

{self.encode(description)}

\n' - ) - self.body.append('
\n') - - -def depart_argparse_group_html(self: HTML5Translator, node: argparse_group) -> None: - """Depart argparse_group node - close argument group. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_group - The group node being departed. - """ - self.body.append("
\n") - self.body.append("
\n") - - -def visit_argparse_argument_html( - self: HTML5Translator, node: argparse_argument -) -> None: - """Visit argparse_argument node - render argument entry with highlighting. - - Argument names are highlighted with semantic CSS classes: - - Short options (-h) get class 'na' (Name.Attribute) - - Long options (--help) get class 'nt' (Name.Tag) - - Positional arguments get class 'nl' (Name.Label) - - Metavars get class 'nv' (Name.Variable) - - The argument is wrapped in a container div with a unique ID for linking. - A headerlink anchor (¶) is added for direct navigation. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_argument - The argument node being visited. - """ - names: list[str] = node.get("names", []) - metavar = node.get("metavar") - id_prefix: str = node.get("id_prefix", "") - - # Generate unique ID for this argument - arg_id = _generate_argument_id(names, id_prefix) - - # Open wrapper div with ID for linking - if arg_id: - self.body.append(f'
\n') - else: - self.body.append('
\n') - - # Build the argument signature with syntax highlighting - highlighted_sig = _highlight_argument_names(names, metavar, self.encode) - - # Add headerlink anchor inside dt for navigation - headerlink = "" - if arg_id: - headerlink = f'' - - self.body.append( - f'
{highlighted_sig}{headerlink}
\n' - ) - self.body.append('
') - - # Add help text - help_text = node.get("help") - if help_text: - self.body.append(f"

{self.encode(help_text)}

") - - -def depart_argparse_argument_html( - self: HTML5Translator, node: argparse_argument -) -> None: - """Depart argparse_argument node - close argument entry. - - Adds default, choices, and type information if present. - Default values are wrapped in ```` for styled display. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_argument - The argument node being departed. - """ - # Build metadata as definition list items - default = node.get("default_string") - choices = node.get("choices") - type_name = node.get("type_name") - required = node.get("required", False) - - if default is not None or choices or type_name or required: - self.body.append('
\n') - - if default is not None: - self.body.append('
') - self.body.append('
Default
') - self.body.append( - f'
' - f'{self.encode(default)}
' - ) - self.body.append("
\n") - - if type_name: - self.body.append('
') - self.body.append('
Type
') - self.body.append( - f'
' - f'{self.encode(type_name)}
' - ) - self.body.append("
\n") - - if choices: - choices_str = ", ".join(str(c) for c in choices) - self.body.append('
') - self.body.append('
Choices
') - self.body.append( - f'
{self.encode(choices_str)}
' - ) - self.body.append("
\n") - - if required: - self.body.append('
Required
\n') - - self.body.append("
\n") - - self.body.append("
\n") - # Close wrapper div - self.body.append("
\n") - - -def visit_argparse_subcommands_html( - self: HTML5Translator, node: argparse_subcommands -) -> None: - """Visit argparse_subcommands node - start subcommands section. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_subcommands - The subcommands node being visited. - """ - title = node.get("title", "Sub-commands") - self.body.append('
\n') - self.body.append( - f'

{self.encode(title)}

\n' - ) - - -def depart_argparse_subcommands_html( - self: HTML5Translator, node: argparse_subcommands -) -> None: - """Depart argparse_subcommands node - close subcommands section. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_subcommands - The subcommands node being departed. - """ - self.body.append("
\n") - - -def visit_argparse_subcommand_html( - self: HTML5Translator, node: argparse_subcommand -) -> None: - """Visit argparse_subcommand node - start subcommand entry. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_subcommand - The subcommand node being visited. - """ - name = node.get("name", "") - aliases: list[str] = node.get("aliases", []) - - self.body.append(f'
\n') - - # Subcommand header - header = name - if aliases: - alias_str = ", ".join(aliases) - header = f"{name} ({alias_str})" - self.body.append( - f'

{self.encode(header)}

\n' - ) - - # Help text - help_text = node.get("help") - if help_text: - self.body.append( - f'

{self.encode(help_text)}

\n' - ) - - -def depart_argparse_subcommand_html( - self: HTML5Translator, node: argparse_subcommand -) -> None: - """Depart argparse_subcommand node - close subcommand entry. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_subcommand - The subcommand node being departed. - """ - self.body.append("
\n") diff --git a/docs/_ext/sphinx_argparse_neo/parser.py b/docs/_ext/sphinx_argparse_neo/parser.py deleted file mode 100644 index f3a6db44af..0000000000 --- a/docs/_ext/sphinx_argparse_neo/parser.py +++ /dev/null @@ -1,659 +0,0 @@ -"""Argparse introspection - extract structured data from ArgumentParser. - -This module provides dataclasses and functions to introspect argparse -ArgumentParser instances and convert them into structured data suitable -for documentation rendering. -""" - -from __future__ import annotations - -import argparse -import dataclasses -import typing as t - -from sphinx_argparse_neo.utils import strip_ansi - -# Sentinel for "no default" (distinct from None which is a valid default) -NO_DEFAULT = object() - - -@dataclasses.dataclass -class ArgumentInfo: - """Represents a single CLI argument. - - Examples - -------- - >>> info = ArgumentInfo( - ... names=["-v", "--verbose"], - ... help="Enable verbose output", - ... default=False, - ... default_string="False", - ... choices=None, - ... required=False, - ... metavar=None, - ... nargs=None, - ... action="store_true", - ... type_name=None, - ... const=True, - ... dest="verbose", - ... ) - >>> info.names - ['-v', '--verbose'] - >>> info.is_positional - False - """ - - names: list[str] - help: str | None - default: t.Any - default_string: str | None - choices: list[t.Any] | None - required: bool - metavar: str | None - nargs: str | int | None - action: str - type_name: str | None - const: t.Any - dest: str - - @property - def is_positional(self) -> bool: - """Return True if this is a positional argument. - - Examples - -------- - >>> ArgumentInfo( - ... names=["filename"], - ... help=None, - ... default=None, - ... default_string=None, - ... choices=None, - ... required=True, - ... metavar=None, - ... nargs=None, - ... action="store", - ... type_name=None, - ... const=None, - ... dest="filename", - ... ).is_positional - True - >>> ArgumentInfo( - ... names=["-f", "--file"], - ... help=None, - ... default=None, - ... default_string=None, - ... choices=None, - ... required=False, - ... metavar=None, - ... nargs=None, - ... action="store", - ... type_name=None, - ... const=None, - ... dest="file", - ... ).is_positional - False - """ - return bool(self.names) and not self.names[0].startswith("-") - - -@dataclasses.dataclass -class MutuallyExclusiveGroup: - """Arguments that cannot be used together. - - Examples - -------- - >>> group = MutuallyExclusiveGroup(arguments=[], required=True) - >>> group.required - True - """ - - arguments: list[ArgumentInfo] - required: bool - - -@dataclasses.dataclass -class ArgumentGroup: - """Named group of arguments. - - Examples - -------- - >>> group = ArgumentGroup( - ... title="Output Options", - ... description="Control output format", - ... arguments=[], - ... mutually_exclusive=[], - ... ) - >>> group.title - 'Output Options' - """ - - title: str - description: str | None - arguments: list[ArgumentInfo] - mutually_exclusive: list[MutuallyExclusiveGroup] - - -@dataclasses.dataclass -class SubcommandInfo: - """A subparser/subcommand. - - Examples - -------- - >>> sub = SubcommandInfo( - ... name="sync", - ... aliases=["s"], - ... help="Synchronize repositories", - ... parser=None, # type: ignore[arg-type] - ... ) - >>> sub.aliases - ['s'] - """ - - name: str - aliases: list[str] - help: str | None - parser: ParserInfo # Recursive reference - - -@dataclasses.dataclass -class ParserInfo: - """Complete parsed ArgumentParser. - - Examples - -------- - >>> info = ParserInfo( - ... prog="myapp", - ... usage=None, - ... bare_usage="myapp [-h] command", - ... description="My application", - ... epilog=None, - ... argument_groups=[], - ... subcommands=None, - ... subcommand_dest=None, - ... ) - >>> info.prog - 'myapp' - """ - - prog: str - usage: str | None - bare_usage: str - description: str | None - epilog: str | None - argument_groups: list[ArgumentGroup] - subcommands: list[SubcommandInfo] | None - subcommand_dest: str | None - - -def _format_default(default: t.Any) -> str | None: - """Format a default value for display. - - Parameters - ---------- - default : t.Any - The default value to format. - - Returns - ------- - str | None - Formatted string representation, or None if suppressed/unset. - - Examples - -------- - >>> _format_default(None) - 'None' - >>> _format_default("hello") - 'hello' - >>> _format_default(42) - '42' - >>> _format_default(argparse.SUPPRESS) is None - True - >>> _format_default([1, 2, 3]) - '[1, 2, 3]' - """ - if default is argparse.SUPPRESS: - return None - if default is None: - return "None" - if isinstance(default, str): - return default - return repr(default) - - -def _get_type_name(action: argparse.Action) -> str | None: - """Extract the type name from an action. - - Parameters - ---------- - action : argparse.Action - The argparse action to inspect. - - Returns - ------- - str | None - The type name, or None if no type is specified. - - Examples - -------- - >>> parser = argparse.ArgumentParser() - >>> action = parser.add_argument("--count", type=int) - >>> _get_type_name(action) - 'int' - >>> action2 = parser.add_argument("--name") - >>> _get_type_name(action2) is None - True - """ - if action.type is None: - return None - if hasattr(action.type, "__name__"): - return action.type.__name__ - return str(action.type) - - -def _get_action_name(action: argparse.Action) -> str: - """Get the action type name. - - Parameters - ---------- - action : argparse.Action - The argparse action to inspect. - - Returns - ------- - str - The action type name. - - Examples - -------- - >>> parser = argparse.ArgumentParser() - >>> action = parser.add_argument("--verbose", action="store_true") - >>> _get_action_name(action) - 'store_true' - >>> action2 = parser.add_argument("--file") - >>> _get_action_name(action2) - 'store' - """ - # Map action classes to their string names - action_class = type(action).__name__ - action_map = { - "_StoreAction": "store", - "_StoreTrueAction": "store_true", - "_StoreFalseAction": "store_false", - "_StoreConstAction": "store_const", - "_AppendAction": "append", - "_AppendConstAction": "append_const", - "_CountAction": "count", - "_HelpAction": "help", - "_VersionAction": "version", - "_ExtendAction": "extend", - "BooleanOptionalAction": "boolean_optional", - } - return action_map.get(action_class, action_class.lower()) - - -def _extract_argument(action: argparse.Action) -> ArgumentInfo: - """Extract ArgumentInfo from an argparse Action. - - Parameters - ---------- - action : argparse.Action - The argparse action to extract information from. - - Returns - ------- - ArgumentInfo - Structured argument information. - - Examples - -------- - >>> parser = argparse.ArgumentParser() - >>> action = parser.add_argument( - ... "-v", "--verbose", - ... action="store_true", - ... help="Enable verbose mode", - ... ) - >>> info = _extract_argument(action) - >>> info.names - ['-v', '--verbose'] - >>> info.action - 'store_true' - """ - # Determine names - option_strings for optionals, dest for positionals - names = list(action.option_strings) if action.option_strings else [action.dest] - - # Determine if required - required = action.required if hasattr(action, "required") else False - # Positional arguments are required by default (unless nargs makes them optional) - if not action.option_strings: - required = action.nargs not in ("?", "*", argparse.REMAINDER) - - # Format metavar - metavar = action.metavar - if isinstance(metavar, tuple): - metavar = " ".join(metavar) - - # Handle default - default = action.default - default_string = _format_default(default) - - return ArgumentInfo( - names=names, - help=action.help if action.help != argparse.SUPPRESS else None, - default=default if default is not argparse.SUPPRESS else NO_DEFAULT, - default_string=default_string, - choices=list(action.choices) if action.choices else None, - required=required, - metavar=metavar, - nargs=action.nargs, - action=_get_action_name(action), - type_name=_get_type_name(action), - const=action.const, - dest=action.dest, - ) - - -def _extract_mutex_groups( - parser: argparse.ArgumentParser, -) -> dict[int, MutuallyExclusiveGroup]: - """Extract mutually exclusive groups from a parser. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to extract from. - - Returns - ------- - dict[int, MutuallyExclusiveGroup] - Mapping from action id to the MutuallyExclusiveGroup it belongs to. - - Examples - -------- - Extract mutually exclusive groups from a parser with one group: - - >>> parser = argparse.ArgumentParser() - >>> group = parser.add_mutually_exclusive_group() - >>> _ = group.add_argument("--foo", help="Use foo") - >>> _ = group.add_argument("--bar", help="Use bar") - >>> mutex_map = _extract_mutex_groups(parser) - >>> len(mutex_map) - 2 - - Each action in the group maps to the same MutuallyExclusiveGroup: - - >>> values = list(mutex_map.values()) - >>> values[0] is values[1] - True - >>> len(values[0].arguments) - 2 - >>> [arg.names[0] for arg in values[0].arguments] - ['--foo', '--bar'] - - A parser without mutex groups returns an empty mapping: - - >>> parser2 = argparse.ArgumentParser() - >>> _ = parser2.add_argument("--verbose") - >>> _extract_mutex_groups(parser2) - {} - """ - mutex_map: dict[int, MutuallyExclusiveGroup] = {} - - for mutex_group in parser._mutually_exclusive_groups: - group_info = MutuallyExclusiveGroup( - arguments=[ - _extract_argument(action) - for action in mutex_group._group_actions - if action.help != argparse.SUPPRESS - ], - required=mutex_group.required, - ) - for action in mutex_group._group_actions: - mutex_map[id(action)] = group_info - - return mutex_map - - -def _extract_argument_groups( - parser: argparse.ArgumentParser, - hide_suppressed: bool = True, -) -> list[ArgumentGroup]: - """Extract argument groups from a parser. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to extract from. - hide_suppressed : bool - Whether to hide arguments with SUPPRESS help. - - Returns - ------- - list[ArgumentGroup] - List of argument groups. - - Examples - -------- - >>> parser = argparse.ArgumentParser(description="Test") - >>> _ = parser.add_argument("filename", help="Input file") - >>> _ = parser.add_argument("-v", "--verbose", action="store_true") - >>> groups = _extract_argument_groups(parser) - >>> len(groups) >= 2 # positional and optional groups - True - """ - mutex_map = _extract_mutex_groups(parser) - seen_mutex: set[int] = set() - groups: list[ArgumentGroup] = [] - - for group in parser._action_groups: - arguments: list[ArgumentInfo] = [] - mutex_groups: list[MutuallyExclusiveGroup] = [] - - for action in group._group_actions: - # Skip help action and suppressed actions - if isinstance(action, argparse._HelpAction): - continue - if hide_suppressed and action.help == argparse.SUPPRESS: - continue - # Skip subparser actions - handled separately - if isinstance(action, argparse._SubParsersAction): - continue - - # Check if this action is in a mutex group - if id(action) in mutex_map: - mutex_info = mutex_map[id(action)] - mutex_id = id(mutex_info) - if mutex_id not in seen_mutex: - seen_mutex.add(mutex_id) - mutex_groups.append(mutex_info) - else: - arguments.append(_extract_argument(action)) - - # Skip empty groups - if not arguments and not mutex_groups: - continue - - groups.append( - ArgumentGroup( - title=group.title or "", - description=group.description, - arguments=arguments, - mutually_exclusive=mutex_groups, - ) - ) - - return groups - - -def _extract_subcommands( - parser: argparse.ArgumentParser, - hide_suppressed: bool = True, -) -> tuple[list[SubcommandInfo] | None, str | None]: - """Extract subcommands from a parser. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to extract from. - hide_suppressed : bool - Whether to hide subcommands with SUPPRESS help. - - Returns - ------- - tuple[list[SubcommandInfo] | None, str | None] - Tuple of (subcommands list, destination variable name). - - Examples - -------- - >>> parser = argparse.ArgumentParser() - >>> subparsers = parser.add_subparsers(dest="command") - >>> _ = subparsers.add_parser("sync", help="Sync repos") - >>> _ = subparsers.add_parser("add", help="Add repo") - >>> subs, dest = _extract_subcommands(parser) - >>> dest - 'command' - >>> len(subs) - 2 - """ - for action in parser._actions: - if isinstance(action, argparse._SubParsersAction): - subcommands: list[SubcommandInfo] = [] - - # Get the choices (subparsers) - choices = action.choices or {} - - # Build reverse mapping of aliases - # action._parser_class might have name_parser_map with aliases - alias_map: dict[str, list[str]] = {} - seen_parsers: dict[int, str] = {} - - for name, subparser in choices.items(): - parser_id = id(subparser) - if parser_id in seen_parsers: - # This is an alias - primary = seen_parsers[parser_id] - if primary not in alias_map: - alias_map[primary] = [] - alias_map[primary].append(name) - else: - seen_parsers[parser_id] = name - - # Now extract subcommand info - processed: set[int] = set() - for name, subparser in choices.items(): - parser_id = id(subparser) - if parser_id in processed: - continue - processed.add(parser_id) - - # Get help text - help_text: str | None = None - if hasattr(action, "_choices_actions"): - for choice_action in action._choices_actions: - if choice_action.dest == name: - help_text = choice_action.help - break - - if hide_suppressed and help_text == argparse.SUPPRESS: - continue - - # Recursively extract parser info - sub_info = extract_parser(subparser, hide_suppressed=hide_suppressed) - - subcommands.append( - SubcommandInfo( - name=name, - aliases=alias_map.get(name, []), - help=help_text, - parser=sub_info, - ) - ) - - return subcommands, action.dest - - return None, None - - -def _generate_usage(parser: argparse.ArgumentParser) -> str: - """Generate the usage string for a parser. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to generate usage for. - - Returns - ------- - str - The bare usage string (without "usage: " prefix). - - Examples - -------- - >>> parser = argparse.ArgumentParser(prog="myapp") - >>> _ = parser.add_argument("-v", "--verbose", action="store_true") - >>> usage = _generate_usage(parser) - >>> "myapp" in usage - True - """ - # Use argparse's built-in formatter to generate usage - formatter = parser._get_formatter() - formatter.add_usage( - parser.usage, parser._actions, parser._mutually_exclusive_groups - ) - usage: str = formatter.format_help().strip() - - # Strip ANSI codes before checking prefix (handles FORCE_COLOR edge case) - usage = strip_ansi(usage) - - # Remove "usage: " prefix if present - if usage.lower().startswith("usage:"): - usage = usage[6:].strip() - - return usage - - -def extract_parser( - parser: argparse.ArgumentParser, - hide_suppressed: bool = True, -) -> ParserInfo: - """Extract complete parser information. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to extract information from. - hide_suppressed : bool - Whether to hide arguments/subcommands with SUPPRESS help. - - Returns - ------- - ParserInfo - Complete structured parser information. - - Examples - -------- - >>> parser = argparse.ArgumentParser( - ... prog="myapp", - ... description="My application", - ... ) - >>> _ = parser.add_argument("filename", help="Input file") - >>> _ = parser.add_argument("-v", "--verbose", action="store_true") - >>> info = extract_parser(parser) - >>> info.prog - 'myapp' - >>> info.description - 'My application' - >>> len(info.argument_groups) >= 1 - True - """ - subcommands, subcommand_dest = _extract_subcommands(parser, hide_suppressed) - - return ParserInfo( - prog=parser.prog, - usage=parser.usage, - bare_usage=_generate_usage(parser), - description=parser.description, - epilog=parser.epilog, - argument_groups=_extract_argument_groups(parser, hide_suppressed), - subcommands=subcommands, - subcommand_dest=subcommand_dest, - ) diff --git a/docs/_ext/sphinx_argparse_neo/renderer.py b/docs/_ext/sphinx_argparse_neo/renderer.py deleted file mode 100644 index f6c313f9f1..0000000000 --- a/docs/_ext/sphinx_argparse_neo/renderer.py +++ /dev/null @@ -1,604 +0,0 @@ -"""Renderer - convert ParserInfo to docutils nodes. - -This module provides the ArgparseRenderer class that transforms -structured parser information into docutils nodes for documentation. -""" - -from __future__ import annotations - -import dataclasses -import typing as t - -from docutils import nodes -from docutils.statemachine import StringList -from sphinx_argparse_neo.nodes import ( - argparse_argument, - argparse_group, - argparse_program, - argparse_subcommand, - argparse_subcommands, - argparse_usage, -) -from sphinx_argparse_neo.parser import ( - ArgumentGroup, - ArgumentInfo, - MutuallyExclusiveGroup, - ParserInfo, - SubcommandInfo, -) -from sphinx_argparse_neo.utils import escape_rst_emphasis - -if t.TYPE_CHECKING: - from docutils.parsers.rst.states import RSTState - from sphinx.config import Config - - -@dataclasses.dataclass -class RenderConfig: - """Configuration for the renderer. - - Examples - -------- - >>> config = RenderConfig() - >>> config.show_defaults - True - >>> config.group_title_prefix - '' - """ - - group_title_prefix: str = "" - show_defaults: bool = True - show_choices: bool = True - show_types: bool = True - - @classmethod - def from_sphinx_config(cls, config: Config) -> RenderConfig: - """Create RenderConfig from Sphinx configuration. - - Parameters - ---------- - config : Config - Sphinx configuration object. - - Returns - ------- - RenderConfig - Render configuration based on Sphinx config values. - """ - return cls( - group_title_prefix=getattr(config, "argparse_group_title_prefix", ""), - show_defaults=getattr(config, "argparse_show_defaults", True), - show_choices=getattr(config, "argparse_show_choices", True), - show_types=getattr(config, "argparse_show_types", True), - ) - - -class ArgparseRenderer: - """Render ParserInfo to docutils nodes. - - This class can be subclassed to customize rendering behavior. - Override individual methods to change how specific elements are rendered. - - Parameters - ---------- - config : RenderConfig - Rendering configuration. - state : RSTState | None - RST state for parsing nested RST content. - - Examples - -------- - >>> from sphinx_argparse_neo.parser import ParserInfo - >>> config = RenderConfig() - >>> renderer = ArgparseRenderer(config) - >>> info = ParserInfo( - ... prog="myapp", - ... usage=None, - ... bare_usage="myapp [-h]", - ... description="My app", - ... epilog=None, - ... argument_groups=[], - ... subcommands=None, - ... subcommand_dest=None, - ... ) - >>> result = renderer.render(info) - >>> isinstance(result, list) - True - """ - - def __init__( - self, - config: RenderConfig | None = None, - state: RSTState | None = None, - ) -> None: - """Initialize the renderer.""" - self.config = config or RenderConfig() - self.state = state - - @staticmethod - def _extract_id_prefix(prog: str) -> str: - """Extract subcommand from prog for unique section IDs. - - Parameters - ---------- - prog : str - The program name, potentially with subcommand (e.g., "tmuxp load"). - - Returns - ------- - str - The subcommand part for use as ID prefix, or empty string if none. - - Examples - -------- - >>> ArgparseRenderer._extract_id_prefix("tmuxp load") - 'load' - >>> ArgparseRenderer._extract_id_prefix("tmuxp") - '' - >>> ArgparseRenderer._extract_id_prefix("vcspull sync") - 'sync' - >>> ArgparseRenderer._extract_id_prefix("myapp sub cmd") - 'sub-cmd' - """ - parts = prog.split() - if len(parts) <= 1: - return "" - # Join remaining parts with hyphen for multi-level subcommands - return "-".join(parts[1:]) - - def render(self, parser_info: ParserInfo) -> list[nodes.Node]: - """Render a complete parser to docutils nodes. - - Parameters - ---------- - parser_info : ParserInfo - The parsed parser information. - - Returns - ------- - list[nodes.Node] - List of docutils nodes representing the documentation. - - Note - ---- - Sections for Usage and argument groups are emitted as siblings of - argparse_program rather than children. This allows Sphinx's - TocTreeCollector to discover them for inclusion in the table of - contents. - - The rendered structure is: - - - argparse_program (description only, no "examples:" part) - - section#usage (h3 "Usage" with usage block) - - section#positional-arguments (h3) - - section#options (h3) - - The "examples:" definition list in descriptions is left for - argparse_exemplar.py to transform into a proper Examples section. - """ - result: list[nodes.Node] = [] - - # Create program container for description only - program_node = argparse_program() - program_node["prog"] = parser_info.prog - - # Add description (may contain "examples:" definition list for later - # transformation by argparse_exemplar.py) - if parser_info.description: - desc_nodes = self._parse_text(parser_info.description) - program_node.extend(desc_nodes) - - result.append(program_node) - - # Extract ID prefix from prog for unique section IDs - # e.g., "tmuxp load" -> "load", "myapp" -> "" - id_prefix = self._extract_id_prefix(parser_info.prog) - - # Add Usage section as sibling (for TOC visibility) - usage_section = self.render_usage_section(parser_info, id_prefix=id_prefix) - result.append(usage_section) - - # Add argument groups as sibling sections (for TOC visibility) - for group in parser_info.argument_groups: - group_section = self.render_group_section(group, id_prefix=id_prefix) - result.append(group_section) - - # Add subcommands - if parser_info.subcommands: - subcommands_node = self.render_subcommands(parser_info.subcommands) - result.append(subcommands_node) - - # Add epilog - if parser_info.epilog: - epilog_nodes = self._parse_text(parser_info.epilog) - result.extend(epilog_nodes) - - return self.post_process(result) - - def render_usage(self, parser_info: ParserInfo) -> argparse_usage: - """Render the usage block. - - Parameters - ---------- - parser_info : ParserInfo - The parser information. - - Returns - ------- - argparse_usage - Usage node. - """ - usage_node = argparse_usage() - usage_node["usage"] = parser_info.bare_usage - return usage_node - - def render_usage_section( - self, parser_info: ParserInfo, *, id_prefix: str = "" - ) -> nodes.section: - """Render usage as a section with heading for TOC visibility. - - Creates a proper section node with "Usage" heading containing the - usage block. This structure allows Sphinx's TocTreeCollector to - discover it for the table of contents. - - Parameters - ---------- - parser_info : ParserInfo - The parser information. - id_prefix : str - Optional prefix for the section ID (e.g., "load" -> "load-usage"). - Used to ensure unique IDs when multiple argparse directives exist - on the same page. - - Returns - ------- - nodes.section - Section node containing the usage block with a "Usage" heading. - - Examples - -------- - >>> from sphinx_argparse_neo.parser import ParserInfo - >>> renderer = ArgparseRenderer() - >>> info = ParserInfo( - ... prog="myapp", - ... usage=None, - ... bare_usage="myapp [-h] command", - ... description=None, - ... epilog=None, - ... argument_groups=[], - ... subcommands=None, - ... subcommand_dest=None, - ... ) - >>> section = renderer.render_usage_section(info) - >>> section["ids"] - ['usage'] - - With prefix for subcommand pages: - - >>> section = renderer.render_usage_section(info, id_prefix="load") - >>> section["ids"] - ['load-usage'] - >>> section.children[0].astext() - 'Usage' - """ - section_id = f"{id_prefix}-usage" if id_prefix else "usage" - section = nodes.section() - section["ids"] = [section_id] - section["names"] = [nodes.fully_normalize_name("Usage")] - section += nodes.title("Usage", "Usage") - - usage_node = argparse_usage() - usage_node["usage"] = parser_info.bare_usage - section += usage_node - - return section - - def render_group_section( - self, group: ArgumentGroup, *, id_prefix: str = "" - ) -> nodes.section: - """Render an argument group wrapped in a section for TOC visibility. - - Creates a proper section node with the group title as heading, - containing the argparse_group node. This structure allows Sphinx's - TocTreeCollector to discover it for the table of contents. - - Parameters - ---------- - group : ArgumentGroup - The argument group to render. - id_prefix : str - Optional prefix for the section ID (e.g., "load" -> "load-options"). - Used to ensure unique IDs when multiple argparse directives exist - on the same page. - - Returns - ------- - nodes.section - Section node containing the group for TOC discovery. - - Examples - -------- - >>> from sphinx_argparse_neo.parser import ArgumentGroup - >>> renderer = ArgparseRenderer() - >>> group = ArgumentGroup( - ... title="positional arguments", - ... description=None, - ... arguments=[], - ... mutually_exclusive=[], - ... ) - >>> section = renderer.render_group_section(group) - >>> section["ids"] - ['positional-arguments'] - - With prefix for subcommand pages: - - >>> section = renderer.render_group_section(group, id_prefix="load") - >>> section["ids"] - ['load-positional-arguments'] - >>> section.children[0].astext() - 'Positional Arguments' - """ - # Title case the group title for proper display - raw_title = group.title or "Arguments" - title = raw_title.title() # "positional arguments" -> "Positional Arguments" - - if self.config.group_title_prefix: - title = f"{self.config.group_title_prefix}{title}" - - # Generate section ID from title (with optional prefix for uniqueness) - base_id = title.lower().replace(" ", "-") - section_id = f"{id_prefix}-{base_id}" if id_prefix else base_id - - # Create section wrapper for TOC discovery - section = nodes.section() - section["ids"] = [section_id] - section["names"] = [nodes.fully_normalize_name(title)] - - # Add title for TOC - Sphinx's TocTreeCollector looks for this - section += nodes.title(title, title) - - # Create the styled group container (with empty title - section provides it) - # Pass id_prefix to render_group so arguments get unique IDs - group_node = self.render_group(group, include_title=False, id_prefix=id_prefix) - section += group_node - - return section - - def render_group( - self, - group: ArgumentGroup, - include_title: bool = True, - *, - id_prefix: str = "", - ) -> argparse_group: - """Render an argument group. - - Parameters - ---------- - group : ArgumentGroup - The argument group to render. - include_title : bool - Whether to include the title in the group node. When False, - the title is assumed to come from a parent section node. - Default is True for backwards compatibility. - id_prefix : str - Optional prefix for argument IDs (e.g., "shell" -> "shell-h"). - Used to ensure unique IDs when multiple argparse directives exist - on the same page. - - Returns - ------- - argparse_group - Group node containing argument nodes. - """ - group_node = argparse_group() - - if include_title: - title = group.title - if self.config.group_title_prefix: - title = f"{self.config.group_title_prefix}{title}" - group_node["title"] = title - else: - # Title provided by parent section - group_node["title"] = "" - - group_node["description"] = group.description - - # Add individual arguments - for arg in group.arguments: - arg_node = self.render_argument(arg, id_prefix=id_prefix) - group_node.append(arg_node) - - # Add mutually exclusive groups - for mutex in group.mutually_exclusive: - mutex_nodes = self.render_mutex_group(mutex, id_prefix=id_prefix) - group_node.extend(mutex_nodes) - - return group_node - - def render_argument( - self, arg: ArgumentInfo, *, id_prefix: str = "" - ) -> argparse_argument: - """Render a single argument. - - Parameters - ---------- - arg : ArgumentInfo - The argument to render. - id_prefix : str - Optional prefix for the argument ID (e.g., "shell" -> "shell-L"). - Used to ensure unique IDs when multiple argparse directives exist - on the same page. - - Returns - ------- - argparse_argument - Argument node. - """ - arg_node = argparse_argument() - arg_node["names"] = arg.names - arg_node["help"] = arg.help - arg_node["metavar"] = arg.metavar - arg_node["required"] = arg.required - arg_node["id_prefix"] = id_prefix - - if self.config.show_defaults: - arg_node["default_string"] = arg.default_string - - if self.config.show_choices: - arg_node["choices"] = arg.choices - - if self.config.show_types: - arg_node["type_name"] = arg.type_name - - return arg_node - - def render_mutex_group( - self, mutex: MutuallyExclusiveGroup, *, id_prefix: str = "" - ) -> list[argparse_argument]: - """Render a mutually exclusive group. - - Parameters - ---------- - mutex : MutuallyExclusiveGroup - The mutually exclusive group. - id_prefix : str - Optional prefix for argument IDs (e.g., "shell" -> "shell-h"). - - Returns - ------- - list[argparse_argument] - List of argument nodes with mutex indicator. - """ - result: list[argparse_argument] = [] - for arg in mutex.arguments: - arg_node = self.render_argument(arg, id_prefix=id_prefix) - # Mark as part of mutex group - arg_node["mutex"] = True - arg_node["mutex_required"] = mutex.required - result.append(arg_node) - return result - - def render_subcommands( - self, subcommands: list[SubcommandInfo] - ) -> argparse_subcommands: - """Render subcommands section. - - Parameters - ---------- - subcommands : list[SubcommandInfo] - List of subcommand information. - - Returns - ------- - argparse_subcommands - Subcommands container node. - """ - container = argparse_subcommands() - container["title"] = "Sub-commands" - - for subcmd in subcommands: - subcmd_node = self.render_subcommand(subcmd) - container.append(subcmd_node) - - return container - - def render_subcommand(self, subcmd: SubcommandInfo) -> argparse_subcommand: - """Render a single subcommand. - - Parameters - ---------- - subcmd : SubcommandInfo - The subcommand information. - - Returns - ------- - argparse_subcommand - Subcommand node, potentially containing nested parser content. - """ - subcmd_node = argparse_subcommand() - subcmd_node["name"] = subcmd.name - subcmd_node["aliases"] = subcmd.aliases - subcmd_node["help"] = subcmd.help - - # Recursively render the subcommand's parser - if subcmd.parser: - nested_nodes = self.render(subcmd.parser) - subcmd_node.extend(nested_nodes) - - return subcmd_node - - def post_process(self, result_nodes: list[nodes.Node]) -> list[nodes.Node]: - """Post-process the rendered nodes. - - Override this method to apply transformations after rendering. - - Parameters - ---------- - result_nodes : list[nodes.Node] - The rendered nodes. - - Returns - ------- - list[nodes.Node] - Post-processed nodes. - """ - return result_nodes - - def _parse_text(self, text: str) -> list[nodes.Node]: - """Parse text as RST or MyST content. - - Parameters - ---------- - text : str - Text to parse. - - Returns - ------- - list[nodes.Node] - Parsed docutils nodes. - """ - if not text: - return [] - - # Escape RST emphasis patterns before parsing (e.g., "django-*" -> "django-\*") - text = escape_rst_emphasis(text) - - if self.state is None: - # No state machine available, return as paragraph - para = nodes.paragraph(text=text) - return [para] - - # Use the state machine to parse RST - container = nodes.container() - self.state.nested_parse( - StringList(text.split("\n")), - 0, - container, - ) - return list(container.children) - - -def create_renderer( - config: RenderConfig | None = None, - state: RSTState | None = None, - renderer_class: type[ArgparseRenderer] | None = None, -) -> ArgparseRenderer: - """Create a renderer instance. - - Parameters - ---------- - config : RenderConfig | None - Rendering configuration. - state : RSTState | None - RST state for parsing. - renderer_class : type[ArgparseRenderer] | None - Custom renderer class to use. - - Returns - ------- - ArgparseRenderer - Configured renderer instance. - """ - cls = renderer_class or ArgparseRenderer - return cls(config=config, state=state) diff --git a/docs/_ext/sphinx_argparse_neo/utils.py b/docs/_ext/sphinx_argparse_neo/utils.py deleted file mode 100644 index 468b1961fa..0000000000 --- a/docs/_ext/sphinx_argparse_neo/utils.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Text processing utilities for sphinx_argparse_neo. - -This module provides utilities for cleaning argparse output before rendering: -- strip_ansi: Remove ANSI escape codes (for when FORCE_COLOR is set) -""" - -from __future__ import annotations - -import re - -# ANSI escape code pattern - matches CSI sequences like \033[32m, \033[1;34m, etc. -_ANSI_RE = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") - - -def strip_ansi(text: str) -> str: - r"""Remove ANSI escape codes from text. - - When FORCE_COLOR is set in the environment, argparse may include ANSI - escape codes in its output. This function removes them so the output - renders correctly in Sphinx documentation. - - Parameters - ---------- - text : str - Text potentially containing ANSI codes. - - Returns - ------- - str - Text with ANSI codes removed. - - Examples - -------- - >>> strip_ansi("plain text") - 'plain text' - >>> strip_ansi("\033[32mgreen\033[0m") - 'green' - >>> strip_ansi("\033[1;34mbold blue\033[0m") - 'bold blue' - """ - return _ANSI_RE.sub("", text) - - -# RST emphasis pattern: matches -* that would trigger inline emphasis errors. -# Pattern matches: non-whitespace/non-backslash char, followed by -*, NOT followed by -# another * (which would be strong emphasis **). -_RST_EMPHASIS_RE = re.compile(r"(?<=[^\s\\])-\*(?!\*)") - - -def escape_rst_emphasis(text: str) -> str: - r"""Escape asterisks that would trigger RST inline emphasis. - - RST interprets ``*text*`` as emphasis. When argparse help text contains - glob patterns like ``django-*``, the ``-*`` sequence triggers RST - "Inline emphasis start-string without end-string" warnings. - - This function escapes such asterisks to prevent RST parsing errors. - - Parameters - ---------- - text : str - Text potentially containing problematic asterisks. - - Returns - ------- - str - Text with asterisks escaped where needed. - - Examples - -------- - >>> escape_rst_emphasis('tmuxp load "my-*"') - 'tmuxp load "my-\\*"' - >>> escape_rst_emphasis("plain text") - 'plain text' - >>> escape_rst_emphasis("*emphasis* is ok") - '*emphasis* is ok' - """ - return _RST_EMPHASIS_RE.sub(r"-\*", text) diff --git a/docs/_static/css/argparse-highlight.css b/docs/_static/css/argparse-highlight.css deleted file mode 100644 index f232c71c8c..0000000000 --- a/docs/_static/css/argparse-highlight.css +++ /dev/null @@ -1,437 +0,0 @@ -/* - * Argparse/CLI Highlighting Styles - * - * Styles for CLI inline roles and argparse help output highlighting. - * Uses "One Dark" inspired color palette (Python 3.14 argparse style). - * - * Color Palette: - * Background: #282C34 - * Default text: #CCCED4 - * Usage label: #61AFEF (blue, bold) - * Program name: #C678DD (purple, bold) - * Subcommands: #98C379 (green) - * Options: #56B6C2 (teal) - * Metavars: #E5C07B (yellow, italic) - * Choices: #98C379 (green) - * Headings: #E5E5E5 (bright, bold) - * Punctuation: #CCCED4 - */ - -/* ========================================================================== - Inline Role Styles - ========================================================================== */ - -/* - * Shared monospace font and code font-size for all CLI inline roles - */ -.cli-option, -.cli-metavar, -.cli-command, -.cli-default, -.cli-choice { - font-family: var(--font-stack--monospace); - font-size: var(--code-font-size); -} - -/* - * CLI Options - * - * Long options (--verbose) and short options (-h) both use teal color. - */ -.cli-option-long, -.cli-option-short { - color: #56b6c2; -} - -/* - * CLI Metavars - * - * Placeholder values like FILE, PATH, DIRECTORY. - * Yellow/amber to indicate "replace me" - distinct from flags (teal). - */ -.cli-metavar { - color: #e5c07b; - font-style: italic; -} - -/* - * CLI Commands and Choices - * - * Both use green to indicate valid enumerated values. - * Commands: subcommand names like sync, add, list - * Choices: enumeration values like json, yaml, table - */ -.cli-command, -.cli-choice { - color: #98c379; -} - -.cli-command { - font-weight: bold; -} - -/* - * CLI Default Values - * - * Default values shown in help text like None, "auto". - * Subtle styling to not distract from options. - */ -.cli-default { - color: #ccced4; - font-style: italic; -} - -/* ========================================================================== - Argparse Code Block Highlighting - ========================================================================== */ - -/* - * These styles apply within Pygments-highlighted code blocks using the - * argparse, argparse-usage, or argparse-help lexers. - */ - -/* Usage heading "usage:" - bold blue */ -.highlight-argparse .gh, -.highlight-argparse-usage .gh, -.highlight-argparse-help .gh { - color: #61afef; - font-weight: bold; -} - -/* Section headers like "positional arguments:", "options:" - neutral bright */ -.highlight-argparse .gs, -.highlight-argparse-help .gs { - color: #e5e5e5; - font-weight: bold; -} - -/* Long options --foo - teal */ -.highlight-argparse .nt, -.highlight-argparse-usage .nt, -.highlight-argparse-help .nt { - color: #56b6c2; - font-weight: normal; -} - -/* Short options -h - teal (same as long) */ -.highlight-argparse .na, -.highlight-argparse-usage .na, -.highlight-argparse-help .na { - color: #56b6c2; - font-weight: normal; -} - -/* Metavar placeholders FILE, PATH - yellow/amber italic */ -.highlight-argparse .nv, -.highlight-argparse-usage .nv, -.highlight-argparse-help .nv { - color: #e5c07b; - font-style: italic; -} - -/* Command/program names - purple bold */ -.highlight-argparse .nl, -.highlight-argparse-usage .nl, -.highlight-argparse-help .nl { - color: #c678dd; - font-weight: bold; -} - -/* Subcommands - bold green */ -.highlight-argparse .nf, -.highlight-argparse-usage .nf, -.highlight-argparse-help .nf { - color: #98c379; - font-weight: bold; -} - -/* Choice values - green */ -.highlight-argparse .no, -.highlight-argparse-usage .no, -.highlight-argparse-help .no, -.highlight-argparse .nc, -.highlight-argparse-usage .nc, -.highlight-argparse-help .nc { - color: #98c379; -} - -/* Punctuation [], {}, () - neutral gray */ -.highlight-argparse .p, -.highlight-argparse-usage .p, -.highlight-argparse-help .p { - color: #ccced4; -} - -/* Operators like | - neutral gray */ -.highlight-argparse .o, -.highlight-argparse-usage .o, -.highlight-argparse-help .o { - color: #ccced4; - font-weight: normal; -} - -/* ========================================================================== - Argparse Directive Highlighting (.. argparse:: output) - ========================================================================== */ - -/* - * These styles apply to the argparse directive output which uses custom - * nodes rendered by sphinx_argparse_neo. The directive adds highlight spans - * directly to the HTML output. - */ - -/* - * Usage Block (.argparse-usage) - * - * The usage block now has both .argparse-usage and .highlight-argparse-usage - * classes. The .highlight-argparse-usage selectors above already handle the - * token highlighting. These selectors provide fallback and ensure consistent - * styling. - */ - -/* Usage block container - match Pygments monokai background and code block styling */ -pre.argparse-usage { - background: var(--argparse-code-background); - font-size: var(--code-font-size); - padding: 0.625rem 0.875rem; - line-height: 1.5; - border-radius: 0.2rem; - scrollbar-color: var(--color-foreground-border) transparent; - scrollbar-width: thin; -} - -.argparse-usage .gh { - color: #61afef; - font-weight: bold; -} - -.argparse-usage .nt { - color: #56b6c2; - font-weight: normal; -} - -.argparse-usage .na { - color: #56b6c2; - font-weight: normal; -} - -.argparse-usage .nv { - color: #e5c07b; - font-style: italic; -} - -.argparse-usage .nl { - color: #c678dd; - font-weight: bold; -} - -.argparse-usage .nf { - color: #98c379; - font-weight: bold; -} - -.argparse-usage .no, -.argparse-usage .nc { - color: #98c379; -} - -.argparse-usage .o { - color: #ccced4; - font-weight: normal; -} - -.argparse-usage .p { - color: #ccced4; -} - -/* - * Argument Name (
) - * - * The argument names in the dl/dt structure are highlighted with - * semantic spans for options and metavars. - */ -.argparse-argument-name .nt { - color: #56b6c2; - font-weight: normal; -} - -.argparse-argument-name .na { - color: #56b6c2; - font-weight: normal; -} - -.argparse-argument-name .nv { - color: #e5c07b; - font-style: italic; -} - -.argparse-argument-name .nl { - color: #c678dd; - font-weight: bold; -} - -/* ========================================================================== - Argument Wrapper and Linking Styles - ========================================================================== */ - -/* - * Argparse-specific code background (monokai #272822) - * Uses a custom variable to avoid affecting Furo's --color-inline-code-background. - */ -:root { - --argparse-code-background: #272822; -} - -/* - * Background styling for argument names - subtle background like code.literal - * This provides visual weight and hierarchy for argument definitions. - */ -.argparse-argument-name { - background: var(--argparse-code-background); - border-radius: 0.2rem; - padding: 0.485rem 0.875rem; - font-family: var(--font-stack--monospace); - font-size: var(--code-font-size); - width: fit-content; - position: relative; -} - -/* - * Wrapper for linking - enables scroll-margin for fixed header navigation - * and :target pseudo-class for highlighting when linked. - */ -.argparse-argument-wrapper { - scroll-margin-top: 2.5rem; -} - -/* - * Headerlink anchor (¶) - hidden until hover - * Positioned outside the dt element to the right. - * Follows Sphinx documentation convention for linkable headings. - */ -.argparse-argument-name .headerlink { - visibility: hidden; - position: absolute; - right: -1.5rem; - top: 50%; - transform: translateY(-50%); - color: var(--color-foreground-secondary); - text-decoration: none; -} - -/* - * Show headerlink on hover or when targeted via URL fragment - */ -.argparse-argument-wrapper:hover .headerlink, -.argparse-argument-wrapper:target .headerlink { - visibility: visible; -} - -.argparse-argument-name .headerlink:hover:not(:visited) { - color: var(--color-foreground-primary); -} - -/* - * Light mode headerlink color overrides - * Needed because code block has dark background regardless of theme - */ -body[data-theme="light"] .argparse-argument-name .headerlink { - color: #9ca0a5; - - &:hover:not(:visited) { - color: #cfd0d0; - } -} - -@media (prefers-color-scheme: light) { - body:not([data-theme="dark"]) .argparse-argument-name .headerlink { - color: #9ca0a5; - - &:hover:not(:visited) { - color: #cfd0d0; - } - } -} - -/* - * Highlight when targeted via URL fragment - * Uses Furo's highlight-on-target color for consistency. - */ -.argparse-argument-wrapper:target .argparse-argument-name { - background-color: var(--color-highlight-on-target); -} - -/* - * Argument metadata definition list - * - * Renders metadata (Default, Type, Choices, Required) as a horizontal - * flexbox of key-value pairs and standalone tags. - */ -.argparse-argument-meta { - margin: 0.5rem 0 0 0; - padding: 0; - display: flex; - flex-wrap: wrap; - gap: 0.5rem 1rem; - align-items: center; -} - -.argparse-meta-item { - display: flex; - align-items: center; - gap: 0.25rem; -} - -.argparse-meta-key { - color: var(--color-foreground-secondary, #6c757d); - font-size: var(--code-font-size); -} - -.argparse-meta-key::after { - content: ":"; -} - -.argparse-meta-value .nv { - background: var(--argparse-code-background); - border-radius: 0.2rem; - padding: 0.1rem 0.3rem; - font-family: var(--font-stack--monospace); - font-size: var(--code-font-size); - color: #e5c07b; -} - -/* - * Meta tag (e.g., "Required") - follows Furo's guilabel pattern - * Uses semi-transparent amber background with border for visibility - * without the harshness of solid fills. Amber conveys "needs attention". - */ -.argparse-meta-tag { - background-color: #fef3c780; - border: 1px solid #fcd34d80; - color: var(--color-foreground-primary); - font-size: var(--code-font-size); - padding: 0.1rem 0.4rem; - border-radius: 0.2rem; - font-weight: 500; -} - -/* Dark mode: darker amber with adjusted border */ -body[data-theme="dark"] .argparse-meta-tag { - background-color: #78350f60; - border-color: #b4530980; -} - -@media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) .argparse-meta-tag { - background-color: #78350f60; - border-color: #b4530980; - } -} - -/* - * Help text description - * Adds spacing above for visual separation from argument name. - */ -.argparse-argument-help { - padding-block-start: 0.5rem; -} diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css deleted file mode 100644 index 00a15fdc22..0000000000 --- a/docs/_static/css/custom.css +++ /dev/null @@ -1,21 +0,0 @@ -.sidebar-tree p.indented-block { - padding: var(--sidebar-item-spacing-vertical) - var(--sidebar-item-spacing-horizontal) 0 - var(--sidebar-item-spacing-horizontal); - margin-bottom: 0; -} - -.sidebar-tree p.indented-block span.indent { - margin-left: var(--sidebar-item-spacing-horizontal); - display: block; -} - -.sidebar-tree p.indented-block .project-name { - font-size: var(--sidebar-item-font-size); - font-weight: bold; - margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); -} - -.sidebar-tree .active { - font-weight: bold; -} diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html deleted file mode 100644 index 2943238cf7..0000000000 --- a/docs/_templates/layout.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "!layout.html" %} -{%- block extrahead %} - {{ super() }} - {%- if theme_show_meta_manifest_tag == true %} - - {% endif -%} - {%- if theme_show_meta_og_tags == true %} - - - - - - - - - - - - - - - - {% endif -%} - {%- if theme_show_meta_app_icon_tags == true %} - - - - - - - - - - - - - - - - - - - - {% endif -%} -{% endblock %} diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html deleted file mode 100644 index 97420c1adf..0000000000 --- a/docs/_templates/sidebar/projects.html +++ /dev/null @@ -1,69 +0,0 @@ - - diff --git a/docs/about.md b/docs/about.md deleted file mode 100644 index 0d380728db..0000000000 --- a/docs/about.md +++ /dev/null @@ -1,104 +0,0 @@ -```{module} tmuxp - -``` - -(about)= - -# About - -tmuxp helps you manage tmux workspaces. - -Built on an object relational mapper for tmux. tmux users can reload common -workspaces from YAML, JSON and {py:obj}`dict` workspace files like -[tmuxinator] and [teamocil]. - -tmuxp is used by developers for tmux automation at great companies like -[Bugsnag], [Pragmatic Coders] and many others. - -To jump right in, see {ref}`quickstart` and {ref}`examples`. - -Interested in some kung-fu or joining the effort? {ref}`api` and -{ref}`developing`. - -[MIT-licensed]. Code on [github](http://github.com/tmux-python/tmuxp). - -[bugsnag]: https://blog.bugsnag.com/benefits-of-using-tmux/ -[pragmatic coders]: http://pragmaticcoders.com/blog/tmuxp-preconfigured-sessions/ - -## Compared to tmuxinator / teamocil - -### Similarities - -**Load sessions** Loads tmux sessions from config - -**YAML** Supports YAML format - -**Inlining / shorthand configuration** All three support short-hand and -simplified markup for panes that have one command. - -**Maturity and stability** As of 2016, all three are considered stable, -well tested and adopted. - -### Missing - -**Version support** tmuxp only supports `tmux >= 3.2`. Teamocil and -tmuxinator may have support for earlier versions. - -### Differences - -**Programming Language** python. teamocil and tmuxinator use ruby. - -**Workspace building process** teamocil and tmuxinator process configs -directly shell commands. tmuxp processes configuration via ORM layer. - -## Additional Features - -**CLI** tmuxp's CLI can attach and kill sessions with tab-completion -support. See {ref}`commands`. - -**Import config** import configs from Teamocil / Tmuxinator [^id4]. See -{ref}`cli-import`. - -**Session freezing** Supports session freezing into YAML and JSON -format [^id4]. See {ref}`cli-freeze`. - -**JSON config** JSON config support. See {ref}`Examples`. - -**ORM-based API** via [libtmux] - Utilizes tmux's unique IDs for -panes, windows and sessions to create an object relational view of the tmux -{class}`~libtmux.Server`, its {class}`~libtmux.Session`, -{class}`~libtmux.Window`, and {class}`~libtmux.Pane`. -See {ref}`libtmux's internals `. - -**Conversion** `$ tmuxp convert ` can convert files to and -from JSON and YAML. - -[^id4]: On freezing - - While freezing and importing sessions is a great way to save time, - tweaking will probably be required - There is no substitute to a - config made with love. - -[libtmux]: https://libtmux.git-pull.com - -## Minor tweaks - -- Unit tests against live tmux version to test statefulness of tmux - sessions, windows and panes. See {ref}`gh-actions`. -- Load + switch to new session from inside tmux. -- Resume session if config loaded. -- Pre-commands virtualenv / rvm / any other commands. -- Load config from anywhere `$ tmuxp load /full/file/path.json`. -- Load config `.tmuxp.yaml` or `.tmuxp.json` from current working - directory with `$ tmuxp load .`. -- `$ tmuxp -2`, `$ tmuxp -8` for forcing term colors a la - {term}`tmux(1)`. -- `$ tmuxp -L`, `$ tmuxp -S` for sockets and - `$ tmuxp -f ` for config file. - -[attempt at 1.7 test]: https://travis-ci.org/tmux-python/tmuxp/jobs/12348263 -[mit-licensed]: http://opensource.org/licenses/MIT -[tmuxinator]: https://github.com/aziz/tmuxinator -[teamocil]: https://github.com/remiprev/teamocil -[erb]: http://ruby-doc.org/stdlib-2.0.0/libdoc/erb/rdoc/ERB.html -[edit this page]: https://github.com/tmux-python/tmuxp/edit/master/doc/about.rst diff --git a/docs/about_tmux.md b/docs/about_tmux.md index 5f10e2275e..2db7b3c4d7 100644 --- a/docs/about_tmux.md +++ b/docs/about_tmux.md @@ -5,6 +5,7 @@ :::{figure} /\_static/tao-tmux-screenshot.png :scale: 60% :align: center +:loading: lazy ISC-licensed terminal multiplexer. diff --git a/docs/cli/completion.md b/docs/cli/completion.md index ee7f1f3fa1..410aed4ee3 100644 --- a/docs/cli/completion.md +++ b/docs/cli/completion.md @@ -32,8 +32,8 @@ $ uvx shtab --help :::{tab} bash -```bash -shtab --shell=bash -u tmuxp.cli.create_parser \ +```console +$ shtab --shell=bash -u tmuxp.cli.create_parser \ | sudo tee "$BASH_COMPLETION_COMPAT_DIR"/TMUXP ``` @@ -41,8 +41,8 @@ shtab --shell=bash -u tmuxp.cli.create_parser \ :::{tab} zsh -```zsh -shtab --shell=zsh -u tmuxp.cli.create_parser \ +```console +$ shtab --shell=zsh -u tmuxp.cli.create_parser \ | sudo tee /usr/local/share/zsh/site-functions/_TMUXP ``` @@ -50,8 +50,8 @@ shtab --shell=zsh -u tmuxp.cli.create_parser \ :::{tab} tcsh -```zsh -shtab --shell=tcsh -u tmuxp.cli.create_parser \ +```console +$ shtab --shell=tcsh -u tmuxp.cli.create_parser \ | sudo tee /etc/profile.d/TMUXP.completion.csh ``` diff --git a/docs/cli/exit-codes.md b/docs/cli/exit-codes.md new file mode 100644 index 0000000000..e8a80e892d --- /dev/null +++ b/docs/cli/exit-codes.md @@ -0,0 +1,30 @@ +(cli-exit-codes)= + +# Exit Codes + +tmuxp uses standard exit codes for scripting and automation. + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | General error (config validation, tmux command failure) | +| `2` | Usage error (invalid arguments, missing required options) | + +## Usage in Scripts + +```bash +#!/bin/bash +tmuxp load my-workspace.yaml +if [ $? -ne 0 ]; then + echo "Failed to load workspace" + exit 1 +fi +``` + +```bash +#!/bin/bash +tmuxp load -d my-workspace.yaml || { + echo "tmuxp failed with exit code $?" + exit 1 +} +``` diff --git a/docs/cli/index.md b/docs/cli/index.md index cb8bf94b3b..fd38b681ea 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -2,7 +2,48 @@ (commands)= -# Commands +# CLI Reference + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} tmuxp load +:link: load +:link-type: doc +Load tmux sessions from workspace configs. +::: + +:::{grid-item-card} tmuxp shell +:link: shell +:link-type: doc +Interactive Python shell with tmux context. +::: + +:::{grid-item-card} tmuxp freeze +:link: freeze +:link-type: doc +Export running sessions to config files. +::: + +:::{grid-item-card} tmuxp convert +:link: convert +:link-type: doc +Convert between YAML and JSON formats. +::: + +:::{grid-item-card} Exit Codes +:link: exit-codes +:link-type: doc +Exit codes for scripting and automation. +::: + +:::{grid-item-card} Recipes +:link: recipes +:link-type: doc +Copy-pasteable command invocations. +::: + +:::: ```{toctree} :caption: General commands @@ -38,6 +79,14 @@ debug-info completion ``` +```{toctree} +:caption: Reference +:maxdepth: 1 + +exit-codes +recipes +``` + (cli-main)= (tmuxp-main)= diff --git a/docs/cli/load.md b/docs/cli/load.md index a362888e15..8be9178f29 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -152,3 +152,104 @@ $ tmuxp load [filename] --log-file [log_filename] ```console $ tmuxp --log-level [LEVEL] load [filename] --log-file [log_filename] ``` + +## Progress display + +When loading a workspace, tmuxp shows an animated spinner with build progress. The spinner updates as windows and panes are created, giving real-time feedback during session builds. + +### Presets + +Five built-in presets control the spinner format: + +| Preset | Format | +|--------|--------| +| `default` | `Loading workspace: {session} {bar} {progress} {window}` | +| `minimal` | `Loading workspace: {session} [{window_progress}]` | +| `window` | `Loading workspace: {session} {window_bar} {window_progress_rel}` | +| `pane` | `Loading workspace: {session} {pane_bar} {session_pane_progress}` | +| `verbose` | `Loading workspace: {session} [window {window_index} of {window_total} · pane {pane_index} of {pane_total}] {window}` | + +Select a preset with `--progress-format`: + +```console +$ tmuxp load --progress-format minimal myproject +``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS_FORMAT=verbose tmuxp load myproject +``` + +### Custom format tokens + +Use a custom format string with any of the available tokens: + +| Token | Description | +|-------|-------------| +| `{session}` | Session name | +| `{window}` | Current window name | +| `{window_index}` | Current window number (1-based) | +| `{window_total}` | Total number of windows | +| `{window_progress}` | Window fraction (e.g. `1/3`) | +| `{window_progress_rel}` | Completed windows fraction (e.g. `1/3`) | +| `{windows_done}` | Number of completed windows | +| `{windows_remaining}` | Number of remaining windows | +| `{pane_index}` | Current pane number in the window | +| `{pane_total}` | Total panes in the current window | +| `{pane_progress}` | Pane fraction (e.g. `2/4`) | +| `{progress}` | Combined progress (e.g. `1/3 win · 2/4 pane`) | +| `{session_pane_progress}` | Panes completed across the session (e.g. `5/10`) | +| `{overall_percent}` | Pane-based completion percentage (0–100) | +| `{bar}` | Composite progress bar | +| `{pane_bar}` | Pane-based progress bar | +| `{window_bar}` | Window-based progress bar | +| `{status_icon}` | Status icon (⏸ during before_script) | + +Example: + +```console +$ tmuxp load --progress-format "{session} {bar} {overall_percent}%" myproject +``` + +### Panel lines + +The spinner shows script output in a panel below the spinner line. Control the panel height with `--progress-lines`: + +Hide the panel entirely (script output goes to stdout): + +```console +$ tmuxp load --progress-lines 0 myproject +``` + +Show unlimited lines (capped to terminal height): + +```console +$ tmuxp load --progress-lines -1 myproject +``` + +Set a custom height (default is 3): + +```console +$ tmuxp load --progress-lines 5 myproject +``` + +### Disabling progress + +Disable the animated spinner entirely: + +```console +$ tmuxp load --no-progress myproject +``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +When progress is disabled, logging flows normally to the terminal and no spinner is rendered. + +### Before-script behavior + +During `before_script` execution, the progress bar shows a marching animation and a ⏸ status icon, indicating that tmuxp is waiting for the script to finish before continuing with pane creation. diff --git a/docs/cli/recipes.md b/docs/cli/recipes.md new file mode 100644 index 0000000000..fdbdd7dad9 --- /dev/null +++ b/docs/cli/recipes.md @@ -0,0 +1,77 @@ +(cli-recipes)= + +# Recipes + +Copy-pasteable command invocations for common tasks. + +## Load a workspace + +```console +$ tmuxp load my-workspace.yaml +``` + +## Load in detached mode + +```console +$ tmuxp load -d my-workspace.yaml +``` + +## Load from a project directory + +```console +$ tmuxp load . +``` + +## Freeze a running session + +```console +$ tmuxp freeze my-session +``` + +## Convert YAML to JSON + +```console +$ tmuxp convert my-workspace.yaml +``` + +## Convert JSON to YAML + +```console +$ tmuxp convert my-workspace.json +``` + +## List available workspaces + +```console +$ tmuxp ls +``` + +## Search workspaces + +```console +$ tmuxp search my-project +``` + +## Edit a workspace config + +```console +$ tmuxp edit my-workspace +``` + +## Collect debug info + +```console +$ tmuxp debug-info +``` + +## Shell with tmux context + +```console +$ tmuxp shell +``` + +Access libtmux objects directly: + +```console +$ tmuxp shell --best --command 'print(server.sessions)' +``` diff --git a/docs/cli/shell.md b/docs/cli/shell.md index de47b9b9e1..586c305e54 100644 --- a/docs/cli/shell.md +++ b/docs/cli/shell.md @@ -23,7 +23,9 @@ $ tmuxp shell -c 'python code' ``` ```{image} ../_static/tmuxp-shell.gif -:width: 100% +:width: 878 +:height: 109 +:loading: lazy ``` ## Interactive usage diff --git a/docs/conf.py b/docs/conf.py index 9a1f957e6f..c2afba8c45 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,19 +1,9 @@ -# flake8: NOQA: E501 """Sphinx documentation configuration for tmuxp.""" from __future__ import annotations -import contextlib -import inspect import pathlib import sys -import typing as t -from os.path import relpath - -import tmuxp - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx # Get the project root dir, which is the parent dir of this cwd = pathlib.Path(__file__).parent @@ -21,215 +11,38 @@ src_root = project_root / "src" sys.path.insert(0, str(src_root)) -sys.path.insert(0, str(cwd / "_ext")) +sys.path.insert(0, str(cwd / "_ext")) # for local aafig extension # package data about: dict[str, str] = {} with (src_root / "tmuxp" / "__about__.py").open() as fp: exec(fp.read(), about) -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.intersphinx", - "sphinx_autodoc_typehints", - "sphinx.ext.todo", - "sphinx.ext.napoleon", - "sphinx.ext.linkcode", - "aafig", - "argparse_exemplar", # Custom sphinx-argparse replacement - "sphinx_inline_tabs", - "sphinx_copybutton", - "sphinxext.opengraph", - "sphinxext.rediraffe", - "myst_parser", - "linkify_issues", -] - -myst_enable_extensions = [ - "colon_fence", - "substitution", - "replacements", - "strikethrough", - "linkify", -] - -templates_path = ["_templates"] - -source_suffix = {".rst": "restructuredtext", ".md": "markdown"} - -master_doc = "index" - -project = about["__title__"] -project_copyright = about["__copyright__"] - -version = "{}".format(".".join(about["__version__"].split("."))[:2]) -release = "{}".format(about["__version__"]) - -exclude_patterns = ["_build"] - -pygments_style = "monokai" -pygments_dark_style = "monokai" - -html_css_files = ["css/custom.css", "css/argparse-highlight.css"] -html_extra_path = ["manifest.json"] -html_static_path = ["_static"] -html_favicon = "_static/favicon.ico" -html_theme = "furo" -html_theme_path: list[str] = [] -html_theme_options: dict[str, str | list[dict[str, str]]] = { - "light_logo": "img/tmuxp.svg", - "dark_logo": "img/tmuxp.svg", - "footer_icons": [ - { - "name": "GitHub", - "url": about["__github__"], - "html": """ - - - - """, - "class": "", - }, - ], - "source_repository": f"{about['__github__']}/", - "source_branch": "master", - "source_directory": "docs/", -} -html_sidebars = { - "**": [ - "sidebar/scroll-start.html", - "sidebar/brand.html", - "sidebar/search.html", - "sidebar/navigation.html", - "sidebar/projects.html", - "sidebar/scroll-end.html", - ], -} - -# linkify_issues -issue_url_tpl = about["__github__"] + "/issues/{issue_id}" - -# sphinx.ext.autodoc -toc_object_entries_show_parents = "hide" -autodoc_default_options = { - "undoc-members": True, - "members": True, - "private-members": True, - "show-inheritance": True, - "member-order": "bysource", -} - -# sphinx-autodoc-typehints -# Suppress warnings for forward references that can't be resolved -# (types in TYPE_CHECKING blocks used for circular import avoidance) -suppress_warnings = [ - "sphinx_autodoc_typehints.forward_reference", -] - -# sphinxext.opengraph -ogp_site_url = about["__docs__"] -ogp_image = "_static/img/icons/icon-192x192.png" -ogp_site_name = about["__title__"] - -# sphinx-copybutton -copybutton_prompt_text = ( - r">>> |\.\.\. |> |\$ |\# | In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " +from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config # noqa: E402 + +import tmuxp # noqa: E402 + +conf = merge_sphinx_config( + project=about["__title__"], + version=about["__version__"], + copyright=about["__copyright__"], + source_repository=f"{about['__github__']}/", + docs_url=about["__docs__"], + source_branch="master", + light_logo="img/tmuxp.svg", + dark_logo="img/tmuxp.svg", + extra_extensions=["aafig", "sphinx_argparse_neo.exemplar"], + intersphinx_mapping={ + "python": ("https://docs.python.org/", None), + "libtmux": ("https://libtmux.git-pull.com/", None), + }, + linkcode_resolve=make_linkcode_resolve(tmuxp, about["__github__"]), + # tmuxp-specific overrides + theme_options={"mask_icon": "/_static/img/tmuxp.svg"}, + html_extra_path=["manifest.json"], + html_favicon="_static/favicon.ico", + aafig_format={"latex": "pdf", "html": "gif"}, + aafig_default_options={"scale": 0.75, "aspect": 0.5, "proportional": True}, + rediraffe_redirects="redirects.txt", ) -copybutton_prompt_is_regexp = True -copybutton_remove_prompts = True - -# sphinxext-rediraffe -rediraffe_redirects = "redirects.txt" -rediraffe_branch = "master~1" - -# aafig format, try to get working with pdf -aafig_format = {"latex": "pdf", "html": "gif"} -aafig_default_options = {"scale": 0.75, "aspect": 0.5, "proportional": True} - -intersphinx_mapping = { - "python": ("https://docs.python.org/", None), - "libtmux": ("https://libtmux.git-pull.com/", None), -} - - -def linkcode_resolve(domain: str, info: dict[str, str]) -> None | str: - """ - Determine the URL corresponding to Python object. - - Notes - ----- - From https://github.com/numpy/numpy/blob/v1.15.1/doc/source/conf.py, 7c49cfa - on Jul 31. License BSD-3. https://github.com/numpy/numpy/blob/v1.15.1/LICENSE.txt - """ - if domain != "py": - return None - - modname = info["module"] - fullname = info["fullname"] - - submod = sys.modules.get(modname) - if submod is None: - return None - - obj = submod - for part in fullname.split("."): - try: - obj = getattr(obj, part) - except Exception: # NOQA: PERF203 - return None - - # strip decorators, which would resolve to the source of the decorator - # possibly an upstream bug in getsourcefile, bpo-1764286 - try: - unwrap = inspect.unwrap - except AttributeError: - pass - else: - if callable(obj): - obj = unwrap(obj) - - try: - fn = inspect.getsourcefile(obj) - except Exception: - fn = None - if not fn: - return None - - try: - source, lineno = inspect.getsourcelines(obj) - except Exception: - lineno = None - - linespec = f"#L{lineno}-L{lineno + len(source) - 1}" if lineno else "" - - fn = relpath(fn, start=pathlib.Path(tmuxp.__file__).parent) - - if "dev" in about["__version__"]: - return "{}/blob/master/{}/{}/{}{}".format( - about["__github__"], - "src", - about["__package_name__"], - fn, - linespec, - ) - return "{}/blob/v{}/{}/{}/{}{}".format( - about["__github__"], - about["__version__"], - "src", - about["__package_name__"], - fn, - linespec, - ) - - -def remove_tabs_js(app: Sphinx, exc: Exception) -> None: - """Fix for sphinx-inline-tabs#18.""" - if app.builder.format == "html" and not exc: - tabs_js = pathlib.Path(app.builder.outdir) / "_static" / "tabs.js" - with contextlib.suppress(FileNotFoundError): - tabs_js.unlink() # When python 3.7 deprecated, use missing_ok=True - - -def setup(app: Sphinx) -> None: - """Sphinx setup hook.""" - app.connect("build-finished", remove_tabs_js) +globals().update(conf) diff --git a/docs/configuration/environmental-variables.md b/docs/configuration/environmental-variables.md index d485da4574..73c0d1e373 100644 --- a/docs/configuration/environmental-variables.md +++ b/docs/configuration/environmental-variables.md @@ -24,3 +24,54 @@ building sessions. For this case you can override it here. ```console $ env LIBTMUX_TMUX_FORMAT_SEPARATOR='__SEP__' tmuxp load [session] ``` + +(TMUXP_PROGRESS)= + +## `TMUXP_PROGRESS` + +Master on/off switch for the animated progress spinner during `tmuxp load`. +Defaults to `1` (enabled). Set to `0` to disable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +Equivalent to the `--no-progress` CLI flag. + +(TMUXP_PROGRESS_FORMAT)= + +## `TMUXP_PROGRESS_FORMAT` + +Set the spinner line format. Accepts a preset name (`default`, `minimal`, `window`, `pane`, `verbose`) or a custom format string with tokens like `{session}`, `{bar}`, `{progress}`: + +```console +$ TMUXP_PROGRESS_FORMAT=minimal tmuxp load myproject +``` + +Custom format example: + +```console +$ TMUXP_PROGRESS_FORMAT="{session} {bar} {overall_percent}%" tmuxp load myproject +``` + +Equivalent to the `--progress-format` CLI flag. + +(TMUXP_PROGRESS_LINES)= + +## `TMUXP_PROGRESS_LINES` + +Number of script-output lines shown in the spinner panel. Defaults to `3`. + +Set to `0` to hide the panel entirely (script output goes to stdout): + +```console +$ TMUXP_PROGRESS_LINES=0 tmuxp load myproject +``` + +Set to `-1` for unlimited lines (capped to terminal height): + +```console +$ TMUXP_PROGRESS_LINES=-1 tmuxp load myproject +``` + +Equivalent to the `--progress-lines` CLI flag. diff --git a/docs/configuration/index.md b/docs/configuration/index.md index aa59b017ee..619cb62710 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -6,6 +6,29 @@ # Workspace files +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Top-level Options +:link: top-level +:link-type: doc +Session and window configuration keys. +::: + +:::{grid-item-card} Environment Variables +:link: environmental-variables +:link-type: doc +TMUXP_CONFIGDIR and other env vars. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +Sample workspace configurations. +::: + +:::: + tmuxp loads your terminal workspace into tmux using workspace files. The workspace file can be JSON or YAML. It's declarative style resembles tmux's object hierarchy: session, window and panes. @@ -167,10 +190,33 @@ $ tmuxp load /opt/myapp ## Reference and usage +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Top-level Options +:link: top-level +:link-type: doc +Session and window configuration keys. +::: + +:::{grid-item-card} Environment Variables +:link: environmental-variables +:link-type: doc +TMUXP_CONFIGDIR and other env vars. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +Sample workspace configurations. +::: + +:::: + ```{toctree} +:hidden: top-level environmental-variables examples - ``` diff --git a/docs/index.md b/docs/index.md index 14b69dedb0..64308bd782 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,41 +1,106 @@ (index)= -```{include} ../README.md -:end-before: ``` diff --git a/docs/api/internals/colors.md b/docs/internals/api/_internal/colors.md similarity index 100% rename from docs/api/internals/colors.md rename to docs/internals/api/_internal/colors.md diff --git a/docs/api/internals/config_reader.md b/docs/internals/api/_internal/config_reader.md similarity index 100% rename from docs/api/internals/config_reader.md rename to docs/internals/api/_internal/config_reader.md diff --git a/docs/api/internals/index.md b/docs/internals/api/_internal/index.md similarity index 90% rename from docs/api/internals/index.md rename to docs/internals/api/_internal/index.md index b96fc8657b..391a80b60f 100644 --- a/docs/api/internals/index.md +++ b/docs/internals/api/_internal/index.md @@ -1,6 +1,6 @@ -(internals)= +(api-internal)= -# Internals +# Internal Modules :::{warning} Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! diff --git a/docs/api/internals/private_path.md b/docs/internals/api/_internal/private_path.md similarity index 100% rename from docs/api/internals/private_path.md rename to docs/internals/api/_internal/private_path.md diff --git a/docs/api/internals/types.md b/docs/internals/api/_internal/types.md similarity index 100% rename from docs/api/internals/types.md rename to docs/internals/api/_internal/types.md diff --git a/docs/api/cli/convert.md b/docs/internals/api/cli/convert.md similarity index 100% rename from docs/api/cli/convert.md rename to docs/internals/api/cli/convert.md diff --git a/docs/api/cli/debug_info.md b/docs/internals/api/cli/debug_info.md similarity index 100% rename from docs/api/cli/debug_info.md rename to docs/internals/api/cli/debug_info.md diff --git a/docs/api/cli/edit.md b/docs/internals/api/cli/edit.md similarity index 100% rename from docs/api/cli/edit.md rename to docs/internals/api/cli/edit.md diff --git a/docs/api/cli/freeze.md b/docs/internals/api/cli/freeze.md similarity index 100% rename from docs/api/cli/freeze.md rename to docs/internals/api/cli/freeze.md diff --git a/docs/api/cli/import_config.md b/docs/internals/api/cli/import_config.md similarity index 100% rename from docs/api/cli/import_config.md rename to docs/internals/api/cli/import_config.md diff --git a/docs/api/cli/index.md b/docs/internals/api/cli/index.md similarity index 98% rename from docs/api/cli/index.md rename to docs/internals/api/cli/index.md index 9289503905..1381fbc90f 100644 --- a/docs/api/cli/index.md +++ b/docs/internals/api/cli/index.md @@ -16,6 +16,7 @@ freeze import_config load ls +progress search shell utils diff --git a/docs/api/cli/load.md b/docs/internals/api/cli/load.md similarity index 100% rename from docs/api/cli/load.md rename to docs/internals/api/cli/load.md diff --git a/docs/api/cli/ls.md b/docs/internals/api/cli/ls.md similarity index 100% rename from docs/api/cli/ls.md rename to docs/internals/api/cli/ls.md diff --git a/docs/internals/api/cli/progress.md b/docs/internals/api/cli/progress.md new file mode 100644 index 0000000000..3b092349cf --- /dev/null +++ b/docs/internals/api/cli/progress.md @@ -0,0 +1,8 @@ +# tmuxp progress - `tmuxp.cli._progress` + +```{eval-rst} +.. automodule:: tmuxp.cli._progress + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/api/cli/search.md b/docs/internals/api/cli/search.md similarity index 100% rename from docs/api/cli/search.md rename to docs/internals/api/cli/search.md diff --git a/docs/api/cli/shell.md b/docs/internals/api/cli/shell.md similarity index 100% rename from docs/api/cli/shell.md rename to docs/internals/api/cli/shell.md diff --git a/docs/api/cli/utils.md b/docs/internals/api/cli/utils.md similarity index 100% rename from docs/api/cli/utils.md rename to docs/internals/api/cli/utils.md diff --git a/docs/api/exc.md b/docs/internals/api/exc.md similarity index 100% rename from docs/api/exc.md rename to docs/internals/api/exc.md diff --git a/docs/api/index.md b/docs/internals/api/index.md similarity index 94% rename from docs/api/index.md rename to docs/internals/api/index.md index 89ec3d508b..2debbe3f26 100644 --- a/docs/api/index.md +++ b/docs/internals/api/index.md @@ -8,7 +8,7 @@ tmux via python API calls. ::: ```{toctree} -internals/index +_internal/index cli/index workspace/index exc diff --git a/docs/api/log.md b/docs/internals/api/log.md similarity index 100% rename from docs/api/log.md rename to docs/internals/api/log.md diff --git a/docs/api/plugin.md b/docs/internals/api/plugin.md similarity index 100% rename from docs/api/plugin.md rename to docs/internals/api/plugin.md diff --git a/docs/api/shell.md b/docs/internals/api/shell.md similarity index 100% rename from docs/api/shell.md rename to docs/internals/api/shell.md diff --git a/docs/api/types.md b/docs/internals/api/types.md similarity index 100% rename from docs/api/types.md rename to docs/internals/api/types.md diff --git a/docs/api/util.md b/docs/internals/api/util.md similarity index 100% rename from docs/api/util.md rename to docs/internals/api/util.md diff --git a/docs/api/workspace/builder.md b/docs/internals/api/workspace/builder.md similarity index 100% rename from docs/api/workspace/builder.md rename to docs/internals/api/workspace/builder.md diff --git a/docs/api/workspace/constants.md b/docs/internals/api/workspace/constants.md similarity index 100% rename from docs/api/workspace/constants.md rename to docs/internals/api/workspace/constants.md diff --git a/docs/api/workspace/finders.md b/docs/internals/api/workspace/finders.md similarity index 100% rename from docs/api/workspace/finders.md rename to docs/internals/api/workspace/finders.md diff --git a/docs/api/workspace/freezer.md b/docs/internals/api/workspace/freezer.md similarity index 100% rename from docs/api/workspace/freezer.md rename to docs/internals/api/workspace/freezer.md diff --git a/docs/api/workspace/importers.md b/docs/internals/api/workspace/importers.md similarity index 100% rename from docs/api/workspace/importers.md rename to docs/internals/api/workspace/importers.md diff --git a/docs/api/workspace/index.md b/docs/internals/api/workspace/index.md similarity index 100% rename from docs/api/workspace/index.md rename to docs/internals/api/workspace/index.md diff --git a/docs/api/workspace/loader.md b/docs/internals/api/workspace/loader.md similarity index 100% rename from docs/api/workspace/loader.md rename to docs/internals/api/workspace/loader.md diff --git a/docs/api/workspace/validation.md b/docs/internals/api/workspace/validation.md similarity index 100% rename from docs/api/workspace/validation.md rename to docs/internals/api/workspace/validation.md diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md new file mode 100644 index 0000000000..7a104dbfea --- /dev/null +++ b/docs/internals/architecture.md @@ -0,0 +1,39 @@ +# Architecture + +How the tmuxp CLI dispatches commands to the underlying library. + +## Request Flow + +``` +tmuxp CLI (argparse) + │ + ├── tmuxp load ──→ workspace.loader ──→ workspace.builder ──→ libtmux + ├── tmuxp freeze ──→ workspace.freezer ──→ libtmux + ├── tmuxp convert ──→ _internal.config_reader + ├── tmuxp shell ──→ libtmux (interactive) + └── tmuxp ls/search ──→ workspace.finders +``` + +## Key Components + +### CLI Layer (`tmuxp.cli`) + +The CLI uses Python's `argparse` with a custom formatter ({mod}`tmuxp.cli._formatter`). +Each subcommand lives in its own module under `tmuxp.cli`. + +The entry point is `tmuxp.cli.cli()`, registered as a console script in `pyproject.toml`. + +### Workspace Layer (`tmuxp.workspace`) + +The workspace layer handles configuration lifecycle: + +1. **Finding**: {mod}`tmuxp.workspace.finders` locates config files +2. **Loading**: {mod}`tmuxp.workspace.loader` reads and validates configs +3. **Building**: {mod}`tmuxp.workspace.builder` creates tmux sessions via libtmux +4. **Freezing**: {mod}`tmuxp.workspace.freezer` exports running sessions + +### Library Layer (libtmux) + +tmuxp delegates all tmux operations to [libtmux](https://libtmux.git-pull.com/). +The `WorkspaceBuilder` creates libtmux `Server`, `Session`, `Window`, and `Pane` +objects to construct the requested workspace. diff --git a/docs/internals/index.md b/docs/internals/index.md new file mode 100644 index 0000000000..82a8c121c0 --- /dev/null +++ b/docs/internals/index.md @@ -0,0 +1,36 @@ +(internals)= + +# Internals + +```{warning} +Everything in this section is **internal implementation detail**. There is +no stability guarantee. Interfaces may change or be removed without notice +between any release. + +If you are building an application with tmuxp, use the [CLI](../cli/index.md) +or refer to the [libtmux API](https://libtmux.git-pull.com/api/). +``` + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Architecture +:link: architecture +:link-type: doc +How the CLI dispatches to the workspace builder and libtmux. +::: + +:::{grid-item-card} Python API +:link: api/index +:link-type: doc +Internal module reference for contributors and plugin authors. +::: + +:::: + +```{toctree} +:hidden: + +architecture +api/index +``` diff --git a/docs/justfile b/docs/justfile index 171766555a..afeec6b509 100644 --- a/docs/justfile +++ b/docs/justfile @@ -27,7 +27,7 @@ default: # Build HTML documentation [group: 'build'] html: - {{ sphinxbuild }} -b html {{ allsphinxopts }} {{ builddir }}/html + {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/html @echo "" @echo "Build finished. The HTML pages are in {{ builddir }}/html." @@ -202,9 +202,9 @@ dev: # Start sphinx-autobuild server [group: 'dev'] start: - uv run sphinx-autobuild "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} + uv run sphinx-autobuild -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} # Design mode: watch static files and disable incremental builds [group: 'dev'] design: - uv run sphinx-autobuild "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} --watch "." -a + uv run sphinx-autobuild -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} --watch "." -a diff --git a/docs/project/code-style.md b/docs/project/code-style.md new file mode 100644 index 0000000000..1ea71dc8c3 --- /dev/null +++ b/docs/project/code-style.md @@ -0,0 +1,32 @@ +# Code Style + +## Formatting + +tmuxp uses [ruff](https://github.com/astral-sh/ruff) for both linting and formatting. + +```console +$ uv run ruff format . +``` + +```console +$ uv run ruff check . --fix --show-fixes +``` + +## Type Checking + +Strict [mypy](https://mypy-lang.org/) is enforced. + +```console +$ uv run mypy +``` + +## Docstrings + +All public functions and methods use NumPy-style docstrings. + +## Imports + +- Standard library: namespace imports (`import pathlib`, not `from pathlib import Path`) + - Exception: `from dataclasses import dataclass, field` +- Typing: `import typing as t`, access via `t.Optional`, `t.NamedTuple`, etc. +- All files: `from __future__ import annotations` diff --git a/docs/developing.md b/docs/project/contributing.md similarity index 98% rename from docs/developing.md rename to docs/project/contributing.md index 9f2e221cfd..cceb9eb61e 100644 --- a/docs/developing.md +++ b/docs/project/contributing.md @@ -175,13 +175,15 @@ $ env PYTEST_ADDOPTS="tests/workspace/test_builder.py" uv run make start Drop into `test_automatic_rename_option()` in `tests/workspace/test_builder.py`: ```console -$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py" uv run make start +$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py" \ + uv run make start ``` Drop into `test_automatic_rename_option()` in `tests/workspace/test_builder.py` and stop on first error: ```console -$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py::test_automatic_rename_option" uv run make start +$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py::test_automatic_rename_option" \ + uv run make start ``` Drop into `pdb` on first error: @@ -318,8 +320,10 @@ $ make SPHINXBUILD='uv run sphinx-build' watch ## tmuxp developer config ```{image} _static/tmuxp-dev-screenshot.png +:width: 1030 +:height: 605 :align: center - +:loading: lazy ``` After you {ref}`install-dev-env`, when inside the tmuxp checkout: diff --git a/docs/project/index.md b/docs/project/index.md new file mode 100644 index 0000000000..2b60711f5c --- /dev/null +++ b/docs/project/index.md @@ -0,0 +1,36 @@ +(project)= + +# Project + +Information for contributors and maintainers. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Contributing +:link: contributing +:link-type: doc +Development setup, running tests, submitting PRs. +::: + +:::{grid-item-card} Code Style +:link: code-style +:link-type: doc +Ruff, mypy, NumPy docstrings, import conventions. +::: + +:::{grid-item-card} Releasing +:link: releasing +:link-type: doc +Release checklist and version policy. +::: + +:::: + +```{toctree} +:hidden: + +contributing +code-style +releasing +``` diff --git a/docs/project/releasing.md b/docs/project/releasing.md new file mode 100644 index 0000000000..37b31aa3a7 --- /dev/null +++ b/docs/project/releasing.md @@ -0,0 +1,50 @@ +# Releasing + +## Release Process + +Releases are triggered by git tags and published to PyPI via OIDC trusted publishing. + +1. Update `CHANGES` with the release notes + +2. Bump version in `src/tmuxp/__about__.py` + +3. Commit: + + ```console + $ git commit -m "tmuxp " + ``` + +4. Tag: + + ```console + $ git tag v + ``` + +5. Push: + + ```console + $ git push && git push --tags + ``` + +6. CI builds and publishes to PyPI automatically via trusted publishing + +## Changelog Format + +The `CHANGES` file uses this format: + +```text +tmuxp () +------------------------ + +### What's new + +- Description of feature (#issue) + +### Bug fixes + +- Description of fix (#issue) + +### Breaking changes + +- Description of break, migration path (#issue) +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index 66d2cb860b..be9e1bdd10 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -1,5 +1,40 @@ -"cli.md" "commands/index.md" -"api.md" "api/index.md" +# "cli.md" → commands/index.md → cli/index.md: not needed with dirhtml (same output path) +# "api.md" → api/index.md: not needed with dirhtml (same output path as api/index.md redirect) "examples.md" "configuration/examples.md" "plugin_system.md" "plugins/index.md" "commands/index.md" "cli/index.md" +"api/index.md" "internals/api/index.md" +"api/exc.md" "internals/api/exc.md" +"api/log.md" "internals/api/log.md" +"api/plugin.md" "internals/api/plugin.md" +"api/shell.md" "internals/api/shell.md" +"api/types.md" "internals/api/types.md" +"api/util.md" "internals/api/util.md" +"api/cli/index.md" "internals/api/cli/index.md" +"api/cli/convert.md" "internals/api/cli/convert.md" +"api/cli/debug_info.md" "internals/api/cli/debug_info.md" +"api/cli/edit.md" "internals/api/cli/edit.md" +"api/cli/freeze.md" "internals/api/cli/freeze.md" +"api/cli/import_config.md" "internals/api/cli/import_config.md" +"api/cli/load.md" "internals/api/cli/load.md" +"api/cli/ls.md" "internals/api/cli/ls.md" +"api/cli/progress.md" "internals/api/cli/progress.md" +"api/cli/search.md" "internals/api/cli/search.md" +"api/cli/shell.md" "internals/api/cli/shell.md" +"api/cli/utils.md" "internals/api/cli/utils.md" +"api/workspace/index.md" "internals/api/workspace/index.md" +"api/workspace/builder.md" "internals/api/workspace/builder.md" +"api/workspace/constants.md" "internals/api/workspace/constants.md" +"api/workspace/finders.md" "internals/api/workspace/finders.md" +"api/workspace/freezer.md" "internals/api/workspace/freezer.md" +"api/workspace/importers.md" "internals/api/workspace/importers.md" +"api/workspace/loader.md" "internals/api/workspace/loader.md" +"api/workspace/validation.md" "internals/api/workspace/validation.md" +"api/internals/index.md" "internals/api/_internal/index.md" +"api/internals/colors.md" "internals/api/_internal/colors.md" +"api/internals/config_reader.md" "internals/api/_internal/config_reader.md" +"api/internals/private_path.md" "internals/api/_internal/private_path.md" +"api/internals/types.md" "internals/api/_internal/types.md" +"plugins/index.md" "topics/plugins.md" +"developing.md" "project/contributing.md" +"about.md" "topics/index.md" diff --git a/docs/topics/index.md b/docs/topics/index.md new file mode 100644 index 0000000000..050ddb9d58 --- /dev/null +++ b/docs/topics/index.md @@ -0,0 +1,52 @@ +(topics)= + +# Topics + +Conceptual guides and workflow documentation. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Workflows +:link: workflows +:link-type: doc +CI integration, scripting, and automation patterns. +::: + +:::{grid-item-card} Plugins +:link: plugins +:link-type: doc +Plugin system for custom behavior. +::: + +:::{grid-item-card} Library vs CLI +:link: library-vs-cli +:link-type: doc +When to use tmuxp CLI vs libtmux directly. +::: + +:::{grid-item-card} Troubleshooting +:link: troubleshooting +:link-type: doc +Common shell, PATH, and tmux issues. +::: + +:::: + +## Compared to tmuxinator / teamocil + +tmuxp, [tmuxinator](https://github.com/aziz/tmuxinator), and +[teamocil](https://github.com/remiprev/teamocil) all load tmux sessions +from config files. Key differences: tmuxp is Python (not Ruby), builds +sessions through [libtmux](https://libtmux.git-pull.com/)'s ORM layer +instead of raw shell commands, supports JSON and YAML, and can +[freeze](../cli/freeze.md) running sessions back to config. + +```{toctree} +:hidden: + +workflows +plugins +library-vs-cli +troubleshooting +``` diff --git a/docs/topics/library-vs-cli.md b/docs/topics/library-vs-cli.md new file mode 100644 index 0000000000..8c431caa3d --- /dev/null +++ b/docs/topics/library-vs-cli.md @@ -0,0 +1,62 @@ +# Library vs CLI + +tmuxp is a CLI tool. [libtmux](https://libtmux.git-pull.com/) is the Python library it's built on. Both control tmux, but they serve different needs. + +## When to Use the CLI + +Use `tmuxp` when: + +- You want **declarative workspace configs** — define your layout in YAML, load it with one command +- You're setting up **daily development environments** — same windows, same panes, every time +- You need **CI/CD tmux sessions** — `tmuxp load -d` in a script +- You prefer **configuration over code** — no Python needed + +```console +$ tmuxp load my-workspace.yaml +``` + +## When to Use libtmux + +Use [libtmux](https://libtmux.git-pull.com/) directly when: + +- You need **dynamic logic** — conditionals, loops, branching based on state +- You want to **read pane output** — capture what's on screen and react to it +- You're **testing** tmux interactions — libtmux provides pytest fixtures +- You need **multi-server orchestration** — manage multiple tmux servers programmatically +- The CLI's config format **can't express** what you need + +```python +import libtmux + +server = libtmux.Server() +session = server.new_session("my-project") +window = session.new_window("editor") +pane = window.split() +pane.send_keys("vim .") +``` + +## Concept Mapping + +How tmuxp config keys map to libtmux API calls: + +| tmuxp YAML | libtmux equivalent | +|------------|-------------------| +| `session_name: foo` | `server.new_session(session_name="foo")` | +| `windows:` | `session.new_window(...)` | +| `panes:` | `window.split(...)` | +| `shell_command:` | `pane.send_keys(...)` | +| `layout: main-vertical` | `window.select_layout("main-vertical")` | +| `start_directory: ~/project` | `session.new_window(start_directory="~/project")` | +| `before_script:` | Run via `subprocess` before building | + +## What the CLI Can't Express + +tmuxp configs are static declarations. They can't: + +- **Branch on conditions** — "only create this pane if a file exists" +- **Read pane output** — "wait until the server is ready, then open the browser" +- **React to state** — "if this session already has 3 windows, add a 4th" +- **Orchestrate across servers** — "connect to both local and remote tmux" +- **Build layouts dynamically** — "create N panes based on a list of services" + +For these, use libtmux directly. See the [libtmux quickstart](https://libtmux.git-pull.com/quickstart.html). diff --git a/docs/plugins/index.md b/docs/topics/plugins.md similarity index 100% rename from docs/plugins/index.md rename to docs/topics/plugins.md diff --git a/docs/topics/troubleshooting.md b/docs/topics/troubleshooting.md new file mode 100644 index 0000000000..bfacad2c34 --- /dev/null +++ b/docs/topics/troubleshooting.md @@ -0,0 +1,40 @@ +# Troubleshooting + +## tmuxp command not found + +Ensure tmuxp is installed and on your `PATH`: + +```console +$ which tmuxp +``` + +If installed with `pip install --user`, ensure `~/.local/bin` is in your `PATH`. + +## tmux server not found + +tmuxp requires a running tmux server or will start one automatically. +Ensure tmux is installed: + +```console +$ tmux -V +``` + +Minimum required version: tmux 3.2a. + +## Configuration errors + +Use `tmuxp debug-info` to collect system information for bug reports: + +```console +$ tmuxp debug-info +``` + +## Session already exists + +If a session with the same name already exists, tmuxp will prompt you. +Use `tmuxp load -d` to load in detached mode alongside existing sessions. + +## Shell completion not working + +See [Shell Completion](../cli/completion.md) for setup instructions +for bash, zsh, and fish. diff --git a/docs/topics/workflows.md b/docs/topics/workflows.md new file mode 100644 index 0000000000..5965653c0d --- /dev/null +++ b/docs/topics/workflows.md @@ -0,0 +1,32 @@ +# Workflows + +## CI Integration + +tmuxp can set up tmux sessions in CI pipelines for integration testing: + +```console +$ tmuxp load -d my-workspace.yaml +``` + +The `-d` flag loads the session in detached mode, useful for headless environments. + +## Scripting + +tmuxp's exit codes enable scripting and error handling. See +[Exit Codes](../cli/exit-codes.md) for the complete list. + +## Automating Development Environments + +Use tmuxp to codify your development environment: + +1. Set up your ideal tmux layout manually +2. Freeze it: `tmuxp freeze my-session` +3. Edit the generated YAML to add commands +4. Load it on any machine: `tmuxp load my-workspace.yaml` + +## User-Level Configuration + +Workspace configs can be stored in: +- `~/.tmuxp/` (legacy) +- `~/.config/tmuxp/` (XDG default) +- Project-local `.tmuxp.yaml` or `.tmuxp/` directory diff --git a/pyproject.toml b/pyproject.toml index 2fde900121..38b552781f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.64.2" +version = "1.67.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ @@ -39,8 +39,7 @@ include = [ { path = "conftest.py", format = "sdist" }, ] dependencies = [ - "libtmux~=0.53.0", - "colorama>=0.3.9", + "libtmux~=0.55.0", "PyYAML>=6.0" ] @@ -56,19 +55,12 @@ tmuxp = 'tmuxp:cli.cli' [dependency-groups] dev = [ # Docs - "aafigure", - "pillow", - "sphinx<9", - "furo", - "gp-libs", - "sphinx-autobuild", - "sphinx-autodoc-typehints", - "sphinx-inline-tabs", - "sphinxext-opengraph", - "sphinx-copybutton", - "sphinxext-rediraffe", - "myst-parser", - "linkify-it-py", + "aafigure", # https://launchpad.net/aafigure + "pillow", # https://pillow.readthedocs.io/ + "gp-sphinx==0.0.1a0", # https://gp-sphinx.git-pull.com/ + "sphinx-argparse-neo==0.0.1a0", # https://gp-sphinx.git-pull.com/ + "gp-libs", # https://gp-libs.git-pull.com/ + "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html # Testing "gp-libs", "pytest", @@ -82,26 +74,18 @@ dev = [ # Lint "ruff", "mypy", - "types-colorama", "types-docutils", "types-Pygments", "types-PyYAML", ] docs = [ - "aafigure", - "pillow", - "sphinx<9", - "furo", - "gp-libs", - "sphinx-autobuild", - "sphinx-autodoc-typehints", - "sphinx-inline-tabs", - "sphinxext-opengraph", - "sphinx-copybutton", - "sphinxext-rediraffe", - "myst-parser", - "linkify-it-py", + "aafigure", # https://launchpad.net/aafigure + "pillow", # https://pillow.readthedocs.io/ + "gp-sphinx==0.0.1a0", # https://gp-sphinx.git-pull.com/ + "sphinx-argparse-neo==0.0.1a0", # https://gp-sphinx.git-pull.com/ + "gp-libs", # https://gp-libs.git-pull.com/ + "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html ] testing = [ "gp-libs", @@ -118,7 +102,6 @@ coverage =[ lint = [ "ruff", "mypy", - "types-colorama", "types-docutils", "types-Pygments", "types-PyYAML", @@ -161,28 +144,16 @@ files = [ "src/", "tests/", ] -exclude = [ - "tests/docs/", -] enable_incomplete_feature = [] [[tool.mypy.overrides]] module = [ "shtab", - "aafigure", "IPython.*", "ptpython.*", "prompt_toolkit.*", "bpython", - "sphinx_argparse_neo", - "sphinx_argparse_neo.*", - "cli_usage_lexer", - "argparse_lexer", - "argparse_roles", - "docutils", - "docutils.*", - "pygments", - "pygments.*", + "aafigure", ] ignore_missing_imports = true @@ -251,5 +222,5 @@ testpaths = [ ] filterwarnings = [ "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", - "ignore:.*invalid escape sequence.*:SyntaxWarning:aafigure:", + "ignore:.*invalid escape sequence.*:SyntaxWarning:.*aafigure.*:", ] diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index aaf0ffb342..48ad3629a1 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -2,9 +2,13 @@ from __future__ import annotations +import logging + +logger = logging.getLogger(__name__) + __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.64.2" +__version__ = "1.67.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/src/tmuxp/__init__.py b/src/tmuxp/__init__.py index 25c46f4d21..78e2118ea5 100644 --- a/src/tmuxp/__init__.py +++ b/src/tmuxp/__init__.py @@ -6,6 +6,8 @@ from __future__ import annotations +import logging + from . import cli, util from .__about__ import ( __author__, @@ -17,3 +19,6 @@ __title__, __version__, ) + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) diff --git a/src/tmuxp/_compat.py b/src/tmuxp/_compat.py index ca83962641..51a055f7f5 100644 --- a/src/tmuxp/_compat.py +++ b/src/tmuxp/_compat.py @@ -1,18 +1,41 @@ -# flake8: NOQA +from __future__ import annotations + +import logging import sys +logger = logging.getLogger(__name__) + PY3 = sys.version_info[0] == 3 PYMINOR = sys.version_info[1] PYPATCH = sys.version_info[2] -_identity = lambda x: x + +def _identity(x: object) -> object: + """Return *x* unchanged — used as a no-op decorator. + + Examples + -------- + >>> from tmuxp._compat import _identity + + Strings pass through unchanged: + + >>> _identity("hello") + 'hello' + + Integers pass through unchanged: + + >>> _identity(42) + 42 + """ + return x + if PY3 and PYMINOR >= 7: - breakpoint = breakpoint + breakpoint = breakpoint # noqa: A001 else: import pdb - breakpoint = pdb.set_trace + breakpoint = pdb.set_trace # noqa: A001 implements_to_string = _identity diff --git a/src/tmuxp/_internal/__init__.py b/src/tmuxp/_internal/__init__.py index 01dccbcfcb..baae40fd2c 100644 --- a/src/tmuxp/_internal/__init__.py +++ b/src/tmuxp/_internal/__init__.py @@ -1 +1,7 @@ """Internal APIs for tmuxp.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py index a25442d83c..a9b350016f 100644 --- a/src/tmuxp/_internal/colors.py +++ b/src/tmuxp/_internal/colors.py @@ -43,11 +43,14 @@ from __future__ import annotations import enum +import logging import os import re import sys import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias @@ -470,6 +473,33 @@ def format_separator(self, length: int = 25) -> str: """ return self.muted("-" * length) + def format_rule(self, width: int = 40, char: str = "─") -> str: + """Format a horizontal rule using Unicode box-drawing characters. + + A richer alternative to ``format_separator()`` which uses plain hyphens. + + Parameters + ---------- + width : int + Number of characters. Default is 40. + char : str + Character to repeat. Default is ``"─"`` (U+2500). + + Returns + ------- + str + Muted (blue) rule when colors enabled, plain rule otherwise. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_rule(10) + '──────────' + >>> colors.format_rule(5, char="=") + '=====' + """ + return self.muted(char * width) + def format_kv(self, key: str, value: str) -> str: """Format key: value pair with syntax highlighting. @@ -580,6 +610,7 @@ def get_color_mode(color_arg: str | None = None) -> ColorMode: # ANSI styling utilities (originally from click, via utils.py) _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") +ANSI_SEQ_RE = _ansi_re def strip_ansi(value: str) -> str: diff --git a/src/tmuxp/_internal/config_reader.py b/src/tmuxp/_internal/config_reader.py index 3c667bbceb..6da248dea7 100644 --- a/src/tmuxp/_internal/config_reader.py +++ b/src/tmuxp/_internal/config_reader.py @@ -3,11 +3,14 @@ from __future__ import annotations import json +import logging import pathlib import typing as t import yaml +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias @@ -106,6 +109,7 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: {'session_name': 'my session'} """ assert isinstance(path, pathlib.Path) + logger.debug("loading config", extra={"tmux_config_path": str(path)}) content = path.open(encoding="utf-8").read() if path.suffix in {".yaml", ".yml"}: diff --git a/src/tmuxp/_internal/private_path.py b/src/tmuxp/_internal/private_path.py index 2ab8a998ae..8fa0cec972 100644 --- a/src/tmuxp/_internal/private_path.py +++ b/src/tmuxp/_internal/private_path.py @@ -6,10 +6,13 @@ from __future__ import annotations +import logging import os import pathlib import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: PrivatePathBase = pathlib.Path else: diff --git a/src/tmuxp/_internal/types.py b/src/tmuxp/_internal/types.py index a3521f5832..41498cebd0 100644 --- a/src/tmuxp/_internal/types.py +++ b/src/tmuxp/_internal/types.py @@ -12,9 +12,12 @@ from __future__ import annotations +import logging import typing as t from typing import TypedDict +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: import sys diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index b05708321f..860a9200cb 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -181,9 +181,9 @@ def create_parser() -> argparse.ArgumentParser: "--log-level", action="store", metavar="log-level", - default="info", + default="warning", choices=["debug", "info", "warning", "error", "critical"], - help='log level (debug, info, warning, error, critical) (default "info")', + help='log level (debug, info, warning, error, critical) (default "warning")', ) parser.add_argument( "--color", @@ -297,7 +297,7 @@ def cli(_args: list[str] | None = None) -> None: parser = create_parser() args = parser.parse_args(_args, namespace=ns) - setup_logger(logger=logger, level=args.log_level.upper()) + setup_logger(level=args.log_level.upper()) if args.subparser_name is None: parser.print_help() diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 9932218fb6..31dab82076 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -9,7 +9,10 @@ from __future__ import annotations +import logging + from tmuxp._internal.colors import ( + ANSI_SEQ_RE, ColorMode, Colors, UnknownStyleColor, @@ -20,7 +23,10 @@ unstyle, ) +logger = logging.getLogger(__name__) + __all__ = [ + "ANSI_SEQ_RE", "ColorMode", "Colors", "UnknownStyleColor", diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py index 9dfceaf29e..d4efb9ff47 100644 --- a/src/tmuxp/cli/_formatter.py +++ b/src/tmuxp/cli/_formatter.py @@ -13,9 +13,12 @@ from __future__ import annotations import argparse +import logging import re import typing as t +logger = logging.getLogger(__name__) + # Options that expect a value (set externally or via --option=value) OPTIONS_EXPECTING_VALUE = frozenset( { diff --git a/src/tmuxp/cli/_output.py b/src/tmuxp/cli/_output.py index 7ac8df92ef..b62f1cc05c 100644 --- a/src/tmuxp/cli/_output.py +++ b/src/tmuxp/cli/_output.py @@ -25,9 +25,12 @@ import enum import json +import logging import sys import typing as t +logger = logging.getLogger(__name__) + class OutputMode(enum.Enum): """Output format modes for CLI commands. @@ -117,6 +120,49 @@ def emit_text(self, text: str) -> None: sys.stdout.write(text + "\n") sys.stdout.flush() + def emit_object(self, data: dict[str, t.Any]) -> None: + """Emit a single top-level JSON object (not a list of records). + + For commands that produce one structured object rather than a stream of + records. Writes immediately without buffering; does not affect + ``_json_buffer``. + + In JSON mode, writes indented JSON followed by a newline. + In NDJSON mode, writes compact single-line JSON followed by a newline. + In HUMAN mode, does nothing (use ``emit_text`` for human output). + + Parameters + ---------- + data : dict + The object to emit. + + Examples + -------- + >>> import io, sys + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit_object({"status": "ok", "count": 3}) + { + "status": "ok", + "count": 3 + } + >>> formatter._json_buffer # buffer is unaffected + [] + + >>> formatter2 = OutputFormatter(OutputMode.NDJSON) + >>> formatter2.emit_object({"status": "ok", "count": 3}) + {"status": "ok", "count": 3} + + >>> formatter3 = OutputFormatter(OutputMode.HUMAN) + >>> formatter3.emit_object({"status": "ok"}) # no output in HUMAN mode + """ + if self.mode == OutputMode.JSON: + sys.stdout.write(json.dumps(data, indent=2) + "\n") + sys.stdout.flush() + elif self.mode == OutputMode.NDJSON: + sys.stdout.write(json.dumps(data) + "\n") + sys.stdout.flush() + # HUMAN: no-op + def finalize(self) -> None: """Finalize output (flush JSON buffer if needed). diff --git a/src/tmuxp/cli/_progress.py b/src/tmuxp/cli/_progress.py new file mode 100644 index 0000000000..01d31d7272 --- /dev/null +++ b/src/tmuxp/cli/_progress.py @@ -0,0 +1,1131 @@ +"""Progress indicators for tmuxp CLI. + +This module provides a threaded spinner for long-running operations, +using only standard library and ANSI escape sequences. +""" + +from __future__ import annotations + +import atexit +import collections +import dataclasses +import itertools +import logging +import shutil +import sys +import threading +import time +import typing as t + +from ._colors import ANSI_SEQ_RE, ColorMode, Colors, strip_ansi + +logger = logging.getLogger(__name__) + + +if t.TYPE_CHECKING: + import types + + +# ANSI Escape Sequences +HIDE_CURSOR = "\033[?25l" +SHOW_CURSOR = "\033[?25h" +ERASE_LINE = "\033[2K" +CURSOR_TO_COL0 = "\r" +CURSOR_UP_1 = "\x1b[1A" +SYNC_START = "\x1b[?2026h" # synchronized output: buffer until SYNC_END +SYNC_END = "\x1b[?2026l" # flush — prevents multi-line flicker + +# Spinner frames (braille pattern) +SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + +BAR_WIDTH = 10 # inner fill character count +DEFAULT_OUTPUT_LINES = 3 # default spinner panel height (lines of script output) + + +def _visible_len(s: str) -> int: + r"""Return visible length of *s*, ignoring ANSI escapes. + + Examples + -------- + >>> _visible_len("hello") + 5 + >>> _visible_len("\033[32mgreen\033[0m") + 5 + >>> _visible_len("") + 0 + """ + return len(strip_ansi(s)) + + +def _truncate_visible(text: str, max_visible: int, suffix: str = "...") -> str: + r"""Truncate *text* to *max_visible* visible characters, preserving ANSI sequences. + + If the visible length of *text* is already within *max_visible*, it is + returned unchanged. Otherwise the text is cut so that exactly + *max_visible* visible characters remain, a ``\x1b[0m`` reset is appended + (to prevent color bleed), followed by *suffix*. + + Parameters + ---------- + text : str + Input string, possibly containing ANSI escape sequences. + max_visible : int + Maximum number of visible (non-ANSI) characters to keep. + suffix : str + Appended after the reset when truncation occurs. Default ``"..."``. + + Returns + ------- + str + Truncated string with ANSI sequences intact. + + Examples + -------- + Plain text truncation: + + >>> _truncate_visible("hello world", 5) + 'hello\x1b[0m...' + + ANSI sequences are preserved whole: + + >>> _truncate_visible("\033[32mgreen\033[0m", 3) + '\x1b[32mgre\x1b[0m...' + + No truncation needed: + + >>> _truncate_visible("short", 10) + 'short' + + Empty string: + + >>> _truncate_visible("", 5) + '' + """ + if max_visible <= 0: + return "" + if _visible_len(text) <= max_visible: + return text + + result: list[str] = [] + visible = 0 + i = 0 + while i < len(text) and visible < max_visible: + m = ANSI_SEQ_RE.match(text, i) + if m: + result.append(m.group()) + i = m.end() + else: + result.append(text[i]) + visible += 1 + i += 1 + return "".join(result) + "\x1b[0m" + suffix + + +SUCCESS_TEMPLATE = "Loaded workspace: {session} ({workspace_path}) {summary}" + +PROGRESS_PRESETS: dict[str, str] = { + "default": "Loading workspace: {session} {bar} {progress} {window}", + "minimal": "Loading workspace: {session} [{window_progress}]", + "window": "Loading workspace: {session} {window_bar} {window_progress_rel}", + "pane": "Loading workspace: {session} {pane_bar} {session_pane_progress}", + "verbose": ( + "Loading workspace: {session} [window {window_index} of {window_total}" + " · pane {pane_index} of {pane_total}] {window}" + ), +} + + +def render_bar(done: int, total: int, width: int = BAR_WIDTH) -> str: + """Render a plain-text ASCII progress bar without color. + + Parameters + ---------- + done : int + Completed units. + total : int + Total units. When ``<= 0``, returns ``""``. + width : int + Inner fill character count; default :data:`BAR_WIDTH`. + + Returns + ------- + str + A bar like ``"█████░░░░░"``. + Returns ``""`` when *total* <= 0 or *width* <= 0. + + Examples + -------- + >>> render_bar(0, 10) + '░░░░░░░░░░' + >>> render_bar(5, 10) + '█████░░░░░' + >>> render_bar(10, 10) + '██████████' + >>> render_bar(0, 0) + '' + >>> render_bar(3, 10, width=5) + '█░░░░' + """ + if total <= 0 or width <= 0: + return "" + filled = min(width, int(done / total * width)) + return "█" * filled + "░" * (width - filled) + + +class _SafeFormatMap(dict): # type: ignore[type-arg] + """dict subclass that returns ``{key}`` for missing keys in format_map.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def resolve_progress_format(fmt: str) -> str: + """Return the format string for *fmt*, resolving preset names. + + If *fmt* is a key in :data:`PROGRESS_PRESETS` the corresponding + format string is returned; otherwise *fmt* is returned as-is. + + Examples + -------- + >>> resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + True + >>> resolve_progress_format("{session} w{window_progress}") + '{session} w{window_progress}' + >>> resolve_progress_format("unknown-preset") + 'unknown-preset' + """ + return PROGRESS_PRESETS.get(fmt, fmt) + + +@dataclasses.dataclass +class _WindowStatus: + """State for a single window in the build tree.""" + + name: str + done: bool = False + pane_num: int | None = None + pane_total: int | None = None + pane_done: int = 0 # panes completed in this window (set on window_done) + + +class BuildTree: + """Tracks session/window/pane build state; renders a structural progress tree. + + **Template Token Lifecycle** + + Each token is first available at the event listed in its column. + ``—`` means the value does not change at that phase. + + .. list-table:: + :header-rows: 1 + + * - Token + - Pre-``session_created`` + - After ``session_created`` + - After ``window_started`` + - After ``pane_creating`` + - After ``window_done`` + * - ``{session}`` + - ``""`` + - session name + - — + - — + - — + * - ``{window}`` + - ``""`` + - ``""`` + - window name + - — + - last window name + * - ``{window_index}`` + - ``0`` + - ``0`` + - N (1-based started count) + - — + - — + * - ``{window_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{window_progress}`` + - ``""`` + - ``""`` + - ``"N/M"`` when > 0 + - — + - — + * - ``{windows_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - increments + * - ``{windows_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{window_progress_rel}`` + - ``""`` + - ``"0/M"`` + - ``"0/M"`` + - — + - ``"N/M"`` + * - ``{pane_index}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - ``0`` + * - ``{pane_total}`` + - ``0`` + - ``0`` + - window's pane total + - — + - window's pane total + * - ``{pane_progress}`` + - ``""`` + - ``""`` + - ``""`` + - ``"N/M"`` + - ``""`` + * - ``{pane_done}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - pane_total + * - ``{pane_remaining}`` + - ``0`` + - ``0`` + - pane_total + - decrements + - ``0`` + * - ``{pane_progress_rel}`` + - ``""`` + - ``""`` + - ``"0/M"`` + - ``"N/M"`` + - ``"M/M"`` + * - ``{progress}`` + - ``""`` + - ``""`` + - ``"N/M win"`` + - ``"N/M win · P/Q pane"`` + - — + * - ``{session_pane_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{session_panes_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - accumulated + * - ``{session_panes_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{session_pane_progress}`` + - ``""`` + - ``"0/T"`` + - — + - — + - ``"N/T"`` + * - ``{overall_percent}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - updates + * - ``{summary}`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``"[N win, M panes]"`` + * - ``{bar}`` (spinner) + - ``[░░…]`` + - ``[░░…]`` + - starts filling + - fractional + - jumps + * - ``{pane_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{window_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{status_icon}`` (spinner) + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + + During ``before_script``: ``{bar}``, ``{pane_bar}``, ``{window_bar}`` show a + marching animation; ``{status_icon}`` = ``⏸``. + + Examples + -------- + Empty tree renders nothing: + + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> tree = BuildTree() + >>> tree.render(colors, 80) + [] + + After session_created event the header appears: + + >>> tree.on_event({"event": "session_created", "name": "my-session"}) + >>> tree.render(colors, 80) + ['Session'] + + After window_started and pane_creating: + + >>> tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + >>> tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- editor, pane (1 of 2)' + + After window_done the window gets a checkmark: + + >>> tree.on_event({"event": "window_done"}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- ✓ editor' + + **Inline status format:** + + >>> tree2 = BuildTree() + >>> tree2.format_inline("Building projects...") + 'Building projects...' + >>> tree2.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai' + >>> tree2.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + >>> tree2.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs' + """ + + def __init__(self, workspace_path: str = "") -> None: + self.workspace_path: str = workspace_path + self.session_name: str | None = None + self.windows: list[_WindowStatus] = [] + self.window_total: int | None = None + self.session_pane_total: int | None = None + self.session_panes_done: int = 0 + self.windows_done: int = 0 + self._before_script_event: threading.Event = threading.Event() + + def on_event(self, event: dict[str, t.Any]) -> None: + """Update tree state from a build event dict. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.on_event({ + ... "event": "session_created", "name": "dev", "window_total": 2, + ... }) + >>> tree.session_name + 'dev' + >>> tree.window_total + 2 + >>> tree.on_event({ + ... "event": "window_started", "name": "editor", "pane_total": 3, + ... }) + >>> len(tree.windows) + 1 + >>> tree.windows[0].name + 'editor' + """ + kind = event["event"] + if kind == "session_created": + self.session_name = event["name"] + self.window_total = event.get("window_total") + self.session_pane_total = event.get("session_pane_total") + elif kind == "before_script_started": + self._before_script_event.set() + elif kind == "before_script_done": + self._before_script_event.clear() + elif kind == "window_started": + self.windows.append( + _WindowStatus(name=event["name"], pane_total=event["pane_total"]) + ) + elif kind == "pane_creating": + if self.windows: + w = self.windows[-1] + w.pane_num = event["pane_num"] + w.pane_total = event["pane_total"] + elif kind == "window_done": + if self.windows: + w = self.windows[-1] + w.done = True + w.pane_num = None + w.pane_done = w.pane_total or 0 + self.session_panes_done += w.pane_done + self.windows_done += 1 + elif kind == "workspace_built": + for w in self.windows: + w.done = True + + def render(self, colors: Colors, width: int) -> list[str]: + """Render the current tree state to a list of display strings. + + Parameters + ---------- + colors : Colors + Colors instance for ANSI styling. + width : int + Terminal width; window lines are truncated to ``width - 1``. + + Returns + ------- + list[str] + Lines to display; empty list if no session has been created yet. + """ + if self.session_name is None: + return [] + lines: list[str] = [colors.heading("Session")] + for w in self.windows: + if w.done: + line = f"- {colors.success('✓')} {colors.highlight(w.name)}" + elif w.pane_num is not None and w.pane_total is not None: + line = ( + f"- {colors.highlight(w.name)}" + f"{colors.muted(f', pane ({w.pane_num} of {w.pane_total})')}" + ) + else: + line = f"- {colors.highlight(w.name)}" + lines.append(_truncate_visible(line, width - 1, suffix="")) + return lines + + def _context(self) -> dict[str, t.Any]: + """Return the current build-state token dict for template rendering. + + Examples + -------- + Zero-state before any events: + + >>> tree = BuildTree(workspace_path="~/.tmuxp/myapp.yaml") + >>> ev = { + ... "event": "session_created", + ... "name": "myapp", + ... "window_total": 5, + ... "session_pane_total": 10, + ... } + >>> tree.on_event(ev) + >>> ctx = tree._context() + >>> ctx["workspace_path"] + '~/.tmuxp/myapp.yaml' + >>> ctx["session"] + 'myapp' + >>> ctx["window_total"] + 5 + >>> ctx["window_index"] + 0 + >>> ctx["progress"] + '' + >>> ctx["windows_done"] + 0 + >>> ctx["windows_remaining"] + 5 + >>> ctx["window_progress_rel"] + '0/5' + >>> ctx["session_pane_total"] + 10 + >>> ctx["session_panes_remaining"] + 10 + >>> ctx["session_pane_progress"] + '0/10' + >>> ctx["summary"] + '' + + After windows complete, summary shows counts: + + >>> tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + >>> tree.on_event({"event": "window_done"}) + >>> tree.on_event({"event": "window_started", "name": "w2", "pane_total": 5}) + >>> tree.on_event({"event": "window_done"}) + >>> tree._context()["summary"] + '[2 win, 8 panes]' + """ + w = self.windows[-1] if self.windows else None + window_idx = len(self.windows) + win_tot = self.window_total or 0 + pane_idx = (w.pane_num or 0) if w else 0 + pane_tot = (w.pane_total or 0) if w else 0 + + win_progress = f"{window_idx}/{win_tot}" if win_tot and window_idx > 0 else "" + pane_progress = f"{pane_idx}/{pane_tot}" if pane_tot and pane_idx > 0 else "" + progress_parts = [ + f"{win_progress} win" if win_progress else "", + f"{pane_progress} pane" if pane_progress else "", + ] + progress = " · ".join(p for p in progress_parts if p) + + win_done = self.windows_done + win_progress_rel = f"{win_done}/{win_tot}" if win_tot else "" + + pane_done_cur = ( + (w.pane_num or 0) if w and not w.done else (w.pane_done if w else 0) + ) + pane_remaining = max(0, pane_tot - pane_done_cur) + pane_progress_rel = f"{pane_done_cur}/{pane_tot}" if pane_tot else "" + + spt = self.session_pane_total or 0 + session_pane_progress = f"{self.session_panes_done}/{spt}" if spt else "" + overall_percent = int(self.session_panes_done / spt * 100) if spt else 0 + + summary_parts: list[str] = [] + if self.windows_done: + summary_parts.append(f"{self.windows_done} win") + if self.session_panes_done: + summary_parts.append(f"{self.session_panes_done} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + + return { + "workspace_path": self.workspace_path, + "session": self.session_name or "", + "window": w.name if w else "", + "window_index": window_idx, + "window_total": win_tot, + "window_progress": win_progress, + "pane_index": pane_idx, + "pane_total": pane_tot, + "pane_progress": pane_progress, + "progress": progress, + "windows_done": win_done, + "windows_remaining": max(0, win_tot - win_done), + "window_progress_rel": win_progress_rel, + "pane_done": pane_done_cur, + "pane_remaining": pane_remaining, + "pane_progress_rel": pane_progress_rel, + "session_pane_total": spt, + "session_panes_done": self.session_panes_done, + "session_panes_remaining": max(0, spt - self.session_panes_done), + "session_pane_progress": session_pane_progress, + "overall_percent": overall_percent, + "summary": summary, + } + + def format_template( + self, + fmt: str, + extra: dict[str, t.Any] | None = None, + ) -> str: + """Render *fmt* with the current build state. + + Returns ``""`` before ``session_created`` fires so callers can + fall back to a pre-build message. Unknown ``{tokens}`` are left + as-is (not dropped silently). + + The optional *extra* dict is merged on top of :meth:`_context` so + callers (e.g. :class:`Spinner`) can inject ANSI-colored tokens like + ``{bar}`` without adding color concerns to :class:`BuildTree`. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.format_template("{session} [{progress}] {window}") + '' + >>> ev = {"event": "session_created", "name": "cihai", "window_total": 3} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [] ' + >>> ev = {"event": "window_started", "name": "editor", "pane_total": 4} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win] editor' + >>> tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win · 2/4 pane] editor' + >>> tree.format_template("minimal: {session} [{window_progress}]") + 'minimal: cihai [1/3]' + >>> tree.format_template("{session} {unknown_token}") + 'cihai {unknown_token}' + >>> tree.format_template("{session}", extra={"custom": "value"}) + 'cihai' + """ + if self.session_name is None: + return "" + ctx: dict[str, t.Any] = self._context() + if extra: + ctx = {**ctx, **extra} + return fmt.format_map(_SafeFormatMap(ctx)) + + def format_inline(self, base: str) -> str: + """Return base message with current build state appended inline. + + Parameters + ---------- + base : str + The original spinner message to start from. + + Returns + ------- + str + ``base`` alone if no session has been created yet; otherwise + ``"base session_name [W of N windows, P of M panes] window_name"``, + omitting the bracket section when there is no current window, and + omitting individual parts when their totals are not known. + """ + if self.session_name is None: + return base + parts = [base, self.session_name] + if self.windows: + w = self.windows[-1] + window_idx = len(self.windows) + bracket_parts: list[str] = [] + if self.window_total is not None: + bracket_parts.append(f"{window_idx} of {self.window_total} windows") + if w.pane_num is not None and w.pane_total is not None: + bracket_parts.append(f"{w.pane_num} of {w.pane_total} panes") + if bracket_parts: + parts.append(f"[{', '.join(bracket_parts)}]") + parts.append(w.name) + return " ".join(parts) + + +class Spinner: + """A threaded spinner for CLI progress. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> with Spinner("Build...", color_mode=ColorMode.NEVER, stream=stream) as spinner: + ... spinner.add_output_line("Session created: test") + ... spinner.update_message("Creating window: editor") + """ + + def __init__( + self, + message: str = "Loading...", + color_mode: ColorMode = ColorMode.AUTO, + stream: t.TextIO = sys.stderr, + interval: float = 0.1, + output_lines: int = DEFAULT_OUTPUT_LINES, + progress_format: str | None = None, + workspace_path: str = "", + ) -> None: + """Initialize spinner. + + Parameters + ---------- + message : str + Text displayed next to the spinner animation. + color_mode : ColorMode + ANSI color mode for styled output. + stream : t.TextIO + Output stream (default ``sys.stderr``). + interval : float + Seconds between animation frames. + output_lines : int + Max lines in the scrolling output panel. ``0`` hides the panel, + ``-1`` means unlimited. + progress_format : str | None + Format string for progress output. Tokens are documented in + :class:`BuildTree`. ``None`` uses the built-in default. + workspace_path : str + Absolute path to the workspace config file, shown in success + output. + """ + self.message = message + self._base_message = message + self.colors = Colors(color_mode) + self.stream = stream + self.interval = interval + + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._enabled = self._should_enable() + self._panel_hidden = output_lines == 0 + if output_lines < 0: + self._output_lines: collections.deque[str] = ( + collections.deque() + ) # unlimited + elif output_lines == 0: + self._output_lines = collections.deque(maxlen=1) # drop, never render + else: + self._output_lines = collections.deque(maxlen=output_lines) + self._prev_height: int = 0 + self._build_tree: BuildTree = BuildTree(workspace_path=workspace_path) + self._progress_format: str | None = ( + resolve_progress_format(progress_format) + if progress_format is not None + else None + ) + + def _should_enable(self) -> bool: + """Check if spinner should be enabled (TTY check).""" + return self.stream.isatty() + + def _restore_cursor(self) -> None: + """Unconditionally restore cursor — called by atexit on abnormal exit.""" + self.stream.write(SHOW_CURSOR) + self.stream.flush() + + def _spin(self) -> None: + """Spin in background thread.""" + frames = itertools.cycle(SPINNER_FRAMES) + march_pos = 0 # marching bar position counter (local to _spin) + + self.stream.write(HIDE_CURSOR) + self.stream.flush() + + try: + while not self._stop_event.is_set(): + frame = next(frames) + term_width = shutil.get_terminal_size(fallback=(80, 24)).columns + if self._panel_hidden: + panel: list[str] = [] + else: + term_height = shutil.get_terminal_size( + fallback=(80, 24), + ).lines + raw_panel = list(self._output_lines) + max_panel = term_height - 2 + if len(raw_panel) > max_panel: + raw_panel = raw_panel[-max_panel:] + panel = [ + _truncate_visible(line, term_width - 1, suffix="") + for line in raw_panel + ] + new_height = len(panel) + 1 # panel lines + spinner line + + parts: list[str] = [] + + # Erase previous render (cursor is at end of previous spinner line) + if self._prev_height > 0: + parts.append(f"{CURSOR_TO_COL0}{ERASE_LINE}") + parts.extend( + f"{CURSOR_UP_1}{ERASE_LINE}" + for _ in range(self._prev_height - 1) + ) + + # Write panel lines (tree lines already constrained by render()) + parts.extend(f"{output_line}\n" for output_line in panel) + + # Determine final spinner message + if ( + self._progress_format is not None + and self._build_tree._before_script_event.is_set() + ): + # Marching bar: sweep a 2-cell highlight across the bar + p = march_pos % max(1, BAR_WIDTH - 1) + march_bar = ( + self.colors.muted("░" * p) + + self.colors.warning("░░") + + self.colors.muted("░" * max(0, BAR_WIDTH - p - 2)) + ) + tree = self._build_tree + extra: dict[str, t.Any] = { + "session": self.colors.highlight( + tree.session_name or "", + ), + "bar": march_bar, + "pane_bar": march_bar, + "window_bar": march_bar, + "status_icon": self.colors.warning("⏸"), + } + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + msg = rendered if rendered else self._base_message + march_pos += 1 + else: + msg = self.message + march_pos = 0 # reset when not in before_script + + # Write spinner line (no trailing newline — cursor stays here) + spinner_text = f"{self.colors.info(frame)} {msg}" + if _visible_len(spinner_text) > term_width - 1: + spinner_text = _truncate_visible(spinner_text, term_width - 4) + parts.append(f"{CURSOR_TO_COL0}{spinner_text}") + + # Wrap entire frame in synchronized output to prevent flicker. + # Terminals that don't support it safely ignore the sequences. + self.stream.write(SYNC_START + "".join(parts) + SYNC_END) + self.stream.flush() + self._prev_height = new_height + time.sleep(self.interval) + finally: + # Erase the whole block and show cursor + if self._prev_height > 0: + self.stream.write(f"{CURSOR_TO_COL0}{ERASE_LINE}") + for _ in range(self._prev_height - 1): + self.stream.write(f"{CURSOR_UP_1}{ERASE_LINE}") + self.stream.write(SHOW_CURSOR) + self.stream.flush() + self._prev_height = 0 + + def add_output_line(self, line: str) -> None: + r"""Append a line to the live output panel (thread-safe via GIL). + + When the spinner is disabled (non-TTY), writes directly to the stream + so output is not silently swallowed. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.add_output_line("hello world") + >>> stream.getvalue() + 'hello world\n' + """ + stripped = line.rstrip("\n\r") + if stripped: + if self._enabled: + self._output_lines.append(stripped) + else: + self.stream.write(stripped + "\n") + self.stream.flush() + + def update_message(self, message: str) -> None: + """Update the message displayed next to the spinner. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("initial", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.message + 'initial' + >>> spinner.update_message("updated") + >>> spinner.message + 'updated' + """ + self.message = message + + def _build_extra(self) -> dict[str, t.Any]: + """Return spinner-owned template tokens (colored bar, status_icon). + + These are separated from :meth:`BuildTree._context` to keep ANSI/color + concerns out of :class:`BuildTree`, which is also used in tests without + colors. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner._build_tree.on_event( + ... { + ... "event": "session_created", + ... "name": "s", + ... "window_total": 4, + ... "session_pane_total": 8, + ... } + ... ) + >>> extra = spinner._build_extra() + >>> extra["bar"] + '░░░░░░░░░░' + >>> extra["status_icon"] + '' + """ + tree = self._build_tree + win_tot = tree.window_total or 0 + spt = tree.session_pane_total or 0 + + # Composite fraction: (windows_done + pane_frac) / window_total + if win_tot > 0: + cw = tree.windows[-1] if tree.windows else None + pane_frac = 0.0 + if cw and not cw.done and cw.pane_total: + pane_frac = (cw.pane_num or 0) / cw.pane_total + composite_done = tree.windows_done + pane_frac + composite_bar = render_bar(int(composite_done * 100), win_tot * 100) + else: + composite_bar = render_bar(0, 0) + + pane_bar = render_bar(tree.session_panes_done, spt) + window_bar = render_bar(tree.windows_done, win_tot) + + def _color_bar(plain: str) -> str: + if not plain: + return plain + filled = plain.count("█") + empty = plain.count("░") + return self.colors.success("█" * filled) + self.colors.muted("░" * empty) + + return { + "session": self.colors.highlight(tree.session_name or ""), + "bar": _color_bar(composite_bar), + "pane_bar": _color_bar(pane_bar), + "window_bar": _color_bar(window_bar), + "status_icon": "", + } + + def on_build_event(self, event: dict[str, t.Any]) -> None: + """Forward build event to BuildTree and update spinner message inline. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("Loading", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.on_build_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 3, + ... }) + >>> spinner._build_tree.session_name + 'myapp' + """ + self._build_tree.on_event(event) + if self._progress_format is not None: + extra = self._build_extra() + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + # Only switch to template output once a window has started so that + # the session_created → window_started gap doesn't show empty brackets. + self.message = ( + rendered + if (rendered and self._build_tree.windows) + else self._base_message + ) + else: + self.message = self._build_tree.format_inline(self._base_message) + + def start(self) -> None: + """Start the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + """ + if not self._enabled: + return + + atexit.register(self._restore_cursor) + self._stop_event.clear() + self._thread = threading.Thread(target=self._spin, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + >>> spinner._thread is None + True + """ + if self._thread and self._thread.is_alive(): + self._stop_event.set() + self._thread.join() + self._thread = None + atexit.unregister(self._restore_cursor) + + def format_success(self) -> str: + """Render the success template with current build state. + + Uses :data:`SUCCESS_TEMPLATE` with colored ``{session}`` + (``highlight()``), ``{workspace_path}`` (``info()``), and + ``{summary}`` (``muted()``) from :meth:`BuildTree._context`. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream, + ... workspace_path="~/.tmuxp/myapp.yaml") + >>> spinner._build_tree.on_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 4, + ... }) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w1", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w2", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner.format_success() + 'Loaded workspace: myapp (~/.tmuxp/myapp.yaml) [2 win, 4 panes]' + """ + tree = self._build_tree + ctx = tree._context() + extra: dict[str, t.Any] = { + "session": self.colors.highlight(tree.session_name or ""), + "workspace_path": self.colors.info(ctx.get("workspace_path", "")), + "summary": self.colors.muted(ctx.get("summary", "")) + if ctx.get("summary") + else "", + } + return SUCCESS_TEMPLATE.format_map(_SafeFormatMap({**ctx, **extra})) + + def success(self, text: str | None = None) -> None: + """Stop the spinner and print a success line. + + Parameters + ---------- + text : str | None + The success message to display after the checkmark. + When ``None``, uses :meth:`format_success` if a progress format + is configured, otherwise falls back to ``_base_message``. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.success("done") + >>> "✓ done" in stream.getvalue() + True + + With no args and no progress format, falls back to base message: + + >>> stream2 = io.StringIO() + >>> spinner2 = Spinner("Loading...", color_mode=ColorMode.NEVER, stream=stream2) + >>> spinner2.success() + >>> "✓ Loading..." in stream2.getvalue() + True + """ + self.stop() + if text is None and self._progress_format is not None: + text = self.format_success() + elif text is None: + text = self._base_message + checkmark = self.colors.success("\u2713") + msg = f"{checkmark} {text}" + self.stream.write(f"{msg}\n") + self.stream.flush() + + def __enter__(self) -> Spinner: + self.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> t.Literal[False]: + self.stop() + return False diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index 97d2d8cd25..a92cfebbfa 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -3,6 +3,7 @@ from __future__ import annotations import locale +import logging import os import pathlib import typing as t @@ -13,7 +14,9 @@ from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir from ._colors import Colors, build_description, get_color_mode -from .utils import prompt_yes_no +from .utils import prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) CONVERT_DESCRIPTION = build_description( """ @@ -130,7 +133,7 @@ def command_convert( new_workspace, encoding=locale.getpreferredencoding(False), ) - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.success("New workspace file saved to ") + colors.info(str(PrivatePath(newfile))) + ".", diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index 284ee462b3..8a69f81bdd 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os import pathlib import platform @@ -17,8 +18,11 @@ from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, OutputMode from .utils import tmuxp_echo +logger = logging.getLogger(__name__) + DEBUG_INFO_DESCRIPTION = build_description( """ Print diagnostic information for debugging and issue reports. @@ -243,9 +247,6 @@ def command_debug_info( parser: argparse.ArgumentParser | None = None, ) -> None: """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" - import json - import sys - # Get output mode output_json = args.output_json if args else False @@ -259,7 +260,6 @@ def command_debug_info( # Output based on mode if output_json: # Single object, not wrapped in array - sys.stdout.write(json.dumps(data, indent=2) + "\n") - sys.stdout.flush() + OutputFormatter(OutputMode.JSON).emit_object(data) else: tmuxp_echo(_format_human_output(data, colors)) diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index 006ad6bb12..7308f2eba7 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import subprocess import typing as t @@ -10,6 +11,9 @@ from tmuxp.workspace.finders import find_workspace_file from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) EDIT_DESCRIPTION = build_description( """ @@ -59,7 +63,7 @@ def command_edit( workspace_file = find_workspace_file(workspace_file) sys_editor = os.environ.get("EDITOR", "vim") - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.muted("Opening ") + colors.info(str(PrivatePath(workspace_file))) + colors.muted(" in ") diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 9b48ebf01e..fa26569ca7 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -4,6 +4,7 @@ import argparse import locale +import logging import os import pathlib import sys @@ -19,7 +20,9 @@ from tmuxp.workspace.finders import get_workspace_dir from ._colors import Colors, build_description, get_color_mode -from .utils import prompt, prompt_choices, prompt_yes_no +from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) FREEZE_DESCRIPTION = build_description( """ @@ -141,7 +144,7 @@ def command_freeze( if not session: raise exc.SessionNotFound except TmuxpException as e: - print(colors.error(str(e))) # NOQA: T201 RUF100 + tmuxp_echo(colors.error(str(e))) return frozen_workspace = freezer.freeze(session) @@ -149,7 +152,7 @@ def command_freeze( configparser = ConfigReader(workspace) if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.format_separator(63) + "\n" + colors.muted("Freeze does its best to snapshot live tmux sessions.") @@ -163,7 +166,7 @@ def command_freeze( ) ): if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.muted("tmuxp has examples in JSON and YAML format at ") + colors.info("") + "\n" @@ -190,7 +193,7 @@ def command_freeze( color_mode=color_mode, ) if not args.force and os.path.exists(dest_prompt): - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.warning(f"{PrivatePath(dest_prompt)} exists.") + " " + colors.muted("Pick a new filename."), @@ -252,8 +255,9 @@ def extract_workspace_format( workspace, encoding=locale.getpreferredencoding(False), ) + logger.info("workspace saved", extra={"tmux_config_path": str(dest)}) if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", ) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index 63c2d24a30..df49c221ba 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -3,6 +3,7 @@ from __future__ import annotations import locale +import logging import os import pathlib import sys @@ -16,6 +17,8 @@ from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo +logger = logging.getLogger(__name__) + IMPORT_DESCRIPTION = build_description( """ Import workspaces from teamocil and tmuxinator configuration files. @@ -220,6 +223,10 @@ def import_config( encoding=locale.getpreferredencoding(False), ) + logger.info( + "workspace saved", + extra={"tmux_config_path": str(dest)}, + ) tmuxp_echo( colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", ) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 3e6edbd2b7..375cdb1b22 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import contextlib import importlib import logging import os @@ -21,8 +22,41 @@ from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir from ._colors import ColorMode, Colors, build_description, get_color_mode +from ._progress import ( + DEFAULT_OUTPUT_LINES, + SUCCESS_TEMPLATE, + Spinner, + _SafeFormatMap, + resolve_progress_format, +) from .utils import prompt_choices, prompt_yes_no, tmuxp_echo +logger = logging.getLogger(__name__) + + +@contextlib.contextmanager +def _silence_stream_handlers(logger_name: str = "tmuxp") -> t.Iterator[None]: + """Temporarily raise StreamHandler level to WARNING while spinner is active. + + INFO/DEBUG log records are diagnostics for aggregators, not user-facing output; + the spinner is the user-facing progress channel. Restores original levels on exit. + """ + _log = logging.getLogger(logger_name) + saved: list[tuple[logging.StreamHandler[t.Any], int]] = [ + (h, h.level) + for h in _log.handlers + if isinstance(h, logging.StreamHandler) + and not isinstance(h, logging.FileHandler) + ] + for h, _ in saved: + h.setLevel(logging.WARNING) + try: + yield + finally: + for h, level in saved: + h.setLevel(level) + + LOAD_DESCRIPTION = build_description( """ Load tmuxp workspace file(s) and create or attach to a tmux session. @@ -74,6 +108,10 @@ class CLILoadNamespace(argparse.Namespace): colors: CLIColorsLiteral | None color: CLIColorModeLiteral log_file: str | None + log_level: str + progress_format: str | None + panel_lines: int | None + no_progress: bool def load_plugins( @@ -120,6 +158,7 @@ def load_plugins( module_name = ".".join(module_name[:-1]) plugin_name = plugin.split(".")[-1] except AttributeError as error: + logger.debug("plugin load failed", exc_info=True) tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -136,12 +175,16 @@ def load_plugins( default=True, color_mode=colors.mode, ): + logger.warning( + "plugin version constraint not met, user declined skip", + ) tmuxp_echo( colors.warning("[Not Skipping]") + " Plugin versions constraint not met. Exiting...", ) sys.exit(1) except (ImportError, AttributeError) as error: + logger.debug("plugin import failed", exc_info=True) tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -175,7 +218,11 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: plugin.reattach(builder.session) proc = builder.session.cmd("display-message", "-p", "'#S'") for line in proc.stdout: - print(colors.info(line) if colors else line) # NOQA: T201 RUF100 + tmuxp_echo(colors.info(line) if colors else line) + logger.debug( + "reattach display-message output", + extra={"tmux_stdout": [line.strip()]}, + ) if "TMUX" in os.environ: builder.session.switch_client() @@ -184,7 +231,11 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: builder.session.attach() -def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: +def _load_attached( + builder: WorkspaceBuilder, + detached: bool, + pre_attach_hook: t.Callable[[], None] | None = None, +) -> None: """ Load workspace in new session. @@ -192,10 +243,16 @@ def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: ---------- builder: :class:`workspace.builder.WorkspaceBuilder` detached : bool + pre_attach_hook : callable, optional + called after build, before attach/switch_client; use to stop the spinner + so its cleanup sequences don't appear inside the tmux pane. """ builder.build() assert builder.session is not None + if pre_attach_hook is not None: + pre_attach_hook() + if "TMUX" in os.environ: # tmuxp ran from inside tmux # unset TMUX, save it, e.g. '/tmp/tmux-1000/default,30668,0' tmux_env = os.environ.pop("TMUX") @@ -207,7 +264,11 @@ def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: builder.session.attach() -def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: +def _load_detached( + builder: WorkspaceBuilder, + colors: Colors | None = None, + pre_output_hook: t.Callable[[], None] | None = None, +) -> None: """ Load workspace in new session but don't attach. @@ -216,13 +277,19 @@ def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> N builder: :class:`workspace.builder.WorkspaceBuilder` colors : Colors | None Optional Colors instance for styled output. + pre_output_hook : Callable | None + Called after build but before printing, e.g. to stop a spinner. """ builder.build() assert builder.session is not None + if pre_output_hook is not None: + pre_output_hook() + msg = "Session created in detached state." - print(colors.info(msg) if colors else msg) # NOQA: T201 RUF100 + tmuxp_echo(colors.info(msg) if colors else msg) + logger.info("session created in detached state") def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: @@ -252,6 +319,123 @@ def _setup_plugins(builder: WorkspaceBuilder) -> Session: return builder.session +def _dispatch_build( + builder: WorkspaceBuilder, + detached: bool, + append: bool, + answer_yes: bool, + cli_colors: Colors, + pre_attach_hook: t.Callable[[], None] | None = None, + on_error_hook: t.Callable[[], None] | None = None, + pre_prompt_hook: t.Callable[[], None] | None = None, +) -> Session | None: + """Dispatch the build to the correct load path and handle errors. + + Handles the detached/attached/append switching logic and the + ``TmuxpException`` error-recovery prompt. Extracted so the + spinner-enabled and spinner-disabled paths share one implementation. + + Parameters + ---------- + builder : WorkspaceBuilder + Configured workspace builder. + detached : bool + Load session in detached state. + append : bool + Append windows to the current session. + answer_yes : bool + Skip interactive prompts. + cli_colors : Colors + Colors instance for styled output. + pre_attach_hook : callable, optional + Called before attach/switch_client (e.g. stop spinner). + on_error_hook : callable, optional + Called before showing the error-recovery prompt (e.g. stop spinner). + pre_prompt_hook : callable, optional + Called before any interactive prompt (e.g. stop spinner so ANSI + escape sequences don't garble the terminal during user input). + + Returns + ------- + Session | None + The built session, or ``None`` if the user killed it on error. + + Examples + -------- + >>> from tmuxp.cli.load import _dispatch_build + >>> callable(_dispatch_build) + True + """ + try: + if detached: + _load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook) + return _setup_plugins(builder) + + if append: + if "TMUX" in os.environ: # tmuxp ran from inside tmux + _load_append_windows_to_current_session(builder) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + return _setup_plugins(builder) + + # append and answer_yes have no meaning if specified together + if answer_yes: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + return _setup_plugins(builder) + + if "TMUX" in os.environ: # tmuxp ran from inside tmux + if pre_prompt_hook is not None: + pre_prompt_hook() + msg = ( + "Already inside TMUX, switch to session? yes/no\n" + "Or (a)ppend windows in the current active session?\n[y/n/a]" + ) + options = ["y", "n", "a"] + choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) + + if choice == "y": + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + elif choice == "a": + _load_append_windows_to_current_session(builder) + else: + _load_detached(builder, cli_colors) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + except exc.TmuxpException as e: + if on_error_hook is not None: + on_error_hook() + logger.debug("workspace build failed", exc_info=True) + tmuxp_echo(cli_colors.error("[Error]") + f" {e}") + + choice = prompt_choices( + cli_colors.error("Error loading workspace.") + + " (k)ill, (a)ttach, (d)etach?", + choices=["k", "a", "d"], + default="k", + color_mode=cli_colors.mode, + ) + + if choice == "k": + if builder.session is not None: + builder.session.kill() + tmuxp_echo(cli_colors.muted("Session killed.")) + logger.info("session killed by user after build error") + elif choice == "a": + _reattach(builder, cli_colors) + else: + sys.exit() + return None + finally: + builder.on_progress = None + builder.on_before_script = None + builder.on_script_output = None + builder.on_build_event = None + + return _setup_plugins(builder) + + def load_workspace( workspace_file: StrPath, socket_name: str | None = None, @@ -263,6 +447,9 @@ def load_workspace( answer_yes: bool = False, append: bool = False, cli_colors: Colors | None = None, + progress_format: str | None = None, + panel_lines: int | None = None, + no_progress: bool = False, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -288,6 +475,15 @@ def load_workspace( Default False. cli_colors : Colors, optional Colors instance for CLI output formatting. If None, uses AUTO mode. + progress_format : str, optional + Spinner format preset name or custom format string with tokens. + panel_lines : int, optional + Number of script-output lines shown in the spinner panel. + Defaults to the :class:`~tmuxp.cli._progress.Spinner` default (3). + Override via ``TMUXP_PROGRESS_LINES`` environment variable. + no_progress : bool + Disable the progress spinner entirely. Default False. + Also disabled when ``TMUXP_PROGRESS=0``. Notes ----- @@ -344,11 +540,17 @@ def load_workspace( if isinstance(workspace_file, (str, os.PathLike)): workspace_file = pathlib.Path(workspace_file) - tmuxp_echo( - cli_colors.info("[Loading]") - + " " - + cli_colors.highlight(str(PrivatePath(workspace_file))), + logger.info( + "loading workspace", + extra={"tmux_config_path": str(workspace_file)}, ) + _progress_disabled = no_progress or os.getenv("TMUXP_PROGRESS", "1") == "0" + if _progress_disabled: + tmuxp_echo( + cli_colors.info("[Loading]") + + " " + + cli_colors.highlight(str(PrivatePath(workspace_file))), + ) # ConfigReader allows us to open a yaml or json file as a dict raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} @@ -375,13 +577,18 @@ def load_workspace( shutil.which("tmux") # raise exception if tmux not found - try: # load WorkspaceBuilder object for tmuxp workspace / tmux server + # WorkspaceBuilder creation — outside spinner so plugin prompts are safe + try: builder = WorkspaceBuilder( session_config=expanded_workspace, plugins=load_plugins(expanded_workspace, colors=cli_colors), server=t, ) except exc.EmptyWorkspaceException: + logger.warning( + "workspace file is empty", + extra={"tmux_config_path": str(workspace_file)}, + ) tmuxp_echo( cli_colors.warning("[Warning]") + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", @@ -390,7 +597,7 @@ def load_workspace( session_name = expanded_workspace["session_name"] - # if the session already exists, prompt the user to attach + # Session-exists check — outside spinner so prompt_yes_no is safe if builder.session_exists(session_name) and not append: if not detached and ( answer_yes @@ -403,65 +610,96 @@ def load_workspace( _reattach(builder, cli_colors) return None - try: - if detached: - _load_detached(builder, cli_colors) - return _setup_plugins(builder) - - if append: - if "TMUX" in os.environ: # tmuxp ran from inside tmux - _load_append_windows_to_current_session(builder) - else: - _load_attached(builder, detached) - - return _setup_plugins(builder) - - # append and answer_yes have no meaning if specified together - if answer_yes: - _load_attached(builder, detached) - return _setup_plugins(builder) - - if "TMUX" in os.environ: # tmuxp ran from inside tmux - msg = ( - "Already inside TMUX, switch to session? yes/no\n" - "Or (a)ppend windows in the current active session?\n[y/n/a]" + if _progress_disabled: + _private_path = str(PrivatePath(workspace_file)) + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + ) + if result is not None: + summary = "" + try: + win_count = len(result.windows) + pane_count = sum(len(w.panes) for w in result.windows) + summary_parts: list[str] = [] + if win_count: + summary_parts.append(f"{win_count} win") + if pane_count: + summary_parts.append(f"{pane_count} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + except Exception: + logger.debug("session gone before summary", exc_info=True) + ctx = { + "session": cli_colors.highlight(session_name), + "workspace_path": cli_colors.info(_private_path), + "summary": cli_colors.muted(summary) if summary else "", + } + checkmark = cli_colors.success("\u2713") + tmuxp_echo( + f"{checkmark} {SUCCESS_TEMPLATE.format_map(_SafeFormatMap(ctx))}" ) - options = ["y", "n", "a"] - choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) - - if choice == "y": - _load_attached(builder, detached) - elif choice == "a": - _load_append_windows_to_current_session(builder) - else: - _load_detached(builder, cli_colors) - else: - _load_attached(builder, detached) - - except exc.TmuxpException as e: - import traceback - - tmuxp_echo(traceback.format_exc()) - tmuxp_echo(cli_colors.error("[Error]") + f" {e}") + return result - choice = prompt_choices( - cli_colors.error("Error loading workspace.") - + " (k)ill, (a)ttach, (d)etach?", - choices=["k", "a", "d"], - default="k", - color_mode=cli_colors.mode, + # Spinner wraps only the actual build phase + _progress_fmt = resolve_progress_format( + progress_format + if progress_format is not None + else os.getenv("TMUXP_PROGRESS_FORMAT", "default") + ) + _panel_lines_env = os.getenv("TMUXP_PROGRESS_LINES") + if _panel_lines_env: + try: + _panel_lines_env_int: int | None = int(_panel_lines_env) + except ValueError: + _panel_lines_env_int = None + else: + _panel_lines_env_int = None + _panel_lines = panel_lines if panel_lines is not None else _panel_lines_env_int + _private_path = str(PrivatePath(workspace_file)) + _spinner = Spinner( + message=( + f"Loading workspace: {cli_colors.highlight(session_name)} ({_private_path})" + ), + color_mode=cli_colors.mode, + progress_format=_progress_fmt, + output_lines=_panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES, + workspace_path=_private_path, + ) + _success_emitted = False + + def _emit_success() -> None: + nonlocal _success_emitted + if _success_emitted: + return + _success_emitted = True + _spinner.success() + + with ( + _silence_stream_handlers(), + _spinner as spinner, + ): + builder.on_build_event = spinner.on_build_event + _resolved_panel = ( + _panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES ) - - if choice == "k": - if builder.session is not None: - builder.session.kill() - tmuxp_echo(cli_colors.muted("Session killed.")) - elif choice == "a": - _reattach(builder, cli_colors) - else: - sys.exit() - - return _setup_plugins(builder) + if _resolved_panel != 0: + builder.on_script_output = spinner.add_output_line + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + pre_attach_hook=_emit_success, + on_error_hook=spinner.stop, + pre_prompt_hook=spinner.stop, + ) + if result is not None: + _emit_success() + return result def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: @@ -546,6 +784,42 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP help="file to log errors/output to", ) + parser.add_argument( + "--progress-format", + metavar="FORMAT", + dest="progress_format", + default=None, + help=( + "Spinner line format: preset name " + "(default, minimal, window, pane, verbose) " + "or a format string with tokens " + "{session}, {window}, {progress}, {window_progress}, {pane_progress}, etc. " + "Env: TMUXP_PROGRESS_FORMAT" + ), + ) + + parser.add_argument( + "--progress-lines", + metavar="N", + dest="panel_lines", + type=int, + default=None, + help=( + "Number of script-output lines shown in the spinner panel (default: 3). " + "0 hides the panel entirely (script output goes to stdout). " + "-1 shows unlimited lines (capped to terminal height). " + "Env: TMUXP_PROGRESS_LINES" + ), + ) + + parser.add_argument( + "--no-progress", + dest="no_progress", + action="store_true", + default=False, + help=("Disable the animated progress spinner. Env: TMUXP_PROGRESS=0"), + ) + try: import shtab @@ -590,12 +864,7 @@ def command_load( cli_colors = Colors(get_color_mode(args.color)) if args.log_file: - logfile_handler = logging.FileHandler(args.log_file) - logfile_handler.setFormatter(log.LogFormatter()) - # Add handler to tmuxp root logger to capture all tmuxp log messages - tmuxp_logger = logging.getLogger("tmuxp") - tmuxp_logger.setLevel(logging.INFO) # Ensure logger level allows INFO - tmuxp_logger.addHandler(logfile_handler) + log.setup_log_file(args.log_file, args.log_level) if args.workspace_files is None or len(args.workspace_files) == 0: tmuxp_echo(cli_colors.error("Enter at least one config")) @@ -632,4 +901,7 @@ def command_load( answer_yes=args.answer_yes or False, append=args.append or False, cli_colors=cli_colors, + progress_format=args.progress_format, + panel_lines=args.panel_lines, + no_progress=args.no_progress, ) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index de8ec2dcd2..cd1ff5da75 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -29,6 +29,7 @@ import argparse import datetime import json +import logging import pathlib import typing as t @@ -46,6 +47,8 @@ from ._colors import Colors, build_description, get_color_mode from ._output import OutputFormatter, OutputMode, get_output_mode +logger = logging.getLogger(__name__) + LS_DESCRIPTION = build_description( """ List workspace files in the tmuxp configuration directory. @@ -567,9 +570,6 @@ def command_ls( -------- >>> # command_ls() lists workspaces from cwd/parents and ~/.tmuxp/ """ - import json - import sys - # Get color mode from args or default to AUTO color_mode = get_color_mode(args.color if args else None) colors = Colors(color_mode) @@ -612,8 +612,7 @@ def command_ls( "workspaces": [], "global_workspace_dirs": global_dir_candidates, } - sys.stdout.write(json.dumps(output_data, indent=2) + "\n") - sys.stdout.flush() + formatter.emit_object(output_data) # NDJSON: just output nothing for empty workspaces return @@ -623,8 +622,7 @@ def command_ls( "workspaces": workspaces, "global_workspace_dirs": global_dir_candidates, } - sys.stdout.write(json.dumps(output_data, indent=2) + "\n") - sys.stdout.flush() + formatter.emit_object(output_data) return # Human and NDJSON output diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index 93368fd04f..3be4cb1974 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -25,6 +25,7 @@ import argparse import json +import logging import pathlib import re import typing as t @@ -39,6 +40,8 @@ from ._colors import Colors, build_description, get_color_mode from ._output import OutputFormatter, get_output_mode +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index e62f0a0758..57a5ae8e4b 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os import pathlib import typing as t @@ -13,6 +14,9 @@ from tmuxp._compat import PY3, PYMINOR from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) SHELL_DESCRIPTION = build_description( """ @@ -222,7 +226,7 @@ def command_shell( ): from tmuxp._compat import breakpoint as tmuxp_breakpoint - print( # NOQA: T201 RUF100 + tmuxp_echo( cli_colors.muted("Launching ") + cli_colors.highlight("pdb", bold=False) + cli_colors.muted(" shell..."), @@ -233,7 +237,7 @@ def command_shell( from tmuxp.shell import launch shell_name = args.shell or "best" - print( # NOQA: T201 RUF100 + tmuxp_echo( cli_colors.muted("Launching ") + cli_colors.highlight(shell_name, bold=False) + cli_colors.muted(" shell for session ") diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 58896deb0f..034c98b2ed 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t from tmuxp._internal.colors import ( @@ -15,6 +16,8 @@ from tmuxp._internal.private_path import PrivatePath from tmuxp.log import tmuxp_echo +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -215,7 +218,7 @@ def prompt_choices( return None if rv in choices_: return rv - print( + tmuxp_echo( colors.warning(f"Invalid choice '{rv}'. ") + f"Please choose from: {', '.join(choices_)}" ) diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py index 525599270f..545038d7ca 100644 --- a/src/tmuxp/exc.py +++ b/src/tmuxp/exc.py @@ -2,10 +2,14 @@ from __future__ import annotations +import logging + from libtmux._internal.query_list import ObjectDoesNotExist from ._compat import implements_to_string +logger = logging.getLogger(__name__) + class TmuxpException(Exception): """Base Exception for Tmuxp Errors.""" diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index e4429eda6a..d4b9684957 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -4,29 +4,57 @@ from __future__ import annotations import logging +import sys import time import typing as t -from colorama import Fore, Style +from tmuxp._internal.colors import _ansi_colors, _ansi_reset_all -from tmuxp._internal.colors import unstyle +logger = logging.getLogger(__name__) + +_ANSI_RESET = _ansi_reset_all # "\033[0m" +_ANSI_BRIGHT = "\033[1m" +_ANSI_FG_RESET = "\033[39m" LEVEL_COLORS = { - "DEBUG": Fore.BLUE, # Blue - "INFO": Fore.GREEN, # Green - "WARNING": Fore.YELLOW, - "ERROR": Fore.RED, - "CRITICAL": Fore.RED, + "DEBUG": f"\033[{_ansi_colors['blue']}m", + "INFO": f"\033[{_ansi_colors['green']}m", + "WARNING": f"\033[{_ansi_colors['yellow']}m", + "ERROR": f"\033[{_ansi_colors['red']}m", + "CRITICAL": f"\033[{_ansi_colors['red']}m", } -LOG_LEVELS = { - "CRITICAL": 50, - "ERROR": 40, - "WARNING": 30, - "INFO": 20, - "DEBUG": 10, - "NOTSET": 0, -} + +class TmuxpLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] + """LoggerAdapter that merges extra dictionary on Python < 3.13. + + Follows the portable pattern to avoid repeating the same `extra` on every call + while preserving the ability to add per-call `extra` kwargs. + + Examples + -------- + >>> adapter = TmuxpLoggerAdapter( + ... logging.getLogger("test"), + ... {"tmux_session": "my-session"}, + ... ) + >>> msg, kwargs = adapter.process("hello %s", {"extra": {"tmux_window": "editor"}}) + >>> msg + 'hello %s' + >>> kwargs["extra"]["tmux_session"] + 'my-session' + >>> kwargs["extra"]["tmux_window"] + 'editor' + """ + + def process( + self, msg: t.Any, kwargs: t.MutableMapping[str, t.Any] + ) -> tuple[t.Any, t.MutableMapping[str, t.Any]]: + """Merge extra dictionary on Python < 3.13.""" + extra = dict(self.extra) if self.extra else {} + if "extra" in kwargs: + extra.update(kwargs["extra"]) + kwargs["extra"] = extra + return msg, kwargs def setup_logger( @@ -43,10 +71,17 @@ def setup_logger( logger instance for tmuxp """ if not logger: # if no logger exists, make one - logger = logging.getLogger() + logger = logging.getLogger("tmuxp") + + has_handlers = any(not isinstance(h, logging.NullHandler) for h in logger.handlers) + + if not has_handlers: # setup logger handlers + channel = logging.StreamHandler() + formatter = DebugLogFormatter() if level == "DEBUG" else LogFormatter() + channel.setFormatter(formatter) + logger.addHandler(channel) - if not logger.handlers: # setup logger handlers - logger.setLevel(level) + logger.setLevel(level) def set_style( @@ -86,27 +121,27 @@ def template( str Template for logger message. """ - reset = Style.RESET_ALL + reset = _ANSI_RESET levelname = set_style( "(%(levelname)s)", stylized, - style_before=(LEVEL_COLORS.get(record.levelname, "") + Style.BRIGHT), - style_after=Style.RESET_ALL, + style_before=(LEVEL_COLORS.get(record.levelname, "") + _ANSI_BRIGHT), + style_after=_ANSI_RESET, suffix=" ", ) asctime = set_style( "%(asctime)s", stylized, - style_before=(Fore.BLACK + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), + style_before=(f"\033[{_ansi_colors['black']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), prefix="[", suffix="]", ) name = set_style( "%(name)s", stylized, - style_before=(Fore.WHITE + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), + style_before=(f"\033[{_ansi_colors['white']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), prefix=" ", suffix=" ", ) @@ -126,7 +161,7 @@ def format(self, record: logging.LogRecord) -> str: except Exception as e: record.message = f"Bad message ({e!r}): {record.__dict__!r}" - date_format = "%H:%m:%S" + date_format = "%H:%M:%S" formatting = self.converter(record.created) record.asctime = time.strftime(date_format, formatting) @@ -156,42 +191,41 @@ def debug_log_template( str Log template. """ - reset = Style.RESET_ALL + reset = _ANSI_RESET levelname = ( LEVEL_COLORS.get(record.levelname, "") - + Style.BRIGHT + + _ANSI_BRIGHT + "(%(levelname)1.1s)" - + Style.RESET_ALL + + _ANSI_RESET + " " ) asctime = ( "[" - + Fore.BLACK - + Style.DIM - + Style.BRIGHT + + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + "%(asctime)s" - + Fore.RESET - + Style.RESET_ALL + + _ANSI_FG_RESET + + _ANSI_RESET + "]" ) name = ( " " - + Fore.WHITE - + Style.DIM - + Style.BRIGHT + + f"\033[{_ansi_colors['white']}m" + + _ANSI_BRIGHT + "%(name)s" - + Fore.RESET - + Style.RESET_ALL + + _ANSI_FG_RESET + + _ANSI_RESET + " " ) - module_funcName = Fore.GREEN + Style.BRIGHT + "%(module)s.%(funcName)s()" + module_funcName = ( + f"\033[{_ansi_colors['green']}m" + _ANSI_BRIGHT + "%(module)s.%(funcName)s()" + ) lineno = ( - Fore.BLACK - + Style.DIM - + Style.BRIGHT + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + ":" - + Style.RESET_ALL - + Fore.CYAN + + _ANSI_RESET + + f"\033[{_ansi_colors['cyan']}m" + "%(lineno)d" ) @@ -204,42 +238,61 @@ class DebugLogFormatter(LogFormatter): template = debug_log_template -# Use tmuxp root logger so messages propagate to CLI handlers -_echo_logger = logging.getLogger("tmuxp") +def setup_log_file(log_file: str, level: str = "INFO") -> None: + """Attach a file handler to the tmuxp logger. + + Parameters + ---------- + log_file : str + Path to the log file. + level : str + Log level name (e.g. "DEBUG", "INFO"). Selects formatter and sets + handler filtering level. + + Examples + -------- + >>> import tempfile, os, logging + >>> f = tempfile.NamedTemporaryFile(suffix=".log", delete=False) + >>> f.close() + >>> setup_log_file(f.name, level="INFO") + >>> tmuxp_logger = logging.getLogger("tmuxp") + >>> tmuxp_logger.handlers = [ + ... h for h in tmuxp_logger.handlers if not isinstance(h, logging.FileHandler) + ... ] + >>> os.unlink(f.name) + """ + handler = logging.FileHandler(log_file) + formatter = DebugLogFormatter() if level.upper() == "DEBUG" else LogFormatter() + handler.setFormatter(formatter) + handler_level = getattr(logging, level.upper()) + handler.setLevel(handler_level) + tmuxp_logger = logging.getLogger("tmuxp") + tmuxp_logger.addHandler(handler) + if tmuxp_logger.level == logging.NOTSET or tmuxp_logger.level > handler_level: + tmuxp_logger.setLevel(handler_level) def tmuxp_echo( message: str | None = None, - log_level: str = "INFO", - style_log: bool = False, + file: t.TextIO | None = None, ) -> None: - """Combine logging.log and print for CLI output. + """Print user-facing CLI output. Parameters ---------- message : str | None - Message to log and print. If None, does nothing. - log_level : str - Log level to use (DEBUG, INFO, WARNING, ERROR, CRITICAL). - Default is INFO. - style_log : bool - If True, preserve ANSI styling in log output. - If False, strip ANSI codes from log output. Default is False. + Message to print. If None, does nothing. + file : t.TextIO | None + Output stream. Defaults to sys.stdout. Examples -------- - >>> tmuxp_echo("Session loaded") # doctest: +ELLIPSIS + >>> tmuxp_echo("Session loaded") Session loaded - >>> tmuxp_echo("Warning message", log_level="WARNING") # doctest: +ELLIPSIS + >>> tmuxp_echo("Warning message") Warning message """ if message is None: return - - if style_log: - _echo_logger.log(LOG_LEVELS[log_level], message) - else: - _echo_logger.log(LOG_LEVELS[log_level], unstyle(message)) - - print(message) + print(message, file=file or sys.stdout) diff --git a/src/tmuxp/plugin.py b/src/tmuxp/plugin.py index 84be58d96d..fa153c7771 100644 --- a/src/tmuxp/plugin.py +++ b/src/tmuxp/plugin.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import libtmux @@ -11,6 +12,8 @@ from .__about__ import __version__ from .exc import TmuxpPluginException +logger = logging.getLogger(__name__) + #: Minimum version of tmux required to run tmuxp TMUX_MIN_VERSION = "3.2" @@ -181,6 +184,7 @@ def __init__(self, **kwargs: Unpack[PluginConfigSchema]) -> None: def _version_check(self) -> None: """Check all dependency versions for compatibility.""" + logger.debug("checking version constraints for %s", self.plugin_name) for dep, constraints in self.version_constraints.items(): assert isinstance(constraints, dict) try: diff --git a/src/tmuxp/shell.py b/src/tmuxp/shell.py index aea0e92020..3d56655ba5 100644 --- a/src/tmuxp/shell.py +++ b/src/tmuxp/shell.py @@ -106,14 +106,17 @@ def has_bpython() -> bool: def detect_best_shell() -> CLIShellLiteral: """Return the best, most feature-rich shell available.""" if has_ptipython(): - return "ptipython" - if has_ptpython(): - return "ptpython" - if has_ipython(): - return "ipython" - if has_bpython(): - return "bpython" - return "code" + shell: CLIShellLiteral = "ptipython" + elif has_ptpython(): + shell = "ptpython" + elif has_ipython(): + shell = "ipython" + elif has_bpython(): + shell = "bpython" + else: + shell = "code" + logger.debug("detected shell: %s", shell) + return shell def get_bpython( diff --git a/src/tmuxp/types.py b/src/tmuxp/types.py index 4d267ae2ee..4aa5031239 100644 --- a/src/tmuxp/types.py +++ b/src/tmuxp/types.py @@ -9,8 +9,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from os import PathLike diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 490ee7e940..152b1f6c06 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -10,6 +10,7 @@ import typing as t from . import exc +from .log import tmuxp_echo if t.TYPE_CHECKING: import pathlib @@ -27,8 +28,12 @@ def run_before_script( script_file: str | pathlib.Path, cwd: pathlib.Path | None = None, + on_line: t.Callable[[str], None] | None = None, ) -> int: - """Execute shell script, ``tee``-ing output to both terminal (if TTY) and buffer.""" + """Execute shell script, streaming output to callback or terminal (if TTY). + + Output is buffered and optionally forwarded via the ``on_line`` callback. + """ script_cmd = shlex.split(str(script_file)) try: @@ -67,13 +72,17 @@ def run_before_script( if line_out and line_out.strip(): out_buffer.append(line_out) - if is_out_tty: + if on_line is not None: + on_line(line_out) + elif is_out_tty: sys.stdout.write(line_out) sys.stdout.flush() if line_err and line_err.strip(): err_buffer.append(line_err) - if is_err_tty: + if on_line is not None: + on_line(line_err) + elif is_err_tty: sys.stderr.write(line_err) sys.stderr.flush() @@ -110,7 +119,9 @@ def oh_my_zsh_auto_title() -> None: or os.environ.get("DISABLE_AUTO_TITLE") == "false" ) ): - print( # NOQA: T201 RUF100 + logger.warning("oh-my-zsh DISABLE_AUTO_TITLE not set") + tmuxp_echo( + "oh-my-zsh DISABLE_AUTO_TITLE not set.\n\n" "Please set:\n\n" "\texport DISABLE_AUTO_TITLE='true'\n\n" "in ~/.zshrc or where your zsh profile is stored.\n" @@ -189,8 +200,14 @@ def get_pane(window: Window, current_pane: Pane | None = None) -> Pane: pane = window.panes.get(pane_id=current_pane.pane_id) else: pane = window.active_pane - except exc.TmuxpException as e: - print(e) # NOQA: T201 RUF100 + except Exception as e: + logger.debug( + "pane lookup failed", + extra={"tmux_pane": str(current_pane) if current_pane else ""}, + ) + if current_pane: + raise exc.PaneNotFound(str(current_pane)) from e + raise exc.PaneNotFound from e if pane is None: if current_pane: diff --git a/src/tmuxp/workspace/__init__.py b/src/tmuxp/workspace/__init__.py index ac87e57f62..2b3996b050 100644 --- a/src/tmuxp/workspace/__init__.py +++ b/src/tmuxp/workspace/__init__.py @@ -1 +1,7 @@ """tmuxp workspace functionality.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 24c93b3c24..728b477963 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -15,6 +15,7 @@ from libtmux.window import Window from tmuxp import exc +from tmuxp.log import TmuxpLoggerAdapter from tmuxp.util import get_current_pane, run_before_script if t.TYPE_CHECKING: @@ -168,6 +169,101 @@ class WorkspaceBuilder: >>> sorted([window.name for window in session.windows]) ['editor', 'logging', 'test'] + **Progress callback:** + + >>> calls: list[str] = [] + >>> progress_cfg = { + ... "session_name": "progress-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=progress_cfg, + ... server=server, + ... on_progress=calls.append, + ... ) + >>> builder.build() + >>> "Workspace built" in calls + True + + **Before-script hook:** + + >>> hook_calls: list[bool] = [] + >>> no_script_cfg = { + ... "session_name": "hook-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=no_script_cfg, + ... server=server, + ... on_before_script=lambda: hook_calls.append(True), + ... ) + >>> builder.build() + >>> hook_calls # no before_script in config, callback not fired + [] + + **Script output hook:** + + >>> script_lines: list[str] = [] + >>> no_script_cfg2 = { + ... "session_name": "script-output-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=no_script_cfg2, + ... server=server, + ... on_script_output=script_lines.append, + ... ) + >>> builder.build() + >>> script_lines # no before_script in config, callback not fired + [] + + **Build events hook:** + + >>> events: list[dict] = [] + >>> event_cfg = { + ... "session_name": "events-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=event_cfg, + ... server=server, + ... on_build_event=events.append, + ... ) + >>> builder.build() + >>> [e["event"] for e in events] + ['session_created', 'window_started', 'pane_creating', + 'window_done', 'workspace_built'] + >>> next(e for e in events if e["event"] == "session_created")["session_pane_total"] + 1 + + **Build events with before_script:** + + ``before_script_started`` fires before the script runs; + ``before_script_done`` fires in ``finally`` (success or failure). + + >>> script_events: list[dict] = [] + >>> script_event_cfg = { + ... "session_name": "script-events-demo", + ... "before_script": "echo hello", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=script_event_cfg, + ... server=server, + ... on_build_event=script_events.append, + ... ) + >>> builder.build() + >>> event_names = [e["event"] for e in script_events] + >>> "before_script_started" in event_names + True + >>> "before_script_done" in event_names + True + >>> bs_start = event_names.index("before_script_started") + >>> bs_done = event_names.index("before_script_done") + >>> win_start = event_names.index("window_started") + >>> bs_start < bs_done < win_start + True + The normal phase of loading is: 1. Load JSON / YAML file via :class:`pathlib.Path`:: @@ -210,12 +306,20 @@ class WorkspaceBuilder: server: Server _session: Session | None session_name: str + on_progress: t.Callable[[str], None] | None + on_before_script: t.Callable[[], None] | None + on_script_output: t.Callable[[str], None] | None + on_build_event: t.Callable[[dict[str, t.Any]], None] | None def __init__( self, session_config: dict[str, t.Any], server: Server, plugins: list[t.Any] | None = None, + on_progress: t.Callable[[str], None] | None = None, + on_before_script: t.Callable[[], None] | None = None, + on_script_output: t.Callable[[str], None] | None = None, + on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None, ) -> None: """Initialize workspace loading. @@ -230,6 +334,23 @@ def __init__( server : :class:`libtmux.Server` tmux server to build session in + on_progress : callable, optional + callback for progress updates during building + + on_before_script : callable, optional + called just before ``before_script`` runs; use to clear the terminal + (e.g. stop a spinner) so script output is not interleaved + + on_script_output : callable, optional + called with each output line from ``before_script`` subprocess; when + set, raw TTY tee is suppressed so the caller can route lines to a + live panel instead + + on_build_event : callable, optional + called with a dict event at each structural build milestone (session + created, window started/done, pane creating, workspace built); used + by the CLI to render a live session tree + Notes ----- TODO: Initialize :class:`libtmux.Session` from here, in @@ -247,6 +368,10 @@ def __init__( self.session_config = session_config self.plugins = plugins + self.on_progress = on_progress + self.on_before_script = on_before_script + self.on_script_output = on_script_output + self.on_build_event = on_build_event if self.server is not None and self.session_exists( session_name=self.session_config["session_name"], @@ -331,7 +456,26 @@ def build(self, session: Session | None = None, append: bool = False) -> None: assert session is not None assert session.name is not None + if self.on_progress: + self.on_progress(f"Session created: {session.name}") + self._session = session + if self.on_build_event: + self.on_build_event( + { + "event": "session_created", + "name": session.name, + "window_total": len(self.session_config["windows"]), + "session_pane_total": sum( + len(w.get("panes", [])) for w in self.session_config["windows"] + ), + } + ) + _log = TmuxpLoggerAdapter( + logger, + {"tmux_session": self.session_config["session_name"]}, + ) + _log.info("session created") assert session.server is not None @@ -348,6 +492,10 @@ def build(self, session: Session | None = None, append: bool = False) -> None: focus = None if "before_script" in self.session_config: + if self.on_before_script: + self.on_before_script() + if self.on_build_event: + self.on_build_event({"event": "before_script_started"}) try: cwd = None @@ -355,10 +503,28 @@ def build(self, session: Session | None = None, append: bool = False) -> None: # session start directory, if it exists. if "start_directory" in self.session_config: cwd = self.session_config["start_directory"] - run_before_script(self.session_config["before_script"], cwd=cwd) + _log.debug( + "running before script", + ) + run_before_script( + self.session_config["before_script"], + cwd=cwd, + on_line=self.on_script_output, + ) except Exception: + _log.error( + "before script failed", + extra={ + "tmux_config_path": str( + self.session_config["before_script"], + ), + }, + ) self.session.kill() raise + finally: + if self.on_build_event: + self.on_build_event({"event": "before_script_done"}) if "options" in self.session_config: for option, value in self.session_config["options"].items(): @@ -397,9 +563,18 @@ def build(self, session: Session | None = None, append: bool = False) -> None: if focus_pane: focus_pane.select() + if self.on_build_event: + self.on_build_event({"event": "window_done"}) + if focus: focus.select() + if self.on_progress: + self.on_progress("Workspace built") + _log.info("workspace built") + if self.on_build_event: + self.on_build_event({"event": "workspace_built"}) + def iter_create_windows( self, session: Session, @@ -431,6 +606,17 @@ def iter_create_windows( ): window_name = window_config.get("window_name", None) + if self.on_progress: + self.on_progress(f"Creating window: {window_name or window_iterator}") + if self.on_build_event: + self.on_build_event( + { + "event": "window_started", + "name": window_name or str(window_iterator), + "pane_total": len(window_config["panes"]), + } + ) + is_first_window_pass = self.first_window_pass( window_iterator, session, @@ -469,6 +655,14 @@ def iter_create_windows( environment=environment, ) assert isinstance(window, Window) + window_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": session.name or "", + "tmux_window": window_name or "", + }, + ) + window_log.debug("window created") if is_first_window_pass: # if first window, use window 1 session.active_window.kill() @@ -518,6 +712,17 @@ def iter_create_panes( window_config["panes"], start=pane_base_index, ): + if self.on_progress: + self.on_progress(f"Creating pane: {pane_index}") + if self.on_build_event: + self.on_build_event( + { + "event": "pane_creating", + "pane_num": pane_index - int(pane_base_index) + 1, + "pane_total": len(window_config["panes"]), + } + ) + if pane_index == int(pane_base_index): pane = window.active_pane else: @@ -563,6 +768,15 @@ def get_pane_shell( ) assert isinstance(pane, Pane) + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) + pane_log.debug("pane created") # Skip readiness wait when a custom shell/command launcher is set. # The shell/window_shell key runs a command (e.g. "top", "sleep 999") @@ -594,6 +808,7 @@ def get_pane_shell( time.sleep(sleep_before) pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) + pane_log.debug("sent command %s", cmd["cmd"]) if sleep_after is not None: time.sleep(sleep_after) diff --git a/src/tmuxp/workspace/constants.py b/src/tmuxp/workspace/constants.py index 48ecc9c1f6..4a39082b6d 100644 --- a/src/tmuxp/workspace/constants.py +++ b/src/tmuxp/workspace/constants.py @@ -2,4 +2,8 @@ from __future__ import annotations +import logging + +logger = logging.getLogger(__name__) + VALID_WORKSPACE_DIR_FILE_EXTENSIONS = [".yaml", ".yml", ".json"] diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index da19bcc887..2bc7704c28 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -142,6 +142,12 @@ def find_local_workspace_files( if start_dir is None: start_dir = os.getcwd() + logger.debug( + "searching for local workspace files from %s", + start_dir, + extra={"tmux_config_path": str(start_dir)}, + ) + current = pathlib.Path(start_dir).resolve() home = pathlib.Path.home().resolve() found: list[pathlib.Path] = [] @@ -361,12 +367,17 @@ def find_workspace_file( ] if len(candidates) > 1: + logger.warning( + "multiple workspace files found, use distinct file names" + " to avoid ambiguity", + extra={"tmux_config_path": workspace_file}, + ) colors = Colors(ColorMode.AUTO) tmuxp_echo( colors.error( - "Multiple .tmuxp.{yml,yaml,json} workspace_files in " - + dirname(workspace_file) - ), + "Multiple .tmuxp.{yaml,yml,json} files found in " + + str(workspace_file) + ) ) tmuxp_echo( "This is undefined behavior, use only one. " @@ -383,6 +394,11 @@ def find_workspace_file( if file_error: raise FileNotFoundError(file_error, workspace_file) + logger.debug( + "resolved workspace file %s", + workspace_file, + extra={"tmux_config_path": workspace_file}, + ) return workspace_file diff --git a/src/tmuxp/workspace/freezer.py b/src/tmuxp/workspace/freezer.py index 8807e9e43e..7ec302494f 100644 --- a/src/tmuxp/workspace/freezer.py +++ b/src/tmuxp/workspace/freezer.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from libtmux.pane import Pane from libtmux.session import Session @@ -64,6 +67,8 @@ def freeze(session: Session) -> dict[str, t.Any]: dict tmuxp compatible workspace """ + logger.debug("freezing session", extra={"tmux_session": session.session_name}) + session_config: dict[str, t.Any] = { "session_name": session.session_name, "windows": [], @@ -119,5 +124,12 @@ def filter_interpreters_and_shells(current_cmd: str | None) -> bool: window_config["panes"].append(pane_config) session_config["windows"].append(window_config) + logger.debug( + "frozen window", + extra={ + "tmux_session": session.session_name, + "tmux_window": window.name, + }, + ) return session_config diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index fda361ca6f..65184d73a4 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. @@ -19,6 +22,14 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + logger.debug( + "importing tmuxinator workspace", + extra={ + "tmux_session": workspace_dict.get("project_name") + or workspace_dict.get("name", ""), + }, + ) + tmuxp_workspace: dict[str, t.Any] = {} if "project_name" in workspace_dict: @@ -122,6 +133,12 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: - clear - cmd_separator """ + _inner = workspace_dict.get("session", workspace_dict) + logger.debug( + "importing teamocil workspace", + extra={"tmux_session": _inner.get("name", "")}, + ) + tmuxp_workspace: dict[str, t.Any] = {} if "session" in workspace_dict: diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 613b2b589d..9efcd05b52 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -101,6 +101,11 @@ def expand( ------- dict """ + logger.debug( + "expanding workspace config", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + # Note: cli.py will expand workspaces relative to project's workspace directory # for the first cwd argument. cwd = pathlib.Path().cwd() if not cwd else pathlib.Path(cwd) @@ -207,6 +212,11 @@ def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + logger.debug( + "trickling down workspace defaults", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + # prepends a pane's ``shell_command`` list with the window and sessions' # ``shell_command_before``. diff --git a/src/tmuxp/workspace/validation.py b/src/tmuxp/workspace/validation.py index 2573209a5d..e7fe4da743 100644 --- a/src/tmuxp/workspace/validation.py +++ b/src/tmuxp/workspace/validation.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging import typing as t from tmuxp import exc +logger = logging.getLogger(__name__) + class SchemaValidationError(exc.WorkspaceError): """Tmuxp configuration validation base error.""" @@ -70,6 +73,15 @@ def validate_schema(workspace_dict: t.Any) -> bool: ------- bool """ + logger.debug( + "validating workspace schema", + extra={ + "tmux_session": workspace_dict.get("session_name", "") + if isinstance(workspace_dict, dict) + else "", + }, + ) + # verify session_name if "session_name" not in workspace_dict: raise SessionNameMissingValidationError diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 2191b7320c..ec045dcf3c 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -17,7 +17,6 @@ from tmuxp import cli from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli._colors import ColorMode, Colors from tmuxp.cli.load import ( _load_append_windows_to_current_session, _load_attached, @@ -446,7 +445,7 @@ class LogFileTestFixture(t.NamedTuple): LOG_FILE_TEST_FIXTURES: list[LogFileTestFixture] = [ LogFileTestFixture( test_id="load_with_log_file", - cli_args=["load", ".", "--log-file", "log.txt", "-d"], + cli_args=["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"], ), ] @@ -484,10 +483,45 @@ def test_load_log_file( result = capsys.readouterr() log_file_path = tmp_path / "log.txt" - assert "Loading" in log_file_path.open().read() + assert "loading workspace" in log_file_path.open().read() assert result.out is not None +def test_load_log_file_level_filtering( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Log-level filtering: INFO log file should not contain DEBUG messages.""" + tmuxp_config_path = tmp_path / ".tmuxp.yaml" + tmuxp_config_path.write_text( + """ +session_name: hello + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + with contextlib.suppress(Exception): + cli.cli(["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"]) + + log_file_path = tmp_path / "log.txt" + log_contents = log_file_path.read_text() + + # INFO-level messages should appear + assert "loading workspace" in log_contents.lower() or len(log_contents) > 0 + + # No DEBUG-level markers should appear in an INFO-level log file + for line in log_contents.splitlines(): + assert "(DEBUG)" not in line, ( + f"DEBUG message leaked into INFO-level log file: {line}" + ) + + def test_load_plugins( monkeypatch_plugin_test_packages: None, ) -> None: @@ -758,18 +792,108 @@ def test_load_append_windows_to_current_session( # Privacy masking in load command -def test_load_masks_home_in_loading_message(monkeypatch: pytest.MonkeyPatch) -> None: - """Load command should mask home directory in [Loading] message.""" +def test_load_no_ansi_in_nontty_stderr( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """No ANSI escape codes in stderr when running in non-TTY context (CI/pipe).""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + load_workspace(str(session_file), socket_name=server.socket_name, detached=True) + + captured = capsys.readouterr() + assert "\x1b[" not in captured.err, "ANSI codes leaked into non-TTY stderr" + + +class ProgressDisableFixture(t.NamedTuple): + """Test fixture for progress disable logic.""" + + test_id: str + env_value: str | None + no_progress_flag: bool + expected_disabled: bool + + +PROGRESS_DISABLE_FIXTURES: list[ProgressDisableFixture] = [ + ProgressDisableFixture("default_enabled", None, False, False), + ProgressDisableFixture("env_disabled", "0", False, True), + ProgressDisableFixture("flag_disabled", None, True, True), + ProgressDisableFixture("env_enabled_explicit", "1", False, False), + ProgressDisableFixture("flag_overrides_env", "1", True, True), +] + + +@pytest.mark.parametrize( + list(ProgressDisableFixture._fields), + PROGRESS_DISABLE_FIXTURES, + ids=[f.test_id for f in PROGRESS_DISABLE_FIXTURES], +) +def test_progress_disable_logic( + test_id: str, + env_value: str | None, + no_progress_flag: bool, + expected_disabled: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Progress disable expression matches expected behavior.""" + if env_value is not None: + monkeypatch.setenv("TMUXP_PROGRESS", env_value) + else: + monkeypatch.delenv("TMUXP_PROGRESS", raising=False) + + import os + + result = no_progress_flag or os.getenv("TMUXP_PROGRESS", "1") == "0" + assert result is expected_disabled + + +def test_load_workspace_no_progress( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with no_progress=True creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + no_progress=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_workspace_env_progress_disabled( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with TMUXP_PROGRESS=0 creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setenv("TMUXP_PROGRESS", "0") + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) workspace_file = pathlib.Path("/home/testuser/work/project/.tmuxp.yaml") - output = ( - colors.info("[Loading]") - + " " - + colors.highlight(str(PrivatePath(workspace_file))) - ) + private_path = str(PrivatePath(workspace_file)) + message = f"Loading workspace: myproject ({private_path})" - assert "~/work/project/.tmuxp.yaml" in output - assert "/home/testuser" not in output + assert "~/work/project/.tmuxp.yaml" in message + assert "/home/testuser" not in message diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 40e1526839..e6f64e28fc 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -161,6 +161,7 @@ def test_ls_json_output( cli.cli(["ls", "--json"]) output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" data = json.loads(output) # JSON output is now an object with workspaces and global_workspace_dirs @@ -200,6 +201,7 @@ def test_ls_ndjson_output( cli.cli(["ls", "--ndjson"]) output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" lines = [line for line in output.strip().split("\n") if line] assert len(lines) == 2 diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py index 3112f60d02..84bee97aa0 100644 --- a/tests/cli/test_output.py +++ b/tests/cli/test_output.py @@ -221,6 +221,54 @@ def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None: assert captured.out == "" +def test_emit_object_json_writes_immediately( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode emit_object should write indented JSON immediately.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"status": "ok", "count": 3} + # Indented output (indent=2) + assert "\n" in captured.out + + +def test_emit_object_ndjson_writes_compact( + capsys: pytest.CaptureFixture[str], +) -> None: + """NDJSON mode emit_object should write compact single-line JSON.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 1 + assert json.loads(lines[0]) == {"status": "ok", "count": 3} + + +def test_emit_object_human_silent(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode emit_object should produce no output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_object({"status": "ok"}) + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_object_does_not_buffer() -> None: + """emit_object must not affect _json_buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.emit_object({"status": "ok"}) + finally: + sys.stdout = old_stdout + assert formatter._json_buffer == [] + + def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None: """Test complete HUMAN output workflow.""" formatter = OutputFormatter(OutputMode.HUMAN) diff --git a/tests/cli/test_progress.py b/tests/cli/test_progress.py new file mode 100644 index 0000000000..8ace187b65 --- /dev/null +++ b/tests/cli/test_progress.py @@ -0,0 +1,1425 @@ +"""Tests for tmuxp CLI progress indicator.""" + +from __future__ import annotations + +import atexit +import io +import pathlib +import time +import typing as t + +import libtmux +import pytest + +from tmuxp.cli._colors import ColorMode +from tmuxp.cli._progress import ( + BAR_WIDTH, + ERASE_LINE, + HIDE_CURSOR, + PROGRESS_PRESETS, + SHOW_CURSOR, + SUCCESS_TEMPLATE, + BuildTree, + Spinner, + _truncate_visible, + _visible_len, + render_bar, + resolve_progress_format, +) + + +class SpinnerEnablementFixture(t.NamedTuple): + """Test fixture for spinner TTY/color enablement matrix.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_enabled: bool + + +SPINNER_ENABLEMENT_FIXTURES: list[SpinnerEnablementFixture] = [ + SpinnerEnablementFixture("tty_color_always", True, ColorMode.ALWAYS, True), + SpinnerEnablementFixture("tty_color_auto", True, ColorMode.AUTO, True), + SpinnerEnablementFixture("tty_color_never", True, ColorMode.NEVER, True), + SpinnerEnablementFixture("non_tty_color_always", False, ColorMode.ALWAYS, False), + SpinnerEnablementFixture("non_tty_color_never", False, ColorMode.NEVER, False), +] + + +@pytest.mark.parametrize( + list(SpinnerEnablementFixture._fields), + SPINNER_ENABLEMENT_FIXTURES, + ids=[f.test_id for f in SPINNER_ENABLEMENT_FIXTURES], +) +def test_spinner_enablement( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_enabled: bool, +) -> None: + """Spinner._enabled depends only on TTY, not on color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + assert spinner._enabled is expected_enabled + + +def test_spinner_disabled_output() -> None: + """Disabled spinner produces no output.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + with Spinner(message="Test", stream=stream) as spinner: + spinner.update_message("Updated") + + assert stream.getvalue() == "" + + +def test_spinner_enabled_output() -> None: + """Enabled spinner writes ANSI control sequences.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Test", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ): + pass # enter and exit — enough for at least one frame + cleanup + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert ERASE_LINE in output + assert "Test" in output + + +def test_spinner_atexit_registered(monkeypatch: pytest.MonkeyPatch) -> None: + """atexit.register called on start, unregistered on stop.""" + registered: list[t.Any] = [] + unregistered: list[t.Any] = [] + monkeypatch.setattr(atexit, "register", lambda fn, *a: registered.append(fn)) + monkeypatch.setattr(atexit, "unregister", lambda fn: unregistered.append(fn)) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream) as spinner: + assert len(registered) == 1 + assert spinner._restore_cursor in registered + + assert len(unregistered) == 1 + assert spinner._restore_cursor in unregistered + + +def test_spinner_cleans_up_on_exception() -> None: + """SHOW_CURSOR written even when body raises.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + msg = "deliberate" + with ( + pytest.raises(ValueError), + Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream), + ): + raise ValueError(msg) + + assert SHOW_CURSOR in stream.getvalue() + + +def test_spinner_update_message_thread_safe() -> None: + """update_message() can be called from the main thread without error.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Start", color_mode=ColorMode.NEVER, stream=stream) + spinner.update_message("Updated") + assert spinner.message == "Updated" + + +def test_spinner_add_output_line_accumulates() -> None: + """add_output_line() appends stripped lines to the panel deque on TTY.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + spinner.add_output_line("Session created: test\n") + spinner.add_output_line("Creating window: editor") + spinner.add_output_line("") # blank lines are ignored + + assert list(spinner._output_lines) == [ + "Session created: test", + "Creating window: editor", + ] + + +def test_spinner_panel_respects_maxlen() -> None: + """Panel deque enforces output_lines maxlen, dropping oldest lines.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", color_mode=ColorMode.NEVER, stream=stream, output_lines=3 + ) + for i in range(5): + spinner.add_output_line(f"line {i}") + + panel = list(spinner._output_lines) + assert len(panel) == 3 + assert panel == ["line 2", "line 3", "line 4"] + + +def test_spinner_panel_rendered_in_output() -> None: + """Enabled spinner writes panel lines and spinner line to stream.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Building...", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ) as spinner: + spinner.add_output_line("Session created: my-session") + # Wait long enough for the spinner thread to render at least one frame + # that includes the panel line (interval=0.01s, so 0.05s is sufficient). + time.sleep(0.05) + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert "Session created: my-session" in output + assert "Building..." in output + + +# BuildTree tests + + +def test_build_tree_empty_renders_nothing() -> None: + """BuildTree.render() returns [] before any session_created event.""" + colors = ColorMode.NEVER + tree = BuildTree() + from tmuxp.cli._colors import Colors + + assert tree.render(Colors(colors), 80) == [] + + +def test_build_tree_session_created_shows_header() -> None: + """After session_created, render() returns the 'Session' heading line.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines == ["Session"] + + +def test_build_tree_window_started_no_pane_yet() -> None: + """window_started adds a window line with just the name (no pane info).""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert len(lines) == 2 + assert lines[1] == "- editor" + + +def test_build_tree_pane_creating_shows_progress() -> None: + """pane_creating updates the last window to show pane N of M.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 3}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- editor, pane (2 of 3)" + + +def test_build_tree_window_done_shows_checkmark() -> None: + """window_done marks the window as done; render shows checkmark.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + tree.on_event({"event": "window_done"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + + +def test_build_tree_workspace_built_marks_all_done() -> None: + """workspace_built marks all windows as done.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "window_started", "name": "logs", "pane_total": 1}) + tree.on_event({"event": "workspace_built"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- ✓ logs" + + +def test_build_tree_multiple_windows_accumulate() -> None: + """Multiple window_started events accumulate into separate tree lines.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "logging", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- logging, pane (1 of 1)" + + +def test_spinner_on_build_event_delegates_to_tree() -> None: + """Spinner.on_build_event() updates the internal BuildTree state.""" + import io + + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Building...", color_mode=ColorMode.NEVER, stream=stream) + spinner.on_build_event({"event": "session_created", "name": "test-session"}) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 1} + ) + + assert spinner._build_tree.session_name == "test-session" + assert len(spinner._build_tree.windows) == 1 + assert spinner._build_tree.windows[0].name == "editor" + + +# BuildTree.format_inline tests + + +def test_build_tree_format_inline_empty() -> None: + """format_inline returns base unchanged when no session has been created.""" + tree = BuildTree() + assert tree.format_inline("Building projects...") == "Building projects..." + + +def test_build_tree_format_inline_session_only() -> None: + """format_inline returns 'base session' after session_created with no windows.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_inline("Building projects...") == "Building projects... cihai" + + +def test_build_tree_format_inline_with_window_total() -> None: + """format_inline shows window index/total bracket after window_started.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows] gp-libs" + + +def test_build_tree_format_inline_with_panes() -> None: + """format_inline includes pane progress once pane_creating fires.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs" + + +def test_build_tree_format_inline_no_window_total() -> None: + """format_inline omits window count bracket when window_total is absent.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai"}) + tree.on_event({"event": "window_started", "name": "main", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + result = tree.format_inline("Building...") + assert result == "Building... cihai [1 of 1 panes] main" + + +def test_spinner_on_build_event_updates_message() -> None: + """on_build_event updates spinner.message via format_inline after each event.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + assert spinner.message == "Building... cihai [1 of 2 windows, 2 of 3 panes] editor" + + +# resolve_progress_format tests + + +def test_resolve_progress_format_preset_name() -> None: + """A known preset name resolves to its format string.""" + assert resolve_progress_format("default") == PROGRESS_PRESETS["default"] + assert resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + assert resolve_progress_format("verbose") == PROGRESS_PRESETS["verbose"] + + +def test_resolve_progress_format_raw_string() -> None: + """A raw template string is returned unchanged.""" + raw = "{session} w{window_progress}" + assert resolve_progress_format(raw) == raw + + +def test_resolve_progress_format_unknown_name() -> None: + """An unknown name not in presets is returned as-is (raw template pass-through).""" + assert resolve_progress_format("not-a-preset") == "not-a-preset" + + +# BuildTree.format_template tests + + +def test_build_tree_format_template_before_session() -> None: + """format_template returns '' before session_created fires.""" + tree = BuildTree() + assert tree.format_template("{session} [{progress}] {window}") == "" + + +def test_build_tree_format_template_session_only() -> None: + """After session_created alone, progress and window are empty.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_template("{session} [{progress}] {window}") == "cihai [] " + + +def test_build_tree_format_template_with_window() -> None: + """After window_started, window progress appears but pane progress does not.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win] editor" + ) + + +def test_build_tree_format_template_with_pane() -> None: + """After pane_creating, both window and pane progress appear.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win · 2/4 pane] editor" + ) + + +def test_build_tree_format_template_minimal() -> None: + """The minimal preset-style template shows only window fraction.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert tree.format_template("{session} [{window_progress}]") == "cihai [1/3]" + + +def test_build_tree_format_template_verbose() -> None: + """Verbose template shows window/pane indices and totals explicitly.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 12}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + result = tree.format_template(PROGRESS_PRESETS["verbose"]) + assert result == "Loading workspace: cihai [window 1 of 12 · pane 2 of 4] editor" + + +def test_build_tree_format_template_bad_token() -> None: + """Unknown tokens are left as {name}, known tokens still resolve.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown}") + # _SafeFormatMap: {session} resolves, {unknown} stays as-is + assert result == "cihai {unknown}" + + +# Spinner.progress_format integration tests + + +def test_spinner_progress_format_updates_message() -> None: + """Spinner with explicit progress_format uses format_template for updates.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + # Use an explicit format string rather than "default" preset to avoid + # coupling this test to the preset definition (which now includes {bar}). + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} [{progress}] {window}", + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 3} + ) + # No windows yet — falls back to base message to avoid showing empty brackets. + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 4} + ) + assert spinner.message == "cihai [1/3 win] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert spinner.message == "cihai [1/3 win · 2/4 pane] editor" + + +def test_spinner_progress_format_none_uses_inline() -> None: + """Spinner with progress_format=None preserves the format_inline path.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + +# render_bar tests + + +def test_render_bar_empty() -> None: + """render_bar with done=0 produces an all-empty bar.""" + assert render_bar(0, 10) == "░░░░░░░░░░" + + +def test_render_bar_half() -> None: + """render_bar with done=5, total=10 fills exactly half.""" + assert render_bar(5, 10) == "█████░░░░░" + + +def test_render_bar_full() -> None: + """render_bar with done=total fills the entire bar.""" + assert render_bar(10, 10) == "██████████" + + +def test_render_bar_zero_total() -> None: + """render_bar with total=0 returns empty string.""" + assert render_bar(0, 0) == "" + + +def test_render_bar_custom_width() -> None: + """render_bar with custom width produces bar of that inner width.""" + assert render_bar(3, 10, width=5) == "█░░░░" + + +def test_render_bar_width_constant() -> None: + """BAR_WIDTH is the default inner width used by render_bar.""" + bar = render_bar(0, 10) + assert len(bar) == BAR_WIDTH + + +# BuildTree new token tests + + +def test_build_tree_context_session_pane_total() -> None: + """session_pane_total token reflects count from session_created event.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + ctx = tree._context() + assert ctx["session_pane_total"] == 8 + assert ctx["session_pane_progress"] == "0/8" + assert ctx["overall_percent"] == 0 + + +def test_build_tree_context_window_progress_rel() -> None: + """window_progress_rel is 0/N from session_created, increments on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 2}) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_done"}) + assert tree._context()["window_progress_rel"] == "1/3" + + +def test_build_tree_context_pane_progress_rel() -> None: + """pane_progress_rel shows 0/M after window_started, N/M after pane_creating.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 4, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "0/4" + + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "2/4" + assert tree._context()["pane_done"] == 2 + assert tree._context()["pane_remaining"] == 2 + + +def test_build_tree_context_overall_percent() -> None: + """overall_percent is pane-based 0-100; updates on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + assert tree._context()["overall_percent"] == 0 + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + tree.on_event({"event": "window_done"}) + assert tree._context()["session_panes_done"] == 4 + assert tree._context()["overall_percent"] == 50 + + +def test_build_tree_before_script_event_toggle() -> None: + """before_script_started sets the Event; before_script_done clears it.""" + tree = BuildTree() + assert not tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_started"}) + assert tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_done"}) + assert not tree._before_script_event.is_set() + + +def test_build_tree_zero_pane_window() -> None: + """Windows with pane_total=0 do not cause division-by-zero or exceptions.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + + assert tree.session_panes_done == 0 + assert tree.windows_done == 1 + ctx = tree._context() + assert ctx["session_pane_progress"] == "" + assert ctx["overall_percent"] == 0 + + +def test_format_template_extra_backward_compat() -> None: + """format_template(fmt) without extra still works as before.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} [{progress}] {window}") + assert result == "cihai [] " + + +def test_format_template_extra_injected() -> None: + """format_template resolves extra tokens from the extra dict.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {bar}", extra={"bar": "[TEST_BAR]"}) + assert result == "cihai [TEST_BAR]" + + +def test_format_template_unknown_token_preserved() -> None: + """Unknown tokens in the format string render as {name}, not blank or raw fmt.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown_token}") + assert result == "cihai {unknown_token}" + + +# Spinner bar token tests + + +def test_spinner_bar_token_no_color() -> None: + """With ColorMode.NEVER, {bar} token in message contains bar characters.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} {bar} {progress} {window}", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "cihai", + "window_total": 3, + "session_pane_total": 6, + } + ) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 2} + ) + spinner.on_build_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_pane_bar_preset() -> None: + """The 'pane' preset wires {pane_bar} and {session_pane_progress}.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="pane", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 4, + } + ) + spinner.on_build_event({"event": "window_started", "name": "w1", "pane_total": 2}) + spinner.on_build_event({"event": "window_done"}) + + assert "2/4" in spinner.message + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_before_script_event_via_events() -> None: + """before_script_started / before_script_done toggle the BuildTree Event flag.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.on_build_event({"event": "before_script_started"}) + assert spinner._build_tree._before_script_event.is_set() + + spinner.on_build_event({"event": "before_script_done"}) + assert not spinner._build_tree._before_script_event.is_set() + + +def test_progress_presets_have_expected_keys() -> None: + """All expected preset names are present in PROGRESS_PRESETS.""" + for name in ("default", "minimal", "window", "pane", "verbose"): + assert name in PROGRESS_PRESETS, f"Missing preset: {name}" + + +def test_progress_presets_default_includes_bar() -> None: + """The 'default' preset includes the {bar} token.""" + assert "{bar}" in PROGRESS_PRESETS["default"] + + +def test_progress_presets_minimal_format() -> None: + """The 'minimal' preset includes the Loading prefix and window_progress token.""" + expected = "Loading workspace: {session} [{window_progress}]" + assert PROGRESS_PRESETS["minimal"] == expected + + +# BuildTree remaining token tests + + +class RemainingTokenFixture(t.NamedTuple): + """Test fixture for windows_remaining and session_panes_remaining tokens.""" + + test_id: str + events: list[dict[str, t.Any]] + token: str + expected: int + + +REMAINING_TOKEN_FIXTURES: list[RemainingTokenFixture] = [ + RemainingTokenFixture( + "windows_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + ], + "windows_remaining", + 3, + ), + RemainingTokenFixture( + "windows_remaining_after_done", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + {"event": "window_started", "name": "w1", "pane_total": 2}, + {"event": "window_done"}, + ], + "windows_remaining", + 2, + ), + RemainingTokenFixture( + "session_panes_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + ], + "session_panes_remaining", + 5, + ), + RemainingTokenFixture( + "session_panes_remaining_after_window", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + {"event": "window_started", "name": "w1", "pane_total": 3}, + {"event": "window_done"}, + ], + "session_panes_remaining", + 2, + ), +] + + +@pytest.mark.parametrize( + list(RemainingTokenFixture._fields), + REMAINING_TOKEN_FIXTURES, + ids=[f.test_id for f in REMAINING_TOKEN_FIXTURES], +) +def test_build_tree_remaining_tokens( + test_id: str, + events: list[dict[str, t.Any]], + token: str, + expected: int, +) -> None: + """Remaining tokens decrement correctly as windows/panes complete.""" + tree = BuildTree() + for ev in events: + tree.on_event(ev) + assert tree._context()[token] == expected + + +# _visible_len tests + + +class VisibleLenFixture(t.NamedTuple): + """Test fixture for _visible_len ANSI-aware length calculation.""" + + test_id: str + text: str + expected_len: int + + +VISIBLE_LEN_FIXTURES: list[VisibleLenFixture] = [ + VisibleLenFixture("plain_text", "hello", 5), + VisibleLenFixture("ansi_green", "\033[32mgreen\033[0m", 5), + VisibleLenFixture("empty_string", "", 0), + VisibleLenFixture("nested_ansi", "\033[1m\033[31mbold red\033[0m", 8), + VisibleLenFixture("ansi_only", "\033[0m", 0), +] + + +@pytest.mark.parametrize( + list(VisibleLenFixture._fields), + VISIBLE_LEN_FIXTURES, + ids=[f.test_id for f in VISIBLE_LEN_FIXTURES], +) +def test_visible_len( + test_id: str, + text: str, + expected_len: int, +) -> None: + """_visible_len returns the visible character count, ignoring ANSI escapes.""" + assert _visible_len(text) == expected_len + + +# Spinner.add_output_line non-TTY write-through tests + + +class OutputLineFixture(t.NamedTuple): + """Test fixture for add_output_line TTY vs non-TTY behavior.""" + + test_id: str + isatty: bool + lines: list[str] + expected_deque: list[str] + expected_stream_contains: str + + +OUTPUT_LINE_FIXTURES: list[OutputLineFixture] = [ + OutputLineFixture( + "tty_accumulates_in_deque", + isatty=True, + lines=["line1\n", "line2\n"], + expected_deque=["line1", "line2"], + expected_stream_contains="", + ), + OutputLineFixture( + "non_tty_writes_to_stream", + isatty=False, + lines=["hello\n", "world\n"], + expected_deque=[], + expected_stream_contains="hello\nworld\n", + ), + OutputLineFixture( + "blank_lines_ignored", + isatty=True, + lines=["", "\n"], + expected_deque=[], + expected_stream_contains="", + ), +] + + +@pytest.mark.parametrize( + list(OutputLineFixture._fields), + OUTPUT_LINE_FIXTURES, + ids=[f.test_id for f in OUTPUT_LINE_FIXTURES], +) +def test_spinner_output_line_behavior( + test_id: str, + isatty: bool, + lines: list[str], + expected_deque: list[str], + expected_stream_contains: str, +) -> None: + """add_output_line accumulates in deque (TTY) or writes to stream (non-TTY).""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + for line in lines: + spinner.add_output_line(line) + + assert list(spinner._output_lines) == expected_deque + assert expected_stream_contains in stream.getvalue() + + +# Spinner.success tests + + +# Panel lines special values tests + + +class PanelLinesFixture(t.NamedTuple): + """Test fixture for Spinner panel_lines special values.""" + + test_id: str + output_lines: int + expected_maxlen: int | None # None = unbounded + expected_hidden: bool + add_count: int + expected_retained: int + + +PANEL_LINES_FIXTURES: list[PanelLinesFixture] = [ + PanelLinesFixture("zero_hides_panel", 0, 1, True, 10, 1), + PanelLinesFixture("negative_unlimited", -1, None, False, 100, 100), + PanelLinesFixture("positive_normal", 5, 5, False, 10, 5), + PanelLinesFixture("default_three", 3, 3, False, 5, 3), +] + + +@pytest.mark.parametrize( + list(PanelLinesFixture._fields), + PANEL_LINES_FIXTURES, + ids=[f.test_id for f in PANEL_LINES_FIXTURES], +) +def test_spinner_panel_lines_special_values( + test_id: str, + output_lines: int, + expected_maxlen: int | None, + expected_hidden: bool, + add_count: int, + expected_retained: int, +) -> None: + """Spinner panel_lines=0 hides, -1 is unlimited, positive caps normally.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=output_lines, + ) + for i in range(add_count): + spinner.add_output_line(f"line {i}") + + assert len(spinner._output_lines) == expected_retained + assert spinner._output_lines.maxlen == expected_maxlen + assert spinner._panel_hidden is expected_hidden + + +def test_spinner_unlimited_caps_rendered_panel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unlimited panel (-1) caps rendered lines to terminal_height - 2.""" + import os as _os + import shutil + + monkeypatch.setattr( + shutil, + "get_terminal_size", + lambda fallback=(80, 24): _os.terminal_size((80, 10)), + ) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=-1, + interval=0.01, + ) + for i in range(50): + spinner.add_output_line(f"line {i}") + + # All 50 lines should be retained in the unbounded deque + assert len(spinner._output_lines) == 50 + + # Start spinner briefly to render at least one frame + spinner.start() + time.sleep(0.05) + spinner.stop() + + output = stream.getvalue() + # Verify that not all 50 lines appear in any single frame + # The cap should limit to terminal_height - 2 = 8 lines + # Only the last 8 lines should appear in output + assert "line 49" in output + assert "line 0" not in output + + +class SuccessFixture(t.NamedTuple): + """Test fixture for Spinner.success() output behavior.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_contains: str + + +SUCCESS_FIXTURES: list[SuccessFixture] = [ + SuccessFixture("tty_with_color", True, ColorMode.ALWAYS, "done"), + SuccessFixture("tty_no_color", True, ColorMode.NEVER, "✓ done"), + SuccessFixture("non_tty", False, ColorMode.NEVER, "✓ done"), +] + + +@pytest.mark.parametrize( + list(SuccessFixture._fields), + SUCCESS_FIXTURES, + ids=[f.test_id for f in SUCCESS_FIXTURES], +) +def test_spinner_success_behavior( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_contains: str, +) -> None: + """success() always emits the checkmark message regardless of TTY/color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + spinner.success("done") + + output = stream.getvalue() + assert "✓" in output + assert expected_contains in output + + +# _truncate_visible tests + + +def test_truncate_visible_plain_text() -> None: + """Plain text is truncated to max_visible chars with default suffix.""" + assert _truncate_visible("hello world", 5) == "hello\x1b[0m..." + + +def test_truncate_visible_ansi_preserved() -> None: + """ANSI sequences are preserved whole; only visible chars count.""" + result = _truncate_visible("\033[32mgreen\033[0m", 3) + assert result == "\x1b[32mgre\x1b[0m..." + + +def test_truncate_visible_no_truncation() -> None: + """String shorter than max_visible is returned unchanged.""" + assert _truncate_visible("short", 10) == "short" + + +def test_truncate_visible_empty() -> None: + """Empty string returns empty string.""" + assert _truncate_visible("", 5) == "" + + +def test_truncate_visible_custom_suffix() -> None: + """Custom suffix is appended after truncation.""" + assert _truncate_visible("hello world", 5, suffix="~") == "hello\x1b[0m~" + + +def test_truncate_visible_no_suffix() -> None: + """Empty suffix produces only the reset sequence.""" + assert _truncate_visible("hello world", 5, suffix="") == "hello\x1b[0m" + + +# workspace_path token tests + + +def test_build_tree_workspace_path_in_context() -> None: + """workspace_path is available in _context() when set on construction.""" + tree = BuildTree(workspace_path="~/.tmuxp/foo.yaml") + tree.on_event({"event": "session_created", "name": "foo", "window_total": 1}) + ctx = tree._context() + assert ctx["workspace_path"] == "~/.tmuxp/foo.yaml" + + +def test_build_tree_workspace_path_empty_default() -> None: + """workspace_path defaults to empty string in _context().""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "s", "window_total": 1}) + assert tree._context()["workspace_path"] == "" + + +def test_spinner_workspace_path_passed_to_tree() -> None: + """Spinner passes workspace_path through to its BuildTree.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/proj.yaml", + ) + assert spinner._build_tree.workspace_path == "~/.tmuxp/proj.yaml" + + +def test_build_tree_workspace_path_in_template() -> None: + """workspace_path token resolves in format_template.""" + tree = BuildTree(workspace_path="~/.tmuxp/bar.yaml") + tree.on_event({"event": "session_created", "name": "bar", "window_total": 1}) + result = tree.format_template("{session} ({workspace_path})") + assert result == "bar (~/.tmuxp/bar.yaml)" + + +# {summary} token tests + + +def test_build_tree_summary_empty_state() -> None: + """Summary token is empty string before any windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["summary"] == "" + + +def test_build_tree_summary_after_windows_done() -> None: + """Summary token shows bracketed win/pane counts after windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 8, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w3", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[3 win, 8 panes]" + + +def test_build_tree_summary_windows_only_no_panes() -> None: + """Summary token shows only win count when pane_total is 0.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[2 win]" + + +def test_build_tree_summary_panes_only() -> None: + """Summary token shows only pane count when windows_done is 0 (edge case).""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 6, + } + ) + # Manually set session_panes_done without window_done to test edge case + tree.session_panes_done = 6 + assert tree._context()["summary"] == "[6 panes]" + + +# format_success() tests + + +def test_spinner_format_success_full_build() -> None: + """format_success renders SUCCESS_TEMPLATE with session, path, and summary.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/myapp.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "myapp", + "window_total": 3, + "session_pane_total": 8, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w1", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w2", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w3", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "myapp" in result + assert "~/.tmuxp/myapp.yaml" in result + assert "[3 win, 8 panes]" in result + + +def test_spinner_format_success_no_windows() -> None: + """format_success with no windows/panes done omits brackets.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/empty.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "empty", + "window_total": 0, + "session_pane_total": 0, + } + ) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "empty" in result + assert "~/.tmuxp/empty.yaml" in result + assert "[" not in result + + +# Spinner.success() with no args tests + + +def test_spinner_success_no_args_template_mode() -> None: + """success() with no args uses format_success when progress_format is set.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + workspace_path="~/.tmuxp/proj.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "proj", + "window_total": 1, + "session_pane_total": 2, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "main", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + spinner.success() + + output = stream.getvalue() + assert "✓" in output + assert "Loaded workspace:" in output + assert "proj" in output + assert "~/.tmuxp/proj.yaml" in output + assert "[1 win, 2 panes]" in output + + +def test_spinner_success_no_args_no_template() -> None: + """success() with no args and no progress_format falls back to _base_message.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading workspace: myapp", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + spinner.success() + + output = stream.getvalue() + assert "✓ Loading workspace: myapp" in output + + +def test_spinner_success_explicit_text_backward_compat() -> None: + """success('custom text') still works as before (backward compat).""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.success("custom done message") + + output = stream.getvalue() + assert "✓ custom done message" in output + + +# SUCCESS_TEMPLATE constant tests + + +def test_success_template_value() -> None: + """SUCCESS_TEMPLATE contains expected tokens.""" + assert "{session}" in SUCCESS_TEMPLATE + assert "{workspace_path}" in SUCCESS_TEMPLATE + assert "{summary}" in SUCCESS_TEMPLATE + assert "Loaded workspace:" in SUCCESS_TEMPLATE + + +def test_no_success_message_on_build_error( + server: libtmux.Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], +) -> None: + """Success message is not emitted when _dispatch_build returns None.""" + import yaml + + from tmuxp.cli._colors import Colors + from tmuxp.cli.load import load_workspace + + monkeypatch.delenv("TMUX", raising=False) + + config = {"session_name": "test-fail", "windows": [{"window_name": "main"}]} + config_file = tmp_path / "fail.yaml" + config_file.write_text(yaml.dump(config)) + + monkeypatch.setattr( + "tmuxp.cli.load._dispatch_build", + lambda *args, **kwargs: None, + ) + + result = load_workspace( + str(config_file), + socket_name=server.socket_name, + cli_colors=Colors(ColorMode.NEVER), + ) + + assert result is None + captured = capfd.readouterr() + assert "\u2713" not in captured.err + assert "Loaded workspace:" not in captured.err diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py index e4140b2645..9e67266b86 100644 --- a/tests/cli/test_search.py +++ b/tests/cli/test_search.py @@ -830,6 +830,7 @@ def test_output_search_results_json(capsys: pytest.CaptureFixture[str]) -> None: formatter.finalize() captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" data = json.loads(captured.out) assert len(data) == 1 assert data[0]["name"] == "dev" @@ -857,6 +858,7 @@ def test_output_search_results_ndjson(capsys: pytest.CaptureFixture[str]) -> Non formatter.finalize() captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" lines = captured.out.strip().split("\n") # Filter out human-readable lines json_lines = [line for line in lines if line.startswith("{")] diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py deleted file mode 100644 index b6723bfd09..0000000000 --- a/tests/docs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for documentation extensions.""" - -from __future__ import annotations diff --git a/tests/docs/_ext/__init__.py b/tests/docs/_ext/__init__.py deleted file mode 100644 index 56548488ec..0000000000 --- a/tests/docs/_ext/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for docs/_ext Sphinx extensions.""" - -from __future__ import annotations diff --git a/tests/docs/_ext/conftest.py b/tests/docs/_ext/conftest.py deleted file mode 100644 index fa2919bdcf..0000000000 --- a/tests/docs/_ext/conftest.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Fixtures and configuration for docs extension tests.""" - -from __future__ import annotations - -import pathlib -import sys - -# Add docs/_ext to path so we can import the extension module -docs_ext_path = pathlib.Path(__file__).parent.parent.parent.parent / "docs" / "_ext" -if str(docs_ext_path) not in sys.path: - sys.path.insert(0, str(docs_ext_path)) diff --git a/tests/docs/_ext/sphinx_argparse_neo/__init__.py b/tests/docs/_ext/sphinx_argparse_neo/__init__.py deleted file mode 100644 index 259f37c931..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for sphinx_argparse_neo extension.""" - -from __future__ import annotations diff --git a/tests/docs/_ext/sphinx_argparse_neo/conftest.py b/tests/docs/_ext/sphinx_argparse_neo/conftest.py deleted file mode 100644 index c805df1f30..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/conftest.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Fixtures and configuration for sphinx_argparse_neo tests.""" - -from __future__ import annotations - -import argparse -import pathlib -import sys - -import pytest - -# Add docs/_ext to path so we can import the extension module -docs_ext_path = ( - pathlib.Path(__file__).parent.parent.parent.parent.parent / "docs" / "_ext" -) -if str(docs_ext_path) not in sys.path: - sys.path.insert(0, str(docs_ext_path)) - - -@pytest.fixture -def simple_parser() -> argparse.ArgumentParser: - """Create a simple parser with basic arguments. - - Returns - ------- - argparse.ArgumentParser - Parser with a positional argument and a couple of options. - """ - parser = argparse.ArgumentParser( - prog="myapp", - description="A simple test application", - ) - parser.add_argument("filename", help="Input file to process") - parser.add_argument( - "-v", "--verbose", action="store_true", help="Enable verbose mode" - ) - parser.add_argument("-o", "--output", metavar="FILE", help="Output file") - return parser - - -@pytest.fixture -def parser_with_groups() -> argparse.ArgumentParser: - """Create a parser with custom argument groups. - - Returns - ------- - argparse.ArgumentParser - Parser with multiple argument groups. - """ - parser = argparse.ArgumentParser(prog="grouped", description="Parser with groups") - - input_group = parser.add_argument_group("Input Options", "Options for input") - input_group.add_argument("--input", "-i", required=True, help="Input file") - input_group.add_argument("--format", choices=["json", "yaml"], help="Input format") - - output_group = parser.add_argument_group("Output Options", "Options for output") - output_group.add_argument("--output", "-o", help="Output file") - output_group.add_argument("--pretty", action="store_true", help="Pretty print") - - return parser - - -@pytest.fixture -def parser_with_subcommands() -> argparse.ArgumentParser: - """Create a parser with subcommands. - - Returns - ------- - argparse.ArgumentParser - Parser with subparsers. - """ - parser = argparse.ArgumentParser(prog="cli", description="CLI with subcommands") - parser.add_argument("-v", "--verbose", action="store_true", help="Verbose mode") - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # Sync subcommand - sync_parser = subparsers.add_parser("sync", help="Synchronize repositories") - sync_parser.add_argument("repo", nargs="?", help="Repository to sync") - sync_parser.add_argument("-f", "--force", action="store_true", help="Force sync") - - # Add subcommand - add_parser = subparsers.add_parser("add", aliases=["a"], help="Add a repository") - add_parser.add_argument("url", help="Repository URL") - add_parser.add_argument("-n", "--name", help="Repository name") - - return parser - - -@pytest.fixture -def parser_with_mutex() -> argparse.ArgumentParser: - """Create a parser with mutually exclusive arguments. - - Returns - ------- - argparse.ArgumentParser - Parser with mutually exclusive group. - """ - parser = argparse.ArgumentParser(prog="mutex", description="Parser with mutex") - - mutex = parser.add_mutually_exclusive_group(required=True) - mutex.add_argument("-v", "--verbose", action="store_true", help="Verbose output") - mutex.add_argument("-q", "--quiet", action="store_true", help="Quiet output") - - return parser - - -@pytest.fixture -def parser_with_all_actions() -> argparse.ArgumentParser: - """Create a parser with all action types. - - Returns - ------- - argparse.ArgumentParser - Parser demonstrating all action types. - """ - parser = argparse.ArgumentParser(prog="actions", description="All action types") - - # store (default) - parser.add_argument("--name", help="Store action") - - # store_const - parser.add_argument( - "--enable", action="store_const", const="enabled", help="Store const" - ) - - # store_true / store_false - parser.add_argument("--flag", action="store_true", help="Store true") - parser.add_argument("--no-flag", action="store_false", help="Store false") - - # append - parser.add_argument("--item", action="append", help="Append action") - - # append_const - parser.add_argument( - "--debug", - action="append_const", - const="debug", - dest="features", - help="Append const", - ) - - # count - parser.add_argument("-v", "--verbose", action="count", default=0, help="Count") - - # BooleanOptionalAction (Python 3.9+) - parser.add_argument( - "--option", action=argparse.BooleanOptionalAction, help="Boolean optional" - ) - - return parser - - -@pytest.fixture -def parser_with_types() -> argparse.ArgumentParser: - """Create a parser with typed arguments. - - Returns - ------- - argparse.ArgumentParser - Parser with various type specifications. - """ - parser = argparse.ArgumentParser(prog="types", description="Typed arguments") - - parser.add_argument("--count", type=int, help="Integer argument") - parser.add_argument("--ratio", type=float, help="Float argument") - parser.add_argument("--path", type=pathlib.Path, help="Path argument") - parser.add_argument("--choice", type=str, choices=["a", "b", "c"], help="Choices") - - return parser - - -@pytest.fixture -def parser_with_nargs() -> argparse.ArgumentParser: - """Create a parser demonstrating nargs variants. - - Returns - ------- - argparse.ArgumentParser - Parser with various nargs specifications. - """ - parser = argparse.ArgumentParser(prog="nargs", description="Nargs variants") - - parser.add_argument("single", help="Single positional") - parser.add_argument("optional", nargs="?", default="default", help="Optional") - parser.add_argument("zero_or_more", nargs="*", help="Zero or more") - parser.add_argument("--one-or-more", nargs="+", help="One or more") - parser.add_argument("--exactly-two", nargs=2, metavar=("A", "B"), help="Exactly 2") - parser.add_argument("remainder", nargs=argparse.REMAINDER, help="Remainder") - - return parser - - -@pytest.fixture -def parser_with_defaults() -> argparse.ArgumentParser: - """Create a parser with various default values. - - Returns - ------- - argparse.ArgumentParser - Parser demonstrating default handling. - """ - parser = argparse.ArgumentParser(prog="defaults") - - parser.add_argument("--none-default", default=None, help="None default") - parser.add_argument("--string-default", default="hello", help="String default") - parser.add_argument("--int-default", default=42, type=int, help="Int default") - parser.add_argument("--list-default", default=[1, 2], help="List default") - parser.add_argument("--suppress", default=argparse.SUPPRESS, help=argparse.SUPPRESS) - - return parser - - -@pytest.fixture -def nested_subcommands_parser() -> argparse.ArgumentParser: - """Create a parser with nested subcommands. - - Returns - ------- - argparse.ArgumentParser - Parser with multiple levels of subparsers. - """ - parser = argparse.ArgumentParser(prog="nested", description="Nested subcommands") - - level1 = parser.add_subparsers(dest="level1") - - # First level: repo - repo = level1.add_parser("repo", help="Repository commands") - repo_subs = repo.add_subparsers(dest="level2") - - # Second level: repo clone - clone = repo_subs.add_parser("clone", help="Clone a repository") - clone.add_argument("url", help="Repository URL") - - # Second level: repo list - repo_subs.add_parser("list", help="List repositories") - - return parser diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_compat.py b/tests/docs/_ext/sphinx_argparse_neo/test_compat.py deleted file mode 100644 index 417e29a6e1..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_compat.py +++ /dev/null @@ -1,330 +0,0 @@ -"""Tests for sphinx_argparse_neo.compat module.""" - -from __future__ import annotations - -import sys -import typing as t - -import pytest -from sphinx_argparse_neo.compat import ( - MockModule, - get_parser_from_entry_point, - get_parser_from_module, - import_module, - mock_imports, -) - -# --- MockModule tests --- - - -def test_mock_module_name() -> None: - """Test MockModule name attribute.""" - mock = MockModule("mypackage.submodule") - assert mock.__name__ == "mypackage.submodule" - - -def test_mock_module_repr() -> None: - """Test MockModule string representation.""" - mock = MockModule("mypackage") - assert repr(mock) == "" - - -def test_mock_module_getattr() -> None: - """Test MockModule attribute access.""" - mock = MockModule("mypackage") - child = mock.submodule - - assert isinstance(child, MockModule) - assert child.__name__ == "mypackage.submodule" - - -def test_mock_module_nested_getattr() -> None: - """Test MockModule nested attribute access.""" - mock = MockModule("pkg") - deep = mock.level1.level2.level3 - - assert deep.__name__ == "pkg.level1.level2.level3" - - -def test_mock_module_callable() -> None: - """Test MockModule is callable.""" - mock = MockModule("mypackage") - result = mock() - - assert result is mock - - -def test_mock_module_callable_with_args() -> None: - """Test MockModule callable with arguments.""" - mock = MockModule("mypackage") - result = mock(1, 2, 3, key="value") - - assert result is mock - - -def test_mock_module_chained_call() -> None: - """Test MockModule chained attribute access and call.""" - mock = MockModule("pkg") - result = mock.SomeClass() - - assert isinstance(result, MockModule) - - -# --- mock_imports context manager tests --- - - -def test_mock_imports_adds_to_sys_modules() -> None: - """Test that mock_imports adds modules to sys.modules.""" - module_name = "test_fake_module_xyz" - - assert module_name not in sys.modules - - with mock_imports([module_name]): - assert module_name in sys.modules - assert isinstance(sys.modules[module_name], MockModule) - - assert module_name not in sys.modules - - -def test_mock_imports_multiple_modules() -> None: - """Test mocking multiple modules.""" - modules = ["fake_a", "fake_b", "fake_c"] - - with mock_imports(modules): - for name in modules: - assert name in sys.modules - - for name in modules: - assert name not in sys.modules - - -def test_mock_imports_nested_modules() -> None: - """Test mocking nested module paths.""" - modules = ["fake_pkg", "fake_pkg.sub", "fake_pkg.sub.deep"] - - with mock_imports(modules): - for name in modules: - assert name in sys.modules - - for name in modules: - assert name not in sys.modules - - -def test_mock_imports_does_not_override_existing() -> None: - """Test that mock_imports doesn't override existing modules.""" - # argparse is already imported - original = sys.modules["argparse"] - - with mock_imports(["argparse"]): - # Should not be replaced - assert sys.modules["argparse"] is original - - assert sys.modules["argparse"] is original - - -def test_mock_imports_cleanup_on_exception() -> None: - """Test that mock_imports cleans up even on exception.""" - module_name = "fake_exception_test" - exc_msg = "Test exception" - - with pytest.raises(ValueError), mock_imports([module_name]): - assert module_name in sys.modules - raise ValueError(exc_msg) - - assert module_name not in sys.modules - - -def test_mock_imports_allows_import() -> None: - """Test that mocked modules can be imported.""" - module_name = "fake_importable" - - with mock_imports([module_name]): - # This should work without ImportError - import fake_importable # type: ignore[import-not-found] - - assert fake_importable.__name__ == "fake_importable" - - -# --- import_module tests --- - - -def test_import_module_builtin() -> None: - """Test importing a built-in module.""" - mod = import_module("argparse") - assert hasattr(mod, "ArgumentParser") - - -def test_import_module_stdlib() -> None: - """Test importing a stdlib module.""" - mod = import_module("os.path") - assert hasattr(mod, "join") - - -def test_import_module_not_found() -> None: - """Test importing a non-existent module.""" - with pytest.raises(ModuleNotFoundError): - import_module("nonexistent_module_xyz") - - -# --- get_parser_from_module tests --- - - -def test_get_parser_from_module_argparse() -> None: - """Test getting parser from argparse module itself.""" - # Create a test module with a parser factory - import types - - test_module = types.ModuleType("test_parser_module") - - def create_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="test") - - test_module.create_parser = create_parser # type: ignore[attr-defined] - sys.modules["test_parser_module"] = test_module - - try: - parser = get_parser_from_module("test_parser_module", "create_parser") - assert parser.prog == "test" - finally: - del sys.modules["test_parser_module"] - - -def test_get_parser_from_module_with_mock() -> None: - """Test getting parser with mocked dependencies.""" - import types - - test_module = types.ModuleType("test_mock_parser") - - def create_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="mocked") - - test_module.create_parser = create_parser # type: ignore[attr-defined] - sys.modules["test_mock_parser"] = test_module - - try: - parser = get_parser_from_module( - "test_mock_parser", - "create_parser", - mock_modules=["fake_dependency"], - ) - assert parser.prog == "mocked" - finally: - del sys.modules["test_mock_parser"] - - -def test_get_parser_from_module_dotted_path() -> None: - """Test getting parser from class method.""" - import types - - test_module = types.ModuleType("test_class_parser") - - class CLI: - @staticmethod - def create_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="from_class") - - test_module.CLI = CLI # type: ignore[attr-defined] - sys.modules["test_class_parser"] = test_module - - try: - parser = get_parser_from_module("test_class_parser", "CLI.create_parser") - assert parser.prog == "from_class" - finally: - del sys.modules["test_class_parser"] - - -def test_get_parser_from_module_not_found() -> None: - """Test error when module not found.""" - with pytest.raises(ModuleNotFoundError): - get_parser_from_module("nonexistent_xyz", "func") - - -def test_get_parser_from_module_func_not_found() -> None: - """Test error when function not found.""" - with pytest.raises(AttributeError): - get_parser_from_module("argparse", "nonexistent_func") - - -# --- get_parser_from_entry_point tests --- - - -def test_get_parser_from_entry_point_valid() -> None: - """Test parsing valid entry point format.""" - import types - - test_module = types.ModuleType("test_entry_point") - - def get_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="entry") - - test_module.get_parser = get_parser # type: ignore[attr-defined] - sys.modules["test_entry_point"] = test_module - - try: - parser = get_parser_from_entry_point("test_entry_point:get_parser") - assert parser.prog == "entry" - finally: - del sys.modules["test_entry_point"] - - -def test_get_parser_from_entry_point_invalid_format() -> None: - """Test error on invalid entry point format.""" - with pytest.raises(ValueError) as exc_info: - get_parser_from_entry_point("no_colon_separator") - - assert "Invalid entry point format" in str(exc_info.value) - - -def test_get_parser_from_entry_point_with_class() -> None: - """Test entry point with class method.""" - import types - - test_module = types.ModuleType("test_entry_class") - - class Factory: - @staticmethod - def parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="factory") - - test_module.Factory = Factory # type: ignore[attr-defined] - sys.modules["test_entry_class"] = test_module - - try: - parser = get_parser_from_entry_point("test_entry_class:Factory.parser") - assert parser.prog == "factory" - finally: - del sys.modules["test_entry_class"] - - -def test_get_parser_from_entry_point_with_mock() -> None: - """Test entry point with mocked modules.""" - import types - - test_module = types.ModuleType("test_entry_mock") - - def make_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="with_mock") - - test_module.make_parser = make_parser # type: ignore[attr-defined] - sys.modules["test_entry_mock"] = test_module - - try: - parser = get_parser_from_entry_point( - "test_entry_mock:make_parser", - mock_modules=["some_optional_dep"], - ) - assert parser.prog == "with_mock" - finally: - del sys.modules["test_entry_mock"] diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py b/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py deleted file mode 100644 index f00594c1dd..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py +++ /dev/null @@ -1,614 +0,0 @@ -"""Tests for sphinx_argparse_neo.nodes module.""" - -from __future__ import annotations - -import re -import typing as t - -import pytest -from docutils import nodes -from sphinx_argparse_neo.nodes import ( - _generate_argument_id, - argparse_argument, - argparse_group, - argparse_program, - argparse_subcommand, - argparse_subcommands, - argparse_usage, -) - -# --- Node creation tests --- - - -def test_argparse_program_creation() -> None: - """Test creating argparse_program node.""" - node = argparse_program() - node["prog"] = "myapp" - - assert node["prog"] == "myapp" - assert isinstance(node, nodes.General) - assert isinstance(node, nodes.Element) - - -def test_argparse_usage_creation() -> None: - """Test creating argparse_usage node.""" - node = argparse_usage() - node["usage"] = "myapp [-h] [--verbose] command" - - assert node["usage"] == "myapp [-h] [--verbose] command" - - -def test_argparse_group_creation() -> None: - """Test creating argparse_group node.""" - node = argparse_group() - node["title"] = "Output Options" - node["description"] = "Control output format" - - assert node["title"] == "Output Options" - assert node["description"] == "Control output format" - - -def test_argparse_argument_creation() -> None: - """Test creating argparse_argument node.""" - node = argparse_argument() - node["names"] = ["-v", "--verbose"] - node["help"] = "Enable verbose mode" - node["metavar"] = None - node["required"] = False - - assert node["names"] == ["-v", "--verbose"] - assert node["help"] == "Enable verbose mode" - - -def test_argparse_subcommands_creation() -> None: - """Test creating argparse_subcommands node.""" - node = argparse_subcommands() - node["title"] = "Commands" - - assert node["title"] == "Commands" - - -def test_argparse_subcommand_creation() -> None: - """Test creating argparse_subcommand node.""" - node = argparse_subcommand() - node["name"] = "sync" - node["aliases"] = ["s"] - node["help"] = "Synchronize repositories" - - assert node["name"] == "sync" - assert node["aliases"] == ["s"] - - -# --- Node nesting tests --- - - -def test_program_can_contain_usage() -> None: - """Test that program node can contain usage node.""" - program = argparse_program() - program["prog"] = "myapp" - - usage = argparse_usage() - usage["usage"] = "myapp [-h]" - - program.append(usage) - - assert len(program.children) == 1 - assert isinstance(program.children[0], argparse_usage) - - -def test_program_can_contain_groups() -> None: - """Test that program node can contain group nodes.""" - program = argparse_program() - - group1 = argparse_group() - group1["title"] = "Positional Arguments" - - group2 = argparse_group() - group2["title"] = "Optional Arguments" - - program.append(group1) - program.append(group2) - - assert len(program.children) == 2 - - -def test_group_can_contain_arguments() -> None: - """Test that group node can contain argument nodes.""" - group = argparse_group() - group["title"] = "Options" - - arg1 = argparse_argument() - arg1["names"] = ["-v"] - - arg2 = argparse_argument() - arg2["names"] = ["-o"] - - group.append(arg1) - group.append(arg2) - - assert len(group.children) == 2 - - -def test_subcommands_can_contain_subcommand() -> None: - """Test that subcommands container can contain subcommand nodes.""" - container = argparse_subcommands() - container["title"] = "Commands" - - sub1 = argparse_subcommand() - sub1["name"] = "sync" - - sub2 = argparse_subcommand() - sub2["name"] = "add" - - container.append(sub1) - container.append(sub2) - - assert len(container.children) == 2 - - -def test_subcommand_can_contain_program() -> None: - """Test that subcommand can contain nested program (for recursion).""" - subcommand = argparse_subcommand() - subcommand["name"] = "sync" - - nested_program = argparse_program() - nested_program["prog"] = "myapp sync" - - subcommand.append(nested_program) - - assert len(subcommand.children) == 1 - assert isinstance(subcommand.children[0], argparse_program) - - -# --- Attribute handling tests --- - - -def test_argument_with_all_attributes() -> None: - """Test argument node with all possible attributes.""" - node = argparse_argument() - node["names"] = ["-f", "--file"] - node["help"] = "Input file" - node["metavar"] = "FILE" - node["required"] = True - node["default_string"] = "input.txt" - node["choices"] = ["a", "b", "c"] - node["type_name"] = "str" - node["mutex"] = False - node["mutex_required"] = False - - assert node["names"] == ["-f", "--file"] - assert node["help"] == "Input file" - assert node["metavar"] == "FILE" - assert node["required"] is True - assert node["default_string"] == "input.txt" - assert node["choices"] == ["a", "b", "c"] - assert node["type_name"] == "str" - - -def test_argument_with_mutex_marker() -> None: - """Test argument node marked as part of mutex group.""" - node = argparse_argument() - node["names"] = ["-v"] - node["mutex"] = True - node["mutex_required"] = True - - assert node["mutex"] is True - assert node["mutex_required"] is True - - -def test_node_get_with_default() -> None: - """Test getting attributes with defaults.""" - node = argparse_argument() - node["names"] = ["-v"] - - # Attribute that exists - assert node.get("names") == ["-v"] - - # Attribute that doesn't exist - get() returns None - assert node.get("nonexistent") is None - - # Attribute with explicit default - assert node.get("help", "default help") == "default help" - - -# --- Full tree construction test --- - - -def test_full_node_tree() -> None: - """Test constructing a complete node tree.""" - # Root program - program = argparse_program() - program["prog"] = "myapp" - - # Usage - usage = argparse_usage() - usage["usage"] = "myapp [-h] [-v] command" - program.append(usage) - - # Positional group - pos_group = argparse_group() - pos_group["title"] = "Positional Arguments" - - cmd_arg = argparse_argument() - cmd_arg["names"] = ["command"] - cmd_arg["help"] = "Command to run" - pos_group.append(cmd_arg) - program.append(pos_group) - - # Optional group - opt_group = argparse_group() - opt_group["title"] = "Optional Arguments" - - verbose = argparse_argument() - verbose["names"] = ["-v", "--verbose"] - verbose["help"] = "Verbose mode" - opt_group.append(verbose) - program.append(opt_group) - - # Subcommands - subs = argparse_subcommands() - subs["title"] = "Commands" - - sync_sub = argparse_subcommand() - sync_sub["name"] = "sync" - sync_sub["help"] = "Sync repos" - subs.append(sync_sub) - - program.append(subs) - - # Verify tree structure - assert len(program.children) == 4 # usage, pos_group, opt_group, subs - assert isinstance(program.children[0], argparse_usage) - assert isinstance(program.children[1], argparse_group) - assert isinstance(program.children[2], argparse_group) - assert isinstance(program.children[3], argparse_subcommands) - - -# --- ID generation tests --- - - -def test_generate_argument_id_short_option() -> None: - """Test ID generation for short option.""" - assert _generate_argument_id(["-L"]) == "L" - - -def test_generate_argument_id_long_option() -> None: - """Test ID generation for long option.""" - assert _generate_argument_id(["--help"]) == "help" - - -def test_generate_argument_id_multiple_names() -> None: - """Test ID generation for argument with multiple names.""" - assert _generate_argument_id(["-v", "--verbose"]) == "v-verbose" - - -def test_generate_argument_id_with_prefix() -> None: - """Test ID generation with prefix for namespace isolation.""" - assert _generate_argument_id(["-L"], "shell") == "shell-L" - assert _generate_argument_id(["--help"], "load") == "load-help" - - -def test_generate_argument_id_positional() -> None: - """Test ID generation for positional argument.""" - assert _generate_argument_id(["filename"]) == "filename" - - -def test_generate_argument_id_empty() -> None: - """Test ID generation with empty names list.""" - assert _generate_argument_id([]) == "" - - -def test_generate_argument_id_prefix_no_names() -> None: - """Test that prefix alone doesn't create ID when no names.""" - assert _generate_argument_id([], "shell") == "" - - -# --- HTML rendering tests using NamedTuple for parametrization --- - - -class ArgumentHTMLCase(t.NamedTuple): - """Test case for argument HTML rendering.""" - - test_id: str - names: list[str] - metavar: str | None - help_text: str | None - default: str | None - type_name: str | None - required: bool - id_prefix: str - expected_patterns: list[str] # Regex patterns to match - - -ARGUMENT_HTML_CASES = [ - ArgumentHTMLCase( - test_id="short_option_with_metavar", - names=["-L"], - metavar="socket-name", - help_text="pass-through for tmux -L", - default="None", - type_name=None, - required=False, - id_prefix="shell", - expected_patterns=[ - r'
', - r'
', - r'-L', - r'socket-name', - r'', - r'
', - r'
Default
', - r'
None
', - r"
", - ], - ), - ArgumentHTMLCase( - test_id="long_option", - names=["--help"], - metavar=None, - help_text="show help", - default=None, - type_name=None, - required=False, - id_prefix="", - expected_patterns=[ - r'--help', - r'id="help"', - r'href="#help"', - ], - ), - ArgumentHTMLCase( - test_id="positional_argument", - names=["filename"], - metavar=None, - help_text="input file", - default=None, - type_name=None, - required=False, - id_prefix="", - expected_patterns=[ - r'filename', - r'id="filename"', - ], - ), - ArgumentHTMLCase( - test_id="multiple_names", - names=["-v", "--verbose"], - metavar=None, - help_text="Enable verbose mode", - default=None, - type_name=None, - required=False, - id_prefix="load", - expected_patterns=[ - r'id="load-v-verbose"', - r'-v', - r'--verbose', - r'href="#load-v-verbose"', - ], - ), - ArgumentHTMLCase( - test_id="metadata_definition_list", - names=["workspace_file"], - metavar="workspace-file", - help_text="checks current tmuxp for workspace files.", - default="None", - type_name="str", - required=True, - id_prefix="edit", - expected_patterns=[ - r'
', - r'
Default
', - r'
None
', - r'
Type
', - r'
str
', - r'
Required
', - r"
", - ], - ), -] - - -class MockTranslator: - """Mock HTML5Translator for testing HTML generation.""" - - def __init__(self) -> None: - """Initialize mock translator.""" - self.body: list[str] = [] - - def encode(self, text: str) -> str: - """HTML encode text.""" - return str(text).replace("&", "&").replace("<", "<").replace(">", ">") - - -def render_argument_to_html( - names: list[str], - metavar: str | None, - help_text: str | None, - default: str | None, - type_name: str | None, - required: bool, - id_prefix: str, -) -> str: - """Render an argument node to HTML string for testing. - - Parameters - ---------- - names - Argument names (e.g., ["-v", "--verbose"]). - metavar - Optional metavar (e.g., "FILE"). - help_text - Help text for the argument. - default - Default value string. - type_name - Type name for the argument (e.g., "str", "int"). - required - Whether the argument is required. - id_prefix - Prefix for the argument ID. - - Returns - ------- - str - HTML string from the mock translator's body. - """ - from sphinx_argparse_neo.nodes import ( - depart_argparse_argument_html, - visit_argparse_argument_html, - ) - - node = argparse_argument() - node["names"] = names - node["metavar"] = metavar - node["help"] = help_text - node["default_string"] = default - node["type_name"] = type_name - node["required"] = required - node["id_prefix"] = id_prefix - - translator = MockTranslator() - visit_argparse_argument_html(translator, node) - depart_argparse_argument_html(translator, node) - - return "".join(translator.body) - - -@pytest.mark.parametrize( - "case", - ARGUMENT_HTML_CASES, - ids=lambda c: c.test_id, -) -def test_argument_html_rendering(case: ArgumentHTMLCase) -> None: - """Test HTML output for argument nodes.""" - html = render_argument_to_html( - names=case.names, - metavar=case.metavar, - help_text=case.help_text, - default=case.default, - type_name=case.type_name, - required=case.required, - id_prefix=case.id_prefix, - ) - - for pattern in case.expected_patterns: - assert re.search(pattern, html), f"Pattern not found: {pattern}\nHTML: {html}" - - -def test_argument_wrapper_has_id() -> None: - """Verify wrapper div has correct ID attribute.""" - html = render_argument_to_html( - names=["-f", "--file"], - metavar="PATH", - help_text="Input file", - default=None, - type_name=None, - required=False, - id_prefix="convert", - ) - - assert 'id="convert-f-file"' in html - assert '
None: - """Verify headerlink anchor exists with correct href.""" - html = render_argument_to_html( - names=["--output"], - metavar="FILE", - help_text="Output file", - default=None, - type_name=None, - required=False, - id_prefix="freeze", - ) - - assert '' in html - - -def test_default_value_styled() -> None: - """Verify default value is wrapped in nv span within definition list.""" - html = render_argument_to_html( - names=["--format"], - metavar=None, - help_text="Output format", - default="json", - type_name=None, - required=False, - id_prefix="", - ) - - assert '
' in html - assert '
Default
' in html - assert '
json
' in html - - -def test_wrapper_div_closed() -> None: - """Verify wrapper div is properly closed.""" - html = render_argument_to_html( - names=["-v"], - metavar=None, - help_text="Verbose", - default=None, - type_name=None, - required=False, - id_prefix="", - ) - - # Count opening and closing div tags - open_divs = html.count("") - assert open_divs == close_divs, f"Unbalanced divs in HTML: {html}" - - -def test_argument_no_id_prefix() -> None: - """Test argument rendering without ID prefix.""" - html = render_argument_to_html( - names=["--debug"], - metavar=None, - help_text="Enable debug mode", - default=None, - type_name=None, - required=False, - id_prefix="", - ) - - assert 'id="debug"' in html - assert 'href="#debug"' in html - - -def test_metadata_uses_definition_list() -> None: - """Verify metadata renders as definition list, not inline paragraph.""" - html = render_argument_to_html( - names=["--format"], - metavar=None, - help_text="Output format", - default="json", - type_name="str", - required=False, - id_prefix="", - ) - - assert '
' in html - assert '
Default
' in html - assert '
json
' in html - assert '
Type
' in html - assert '
str
' in html - - -def test_required_renders_as_tag() -> None: - """Verify Required renders as standalone tag, not key-value.""" - html = render_argument_to_html( - names=["--config"], - metavar="FILE", - help_text="Config file", - default=None, - type_name=None, - required=True, - id_prefix="", - ) - - assert '
Required
' in html - # Should NOT have a matching dd for Required - assert 'argparse-meta-value">Required' not in html diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_parser.py b/tests/docs/_ext/sphinx_argparse_neo/test_parser.py deleted file mode 100644 index 48f43d937f..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_parser.py +++ /dev/null @@ -1,524 +0,0 @@ -"""Tests for sphinx_argparse_neo.parser module.""" - -from __future__ import annotations - -import argparse -import typing as t - -import pytest -from sphinx_argparse_neo.parser import ( - ArgumentInfo, - ParserInfo, - SubcommandInfo, - _extract_argument, - _format_default, - _get_action_name, - _get_type_name, - extract_parser, -) - -# --- _format_default tests --- - - -class FormatDefaultFixture(t.NamedTuple): - """Test fixture for _format_default function.""" - - test_id: str - default: t.Any - expected: str | None - - -FORMAT_DEFAULT_FIXTURES: list[FormatDefaultFixture] = [ - FormatDefaultFixture( - test_id="none_value", - default=None, - expected="None", - ), - FormatDefaultFixture( - test_id="string_value", - default="hello", - expected="hello", - ), - FormatDefaultFixture( - test_id="integer_value", - default=42, - expected="42", - ), - FormatDefaultFixture( - test_id="float_value", - default=3.14, - expected="3.14", - ), - FormatDefaultFixture( - test_id="list_value", - default=[1, 2, 3], - expected="[1, 2, 3]", - ), - FormatDefaultFixture( - test_id="suppress_value", - default=argparse.SUPPRESS, - expected=None, - ), - FormatDefaultFixture( - test_id="empty_string", - default="", - expected="", - ), - FormatDefaultFixture( - test_id="boolean_true", - default=True, - expected="True", - ), - FormatDefaultFixture( - test_id="boolean_false", - default=False, - expected="False", - ), -] - - -@pytest.mark.parametrize( - FormatDefaultFixture._fields, - FORMAT_DEFAULT_FIXTURES, - ids=[f.test_id for f in FORMAT_DEFAULT_FIXTURES], -) -def test_format_default(test_id: str, default: t.Any, expected: str | None) -> None: - """Test default value formatting.""" - assert _format_default(default) == expected - - -# --- _get_type_name tests --- - - -def test_get_type_name_int() -> None: - """Test type name extraction for int.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--count", type=int) - assert _get_type_name(action) == "int" - - -def test_get_type_name_float() -> None: - """Test type name extraction for float.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--ratio", type=float) - assert _get_type_name(action) == "float" - - -def test_get_type_name_str() -> None: - """Test type name extraction for str.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--name", type=str) - assert _get_type_name(action) == "str" - - -def test_get_type_name_none() -> None: - """Test type name extraction when no type specified.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--name") - assert _get_type_name(action) is None - - -def test_get_type_name_callable() -> None: - """Test type name extraction for callable types.""" - from pathlib import Path - - parser = argparse.ArgumentParser() - action = parser.add_argument("--path", type=Path) - assert _get_type_name(action) == "Path" - - -# --- _get_action_name tests --- - - -class ActionNameFixture(t.NamedTuple): - """Test fixture for _get_action_name function.""" - - test_id: str - action_kwargs: dict[str, t.Any] - expected: str - - -ACTION_NAME_FIXTURES: list[ActionNameFixture] = [ - ActionNameFixture( - test_id="store_default", - action_kwargs={"dest": "name"}, - expected="store", - ), - ActionNameFixture( - test_id="store_true", - action_kwargs={"action": "store_true", "dest": "flag"}, - expected="store_true", - ), - ActionNameFixture( - test_id="store_false", - action_kwargs={"action": "store_false", "dest": "flag"}, - expected="store_false", - ), - ActionNameFixture( - test_id="store_const", - action_kwargs={"action": "store_const", "const": "value", "dest": "const"}, - expected="store_const", - ), - ActionNameFixture( - test_id="append", - action_kwargs={"action": "append", "dest": "items"}, - expected="append", - ), - ActionNameFixture( - test_id="count", - action_kwargs={"action": "count", "dest": "verbose"}, - expected="count", - ), -] - - -@pytest.mark.parametrize( - ActionNameFixture._fields, - ACTION_NAME_FIXTURES, - ids=[f.test_id for f in ACTION_NAME_FIXTURES], -) -def test_get_action_name( - test_id: str, action_kwargs: dict[str, t.Any], expected: str -) -> None: - """Test action name extraction.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--test", **action_kwargs) - assert _get_action_name(action) == expected - - -# --- _extract_argument tests --- - - -def test_extract_argument_positional() -> None: - """Test extracting a positional argument.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("filename", help="Input file") - info = _extract_argument(action) - - assert info.names == ["filename"] - assert info.help == "Input file" - assert info.is_positional is True - assert info.required is True - - -def test_extract_argument_optional() -> None: - """Test extracting an optional argument.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("-v", "--verbose", action="store_true", help="Verbose") - info = _extract_argument(action) - - assert info.names == ["-v", "--verbose"] - assert info.action == "store_true" - assert info.is_positional is False - assert info.required is False - - -def test_extract_argument_with_choices() -> None: - """Test extracting argument with choices.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--format", choices=["json", "yaml", "xml"]) - info = _extract_argument(action) - - assert info.choices == ["json", "yaml", "xml"] - - -def test_extract_argument_with_metavar() -> None: - """Test extracting argument with metavar.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--output", metavar="FILE") - info = _extract_argument(action) - - assert info.metavar == "FILE" - - -def test_extract_argument_tuple_metavar() -> None: - """Test extracting argument with tuple metavar.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--range", nargs=2, metavar=("MIN", "MAX")) - info = _extract_argument(action) - - assert info.metavar == "MIN MAX" - - -def test_extract_argument_suppressed_help() -> None: - """Test extracting argument with suppressed help.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--secret", help=argparse.SUPPRESS) - info = _extract_argument(action) - - assert info.help is None - - -# --- extract_parser integration tests --- - - -def test_extract_parser_simple(simple_parser: argparse.ArgumentParser) -> None: - """Test extracting a simple parser.""" - info = extract_parser(simple_parser) - - assert info.prog == "myapp" - assert info.description == "A simple test application" - assert len(info.argument_groups) >= 1 - - # Find arguments - all_args = [arg for group in info.argument_groups for arg in group.arguments] - arg_names = [name for arg in all_args for name in arg.names] - - assert "filename" in arg_names - assert "--verbose" in arg_names or "-v" in arg_names - - -def test_extract_parser_with_groups( - parser_with_groups: argparse.ArgumentParser, -) -> None: - """Test extracting parser with custom groups.""" - info = extract_parser(parser_with_groups) - - group_titles = [g.title for g in info.argument_groups] - assert "Input Options" in group_titles - assert "Output Options" in group_titles - - -def test_extract_parser_with_subcommands( - parser_with_subcommands: argparse.ArgumentParser, -) -> None: - """Test extracting parser with subcommands.""" - info = extract_parser(parser_with_subcommands) - - assert info.subcommands is not None - assert len(info.subcommands) == 2 - - subcmd_names = [s.name for s in info.subcommands] - assert "sync" in subcmd_names - assert "add" in subcmd_names - - # Check alias - add_cmd = next(s for s in info.subcommands if s.name == "add") - assert "a" in add_cmd.aliases - - -def test_extract_parser_with_mutex(parser_with_mutex: argparse.ArgumentParser) -> None: - """Test extracting parser with mutually exclusive group.""" - info = extract_parser(parser_with_mutex) - - # Find the group with mutex - for group in info.argument_groups: - if group.mutually_exclusive: - mutex = group.mutually_exclusive[0] - assert mutex.required is True - assert len(mutex.arguments) == 2 - return - - pytest.fail("No mutually exclusive group found") - - -def test_extract_parser_with_all_actions( - parser_with_all_actions: argparse.ArgumentParser, -) -> None: - """Test extracting parser with all action types.""" - info = extract_parser(parser_with_all_actions) - - all_args = [arg for group in info.argument_groups for arg in group.arguments] - actions = {arg.dest: arg.action for arg in all_args} - - assert actions.get("name") == "store" - assert actions.get("enable") == "store_const" - assert actions.get("flag") == "store_true" - assert actions.get("item") == "append" - assert actions.get("verbose") == "count" - - -def test_extract_parser_with_types( - parser_with_types: argparse.ArgumentParser, -) -> None: - """Test extracting parser with typed arguments.""" - info = extract_parser(parser_with_types) - - all_args = [arg for group in info.argument_groups for arg in group.arguments] - types = {arg.dest: arg.type_name for arg in all_args} - - assert types.get("count") == "int" - assert types.get("ratio") == "float" - assert types.get("path") == "Path" - - -def test_extract_parser_with_nargs( - parser_with_nargs: argparse.ArgumentParser, -) -> None: - """Test extracting parser with nargs variants.""" - info = extract_parser(parser_with_nargs) - - all_args = [arg for group in info.argument_groups for arg in group.arguments] - nargs_map = {arg.dest: arg.nargs for arg in all_args} - - assert nargs_map.get("optional") == "?" - assert nargs_map.get("zero_or_more") == "*" - assert nargs_map.get("one_or_more") == "+" - assert nargs_map.get("exactly_two") == 2 - - -def test_extract_parser_with_defaults( - parser_with_defaults: argparse.ArgumentParser, -) -> None: - """Test extracting parser with various defaults.""" - info = extract_parser(parser_with_defaults) - - all_args = [arg for group in info.argument_groups for arg in group.arguments] - defaults = {arg.dest: arg.default_string for arg in all_args} - - assert defaults.get("none_default") == "None" - assert defaults.get("string_default") == "hello" - assert defaults.get("int_default") == "42" - # Suppressed default should have None default_string - assert "suppress" not in defaults or defaults.get("suppress") is None - - -def test_extract_parser_nested_subcommands( - nested_subcommands_parser: argparse.ArgumentParser, -) -> None: - """Test extracting parser with nested subcommands.""" - info = extract_parser(nested_subcommands_parser) - - assert info.subcommands is not None - assert len(info.subcommands) == 1 - - repo = info.subcommands[0] - assert repo.name == "repo" - assert repo.parser.subcommands is not None - assert len(repo.parser.subcommands) == 2 - - -def test_extract_parser_usage_generation() -> None: - """Test usage string generation.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("file") - parser.add_argument("-v", "--verbose", action="store_true") - - info = extract_parser(parser) - - assert "test" in info.bare_usage - assert "file" in info.bare_usage - - -def test_extract_parser_custom_usage() -> None: - """Test parser with custom usage string.""" - parser = argparse.ArgumentParser(prog="test", usage="test [options] file") - - info = extract_parser(parser) - - assert info.usage == "test [options] file" - - -def test_extract_parser_with_epilog() -> None: - """Test parser with epilog.""" - parser = argparse.ArgumentParser( - prog="test", - epilog="For more info, see docs.", - ) - - info = extract_parser(parser) - - assert info.epilog == "For more info, see docs." - - -# --- ArgumentInfo property tests --- - - -def test_argument_info_is_positional_true() -> None: - """Test is_positional for positional argument.""" - info = ArgumentInfo( - names=["filename"], - help=None, - default=None, - default_string=None, - choices=None, - required=True, - metavar=None, - nargs=None, - action="store", - type_name=None, - const=None, - dest="filename", - ) - assert info.is_positional is True - - -def test_argument_info_is_positional_false() -> None: - """Test is_positional for optional argument.""" - info = ArgumentInfo( - names=["-f", "--file"], - help=None, - default=None, - default_string=None, - choices=None, - required=False, - metavar=None, - nargs=None, - action="store", - type_name=None, - const=None, - dest="file", - ) - assert info.is_positional is False - - -def test_argument_info_empty_names() -> None: - """Test is_positional with empty names list.""" - info = ArgumentInfo( - names=[], - help=None, - default=None, - default_string=None, - choices=None, - required=False, - metavar=None, - nargs=None, - action="store", - type_name=None, - const=None, - dest="empty", - ) - assert info.is_positional is False - - -# --- Dataclass tests --- - - -def test_parser_info_dataclass() -> None: - """Test ParserInfo dataclass creation.""" - info = ParserInfo( - prog="test", - usage=None, - bare_usage="test [-h]", - description="Test description", - epilog="Test epilog", - argument_groups=[], - subcommands=None, - subcommand_dest=None, - ) - - assert info.prog == "test" - assert info.description == "Test description" - - -def test_subcommand_info_recursive() -> None: - """Test SubcommandInfo with nested parser.""" - nested_info = ParserInfo( - prog="nested", - usage=None, - bare_usage="nested [-h]", - description=None, - epilog=None, - argument_groups=[], - subcommands=None, - subcommand_dest=None, - ) - - sub = SubcommandInfo( - name="sub", - aliases=[], - help="Subcommand help", - parser=nested_info, - ) - - assert sub.parser.prog == "nested" diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_renderer.py b/tests/docs/_ext/sphinx_argparse_neo/test_renderer.py deleted file mode 100644 index 0a0b0da698..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_renderer.py +++ /dev/null @@ -1,498 +0,0 @@ -"""Tests for sphinx_argparse_neo.renderer module.""" - -from __future__ import annotations - -import argparse -import typing as t - -from docutils import nodes -from sphinx_argparse_neo.nodes import ( - argparse_argument, - argparse_group, - argparse_program, - argparse_subcommand, - argparse_subcommands, - argparse_usage, -) -from sphinx_argparse_neo.parser import ( - ArgumentGroup, - ArgumentInfo, - MutuallyExclusiveGroup, - ParserInfo, - SubcommandInfo, - extract_parser, -) -from sphinx_argparse_neo.renderer import ( - ArgparseRenderer, - RenderConfig, - create_renderer, -) - -# --- RenderConfig tests --- - - -def test_render_config_defaults() -> None: - """Test RenderConfig default values.""" - config = RenderConfig() - - assert config.group_title_prefix == "" - assert config.show_defaults is True - assert config.show_choices is True - assert config.show_types is True - - -def test_render_config_custom_values() -> None: - """Test RenderConfig with custom values.""" - config = RenderConfig( - group_title_prefix="CLI ", - show_defaults=False, - show_choices=False, - show_types=False, - ) - - assert config.group_title_prefix == "CLI " - assert config.show_defaults is False - assert config.show_choices is False - assert config.show_types is False - - -# --- ArgparseRenderer basic tests --- - - -def test_renderer_creation_default_config() -> None: - """Test creating renderer with default config.""" - renderer = ArgparseRenderer() - - assert renderer.config is not None - assert renderer.config.show_defaults is True - - -def test_renderer_creation_custom_config() -> None: - """Test creating renderer with custom config.""" - config = RenderConfig(group_title_prefix="CLI ") - renderer = ArgparseRenderer(config=config) - - assert renderer.config.group_title_prefix == "CLI " - - -def test_create_renderer_factory() -> None: - """Test create_renderer factory function.""" - renderer = create_renderer() - assert isinstance(renderer, ArgparseRenderer) - - -def test_create_renderer_with_config() -> None: - """Test create_renderer with custom config.""" - config = RenderConfig(show_types=False) - renderer = create_renderer(config=config) - - assert renderer.config.show_types is False - - -# --- Render method tests --- - - -def test_render_simple_parser(simple_parser: argparse.ArgumentParser) -> None: - """Test rendering a simple parser produces sibling nodes for TOC. - - The renderer now outputs sections as siblings of argparse_program: - - argparse_program (description only) - - section#usage - - section#positional-arguments - - section#options - """ - parser_info = extract_parser(simple_parser) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Should have multiple nodes: program + usage section + group sections - assert len(rendered_nodes) >= 3 - - # First node is argparse_program - assert isinstance(rendered_nodes[0], argparse_program) - assert rendered_nodes[0]["prog"] == "myapp" - - # Second node should be usage section - assert isinstance(rendered_nodes[1], nodes.section) - assert "usage" in rendered_nodes[1]["ids"] - - -def test_render_includes_usage(simple_parser: argparse.ArgumentParser) -> None: - """Test that render includes usage as a sibling section.""" - parser_info = extract_parser(simple_parser) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Find the usage section (sibling of program, not child) - usage_sections = [ - n - for n in rendered_nodes - if isinstance(n, nodes.section) and "usage" in n.get("ids", []) - ] - - assert len(usage_sections) == 1 - - # Usage section should contain argparse_usage node - usage_section = usage_sections[0] - usage_node = [c for c in usage_section.children if isinstance(c, argparse_usage)] - assert len(usage_node) == 1 - assert "myapp" in usage_node[0]["usage"] - - -def test_render_includes_groups(simple_parser: argparse.ArgumentParser) -> None: - """Test that render includes argument groups as sibling sections.""" - parser_info = extract_parser(simple_parser) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Groups are now wrapped in sections and are siblings of program - # Find sections that contain argparse_group nodes - group_sections = [ - n - for n in rendered_nodes - if isinstance(n, nodes.section) - and any(isinstance(c, argparse_group) for c in n.children) - ] - - assert len(group_sections) >= 1 - - -def test_render_groups_contain_arguments( - simple_parser: argparse.ArgumentParser, -) -> None: - """Test that rendered groups contain argument nodes.""" - parser_info = extract_parser(simple_parser) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Find sections that contain argparse_group nodes - group_sections = [ - n - for n in rendered_nodes - if isinstance(n, nodes.section) - and any(isinstance(c, argparse_group) for c in n.children) - ] - - # Collect all arguments from groups inside sections - all_args: list[argparse_argument] = [] - for section in group_sections: - for child in section.children: - if isinstance(child, argparse_group): - all_args.extend( - arg for arg in child.children if isinstance(arg, argparse_argument) - ) - - assert len(all_args) >= 1 - - -def test_render_with_subcommands( - parser_with_subcommands: argparse.ArgumentParser, -) -> None: - """Test rendering parser with subcommands.""" - parser_info = extract_parser(parser_with_subcommands) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Subcommands node is a sibling of program - subcommands_nodes = [ - n for n in rendered_nodes if isinstance(n, argparse_subcommands) - ] - - assert len(subcommands_nodes) == 1 - - # Check subcommand children - subs_container = subcommands_nodes[0] - subcmd_nodes = [ - c for c in subs_container.children if isinstance(c, argparse_subcommand) - ] - assert len(subcmd_nodes) == 2 - - -# --- Config option effect tests --- - - -def _collect_args_from_rendered_nodes( - rendered_nodes: list[nodes.Node], -) -> list[argparse_argument]: - """Collect all argparse_argument nodes from rendered output.""" - all_args: list[argparse_argument] = [] - for node in rendered_nodes: - if isinstance(node, nodes.section): - for child in node.children: - if isinstance(child, argparse_group): - all_args.extend( - arg - for arg in child.children - if isinstance(arg, argparse_argument) - ) - return all_args - - -def test_render_group_title_prefix() -> None: - """Test that group_title_prefix is applied to section titles.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("--opt") - parser_info = extract_parser(parser) - - config = RenderConfig(group_title_prefix="CLI: ") - renderer = ArgparseRenderer(config=config) - rendered_nodes = renderer.render(parser_info) - - # Find sections that contain argparse_group - group_sections = [ - n - for n in rendered_nodes - if isinstance(n, nodes.section) - and any(isinstance(c, argparse_group) for c in n.children) - ] - - # Section IDs should include the prefix (normalized) - ids = [section["ids"][0] for section in group_sections if section["ids"]] - assert any("cli:" in id_str.lower() for id_str in ids) - - -def test_render_show_defaults_false() -> None: - """Test that show_defaults=False hides defaults.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("--opt", default="value") - parser_info = extract_parser(parser) - - config = RenderConfig(show_defaults=False) - renderer = ArgparseRenderer(config=config) - rendered_nodes = renderer.render(parser_info) - - all_args = _collect_args_from_rendered_nodes(rendered_nodes) - - # Default string should not be set - for arg in all_args: - assert arg.get("default_string") is None - - -def test_render_show_choices_false() -> None: - """Test that show_choices=False hides choices.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("--format", choices=["json", "yaml"]) - parser_info = extract_parser(parser) - - config = RenderConfig(show_choices=False) - renderer = ArgparseRenderer(config=config) - rendered_nodes = renderer.render(parser_info) - - all_args = _collect_args_from_rendered_nodes(rendered_nodes) - - # Choices should not be set - for arg in all_args: - assert arg.get("choices") is None - - -def test_render_show_types_false() -> None: - """Test that show_types=False hides type info.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("--count", type=int) - parser_info = extract_parser(parser) - - config = RenderConfig(show_types=False) - renderer = ArgparseRenderer(config=config) - rendered_nodes = renderer.render(parser_info) - - all_args = _collect_args_from_rendered_nodes(rendered_nodes) - - # Type name should not be set - for arg in all_args: - assert arg.get("type_name") is None - - -# --- Individual render method tests --- - - -def test_render_usage_method() -> None: - """Test render_usage method directly.""" - parser_info = ParserInfo( - prog="test", - usage=None, - bare_usage="test [-h] [-v]", - description=None, - epilog=None, - argument_groups=[], - subcommands=None, - subcommand_dest=None, - ) - - renderer = ArgparseRenderer() - usage_node = renderer.render_usage(parser_info) - - assert isinstance(usage_node, argparse_usage) - assert usage_node["usage"] == "test [-h] [-v]" - - -def test_render_argument_method() -> None: - """Test render_argument method directly.""" - arg_info = ArgumentInfo( - names=["-v", "--verbose"], - help="Enable verbose mode", - default=False, - default_string="False", - choices=None, - required=False, - metavar=None, - nargs=None, - action="store_true", - type_name=None, - const=True, - dest="verbose", - ) - - renderer = ArgparseRenderer() - arg_node = renderer.render_argument(arg_info) - - assert isinstance(arg_node, argparse_argument) - assert arg_node["names"] == ["-v", "--verbose"] - assert arg_node["help"] == "Enable verbose mode" - - -def test_render_group_method() -> None: - """Test render_group method directly.""" - group_info = ArgumentGroup( - title="Options", - description="Available options", - arguments=[ - ArgumentInfo( - names=["-v"], - help="Verbose", - default=False, - default_string="False", - choices=None, - required=False, - metavar=None, - nargs=None, - action="store_true", - type_name=None, - const=True, - dest="verbose", - ), - ], - mutually_exclusive=[], - ) - - renderer = ArgparseRenderer() - group_node = renderer.render_group(group_info) - - assert isinstance(group_node, argparse_group) - assert group_node["title"] == "Options" - assert group_node["description"] == "Available options" - assert len(group_node.children) == 1 - - -def test_render_mutex_group_method() -> None: - """Test render_mutex_group method.""" - mutex = MutuallyExclusiveGroup( - arguments=[ - ArgumentInfo( - names=["-v"], - help="Verbose", - default=False, - default_string="False", - choices=None, - required=False, - metavar=None, - nargs=None, - action="store_true", - type_name=None, - const=True, - dest="verbose", - ), - ArgumentInfo( - names=["-q"], - help="Quiet", - default=False, - default_string="False", - choices=None, - required=False, - metavar=None, - nargs=None, - action="store_true", - type_name=None, - const=True, - dest="quiet", - ), - ], - required=True, - ) - - renderer = ArgparseRenderer() - nodes = renderer.render_mutex_group(mutex) - - assert len(nodes) == 2 - assert all(isinstance(n, argparse_argument) for n in nodes) - assert all(n.get("mutex") is True for n in nodes) - assert all(n.get("mutex_required") is True for n in nodes) - - -def test_render_subcommand_method() -> None: - """Test render_subcommand method.""" - nested_parser = ParserInfo( - prog="myapp sub", - usage=None, - bare_usage="myapp sub [-h]", - description="Subcommand description", - epilog=None, - argument_groups=[], - subcommands=None, - subcommand_dest=None, - ) - - subcmd_info = SubcommandInfo( - name="sub", - aliases=["s"], - help="Subcommand help", - parser=nested_parser, - ) - - renderer = ArgparseRenderer() - subcmd_node = renderer.render_subcommand(subcmd_info) - - assert isinstance(subcmd_node, argparse_subcommand) - assert subcmd_node["name"] == "sub" - assert subcmd_node["aliases"] == ["s"] - assert subcmd_node["help"] == "Subcommand help" - - # Should have nested program - nested = [c for c in subcmd_node.children if isinstance(c, argparse_program)] - assert len(nested) == 1 - - -# --- Post-process hook test --- - - -def test_post_process_default() -> None: - """Test that default post_process returns nodes unchanged.""" - renderer = ArgparseRenderer() - - from docutils import nodes as dn - - input_nodes = [dn.paragraph(text="test")] - - result = renderer.post_process(input_nodes) - - assert result == input_nodes - - -def test_post_process_custom() -> None: - """Test custom post_process implementation.""" - - class CustomRenderer(ArgparseRenderer): # type: ignore[misc] - def post_process(self, result_nodes: list[t.Any]) -> list[t.Any]: - # Add a marker to each node - for node in result_nodes: - node["custom_marker"] = True - return result_nodes - - renderer = CustomRenderer() - - from docutils import nodes as dn - - input_nodes = [dn.paragraph(text="test")] - - result = renderer.post_process(input_nodes) - - assert result[0].get("custom_marker") is True diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_utils.py b/tests/docs/_ext/sphinx_argparse_neo/test_utils.py deleted file mode 100644 index 129a8fd36d..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_utils.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Tests for sphinx_argparse_neo text processing utilities.""" - -from __future__ import annotations - -import typing as t - -import pytest -from sphinx_argparse_neo.utils import escape_rst_emphasis, strip_ansi - -# --- strip_ansi tests --- - - -class StripAnsiFixture(t.NamedTuple): - """Test fixture for strip_ansi function.""" - - test_id: str - input_text: str - expected: str - - -STRIP_ANSI_FIXTURES: list[StripAnsiFixture] = [ - StripAnsiFixture( - test_id="plain_text", - input_text="hello", - expected="hello", - ), - StripAnsiFixture( - test_id="green_color", - input_text="\033[32mgreen\033[0m", - expected="green", - ), - StripAnsiFixture( - test_id="bold_blue", - input_text="\033[1;34mbold\033[0m", - expected="bold", - ), - StripAnsiFixture( - test_id="multiple_codes", - input_text="\033[1m\033[32mtest\033[0m", - expected="test", - ), - StripAnsiFixture( - test_id="empty_string", - input_text="", - expected="", - ), - StripAnsiFixture( - test_id="mixed_content", - input_text="pre\033[31mred\033[0mpost", - expected="preredpost", - ), - StripAnsiFixture( - test_id="reset_only", - input_text="\033[0m", - expected="", - ), - StripAnsiFixture( - test_id="sgr_params", - input_text="\033[38;5;196mred256\033[0m", - expected="red256", - ), -] - - -@pytest.mark.parametrize( - StripAnsiFixture._fields, - STRIP_ANSI_FIXTURES, - ids=[f.test_id for f in STRIP_ANSI_FIXTURES], -) -def test_strip_ansi(test_id: str, input_text: str, expected: str) -> None: - """Test ANSI escape code stripping.""" - assert strip_ansi(input_text) == expected - - -# --- escape_rst_emphasis tests --- - - -class EscapeRstEmphasisFixture(t.NamedTuple): - """Test fixture for escape_rst_emphasis function.""" - - test_id: str - input_text: str - expected: str - - -ESCAPE_RST_EMPHASIS_FIXTURES: list[EscapeRstEmphasisFixture] = [ - EscapeRstEmphasisFixture( - test_id="glob_pattern_quoted", - input_text='tmuxp load "my-*"', - expected='tmuxp load "my-\\*"', - ), - EscapeRstEmphasisFixture( - test_id="glob_pattern_django", - input_text="django-*", - expected="django-\\*", - ), - EscapeRstEmphasisFixture( - test_id="glob_pattern_flask", - input_text="flask-*", - expected="flask-\\*", - ), - EscapeRstEmphasisFixture( - test_id="multiple_patterns", - input_text="match django-* or flask-* packages", - expected="match django-\\* or flask-\\* packages", - ), - EscapeRstEmphasisFixture( - test_id="plain_text", - input_text="plain text without patterns", - expected="plain text without patterns", - ), - EscapeRstEmphasisFixture( - test_id="rst_emphasis_unchanged", - input_text="*emphasis* is ok", - expected="*emphasis* is ok", - ), - EscapeRstEmphasisFixture( - test_id="already_escaped", - input_text="django-\\*", - expected="django-\\*", - ), - EscapeRstEmphasisFixture( - test_id="empty_string", - input_text="", - expected="", - ), - EscapeRstEmphasisFixture( - test_id="pattern_at_end", - input_text="ending with pattern-*", - expected="ending with pattern-\\*", - ), - EscapeRstEmphasisFixture( - test_id="hyphen_without_asterisk", - input_text="word-with-hyphens", - expected="word-with-hyphens", - ), - EscapeRstEmphasisFixture( - test_id="asterisk_without_hyphen", - input_text="asterisk * alone", - expected="asterisk * alone", - ), - EscapeRstEmphasisFixture( - test_id="double_asterisk", - input_text="glob-** pattern", - expected="glob-** pattern", - ), - EscapeRstEmphasisFixture( - test_id="space_after_asterisk", - input_text="word-* followed by space", - expected="word-\\* followed by space", - ), -] - - -@pytest.mark.parametrize( - EscapeRstEmphasisFixture._fields, - ESCAPE_RST_EMPHASIS_FIXTURES, - ids=[f.test_id for f in ESCAPE_RST_EMPHASIS_FIXTURES], -) -def test_escape_rst_emphasis(test_id: str, input_text: str, expected: str) -> None: - """Test RST emphasis escaping for glob patterns.""" - assert escape_rst_emphasis(input_text) == expected diff --git a/tests/docs/_ext/test_argparse_exemplar.py b/tests/docs/_ext/test_argparse_exemplar.py deleted file mode 100644 index 18679827d5..0000000000 --- a/tests/docs/_ext/test_argparse_exemplar.py +++ /dev/null @@ -1,1073 +0,0 @@ -"""Tests for argparse_exemplar sphinx extension. - -This tests the examples transformation functionality that converts argparse -epilog definition lists into proper documentation sections. - -Note: Tests for strip_ansi have moved to -tests/docs/_ext/sphinx_argparse_neo/test_utils.py since that utility -now lives in sphinx_argparse_neo.utils. -""" - -from __future__ import annotations - -import typing as t - -import pytest -from argparse_exemplar import ( # type: ignore[import-not-found] - ExemplarConfig, - _extract_sections_from_container, - _is_examples_section, - _is_usage_block, - _reorder_nodes, - is_base_examples_term, - is_examples_term, - make_section_id, - make_section_title, - process_node, - transform_definition_list, -) -from docutils import nodes - -# --- is_examples_term tests --- - - -class IsExamplesTermFixture(t.NamedTuple): - """Test fixture for is_examples_term function.""" - - test_id: str - term_text: str - expected: bool - - -IS_EXAMPLES_TERM_FIXTURES: list[IsExamplesTermFixture] = [ - IsExamplesTermFixture( - test_id="base_examples_colon", - term_text="examples:", - expected=True, - ), - IsExamplesTermFixture( - test_id="base_examples_no_colon", - term_text="examples", - expected=True, - ), - IsExamplesTermFixture( - test_id="prefixed_machine_readable", - term_text="Machine-readable output examples:", - expected=True, - ), - IsExamplesTermFixture( - test_id="prefixed_field_scoped", - term_text="Field-scoped search examples:", - expected=True, - ), - IsExamplesTermFixture( - test_id="colon_pattern", - term_text="Machine-readable output: examples:", - expected=True, - ), - IsExamplesTermFixture( - test_id="usage_not_examples", - term_text="Usage:", - expected=False, - ), - IsExamplesTermFixture( - test_id="arguments_not_examples", - term_text="Named Arguments:", - expected=False, - ), - IsExamplesTermFixture( - test_id="case_insensitive_upper", - term_text="EXAMPLES:", - expected=True, - ), - IsExamplesTermFixture( - test_id="case_insensitive_mixed", - term_text="Examples:", - expected=True, - ), -] - - -@pytest.mark.parametrize( - IsExamplesTermFixture._fields, - IS_EXAMPLES_TERM_FIXTURES, - ids=[f.test_id for f in IS_EXAMPLES_TERM_FIXTURES], -) -def test_is_examples_term(test_id: str, term_text: str, expected: bool) -> None: - """Test examples term detection.""" - assert is_examples_term(term_text) == expected - - -# --- is_base_examples_term tests --- - - -class IsBaseExamplesTermFixture(t.NamedTuple): - """Test fixture for is_base_examples_term function.""" - - test_id: str - term_text: str - expected: bool - - -IS_BASE_EXAMPLES_TERM_FIXTURES: list[IsBaseExamplesTermFixture] = [ - IsBaseExamplesTermFixture( - test_id="base_with_colon", - term_text="examples:", - expected=True, - ), - IsBaseExamplesTermFixture( - test_id="base_no_colon", - term_text="examples", - expected=True, - ), - IsBaseExamplesTermFixture( - test_id="uppercase", - term_text="EXAMPLES", - expected=True, - ), - IsBaseExamplesTermFixture( - test_id="mixed_case", - term_text="Examples:", - expected=True, - ), - IsBaseExamplesTermFixture( - test_id="prefixed_not_base", - term_text="Field-scoped examples:", - expected=False, - ), - IsBaseExamplesTermFixture( - test_id="output_examples_not_base", - term_text="Machine-readable output examples:", - expected=False, - ), - IsBaseExamplesTermFixture( - test_id="colon_pattern_not_base", - term_text="Output: examples:", - expected=False, - ), -] - - -@pytest.mark.parametrize( - IsBaseExamplesTermFixture._fields, - IS_BASE_EXAMPLES_TERM_FIXTURES, - ids=[f.test_id for f in IS_BASE_EXAMPLES_TERM_FIXTURES], -) -def test_is_base_examples_term(test_id: str, term_text: str, expected: bool) -> None: - """Test base examples term detection.""" - assert is_base_examples_term(term_text) == expected - - -# --- make_section_id tests --- - - -class MakeSectionIdFixture(t.NamedTuple): - """Test fixture for make_section_id function.""" - - test_id: str - term_text: str - counter: int - is_subsection: bool - expected: str - - -MAKE_SECTION_ID_FIXTURES: list[MakeSectionIdFixture] = [ - MakeSectionIdFixture( - test_id="base_examples", - term_text="examples:", - counter=0, - is_subsection=False, - expected="examples", - ), - MakeSectionIdFixture( - test_id="prefixed_standard", - term_text="Machine-readable output examples:", - counter=0, - is_subsection=False, - expected="machine-readable-output-examples", - ), - MakeSectionIdFixture( - test_id="subsection_omits_suffix", - term_text="Field-scoped examples:", - counter=0, - is_subsection=True, - expected="field-scoped", - ), - MakeSectionIdFixture( - test_id="with_counter", - term_text="examples:", - counter=2, - is_subsection=False, - expected="examples-2", - ), - MakeSectionIdFixture( - test_id="counter_zero_no_suffix", - term_text="examples:", - counter=0, - is_subsection=False, - expected="examples", - ), - MakeSectionIdFixture( - test_id="colon_pattern", - term_text="Machine-readable output: examples:", - counter=0, - is_subsection=False, - expected="machine-readable-output-examples", - ), - MakeSectionIdFixture( - test_id="subsection_with_counter", - term_text="Field-scoped examples:", - counter=1, - is_subsection=True, - expected="field-scoped-1", - ), -] - - -@pytest.mark.parametrize( - MakeSectionIdFixture._fields, - MAKE_SECTION_ID_FIXTURES, - ids=[f.test_id for f in MAKE_SECTION_ID_FIXTURES], -) -def test_make_section_id( - test_id: str, - term_text: str, - counter: int, - is_subsection: bool, - expected: str, -) -> None: - """Test section ID generation.""" - assert make_section_id(term_text, counter, is_subsection=is_subsection) == expected - - -def test_make_section_id_with_page_prefix() -> None: - """Test section ID generation with page_prefix for cross-page uniqueness.""" - # Base "examples:" with page_prefix becomes "sync-examples" - assert make_section_id("examples:", page_prefix="sync") == "sync-examples" - assert make_section_id("examples:", page_prefix="add") == "add-examples" - - # Prefixed examples already unique - page_prefix not added - assert ( - make_section_id("Machine-readable output examples:", page_prefix="sync") - == "machine-readable-output-examples" - ) - - # Subsection with page_prefix - result = make_section_id( - "Field-scoped examples:", is_subsection=True, page_prefix="sync" - ) - assert result == "field-scoped" - - # Empty page_prefix behaves like before - assert make_section_id("examples:", page_prefix="") == "examples" - - -# --- make_section_title tests --- - - -class MakeSectionTitleFixture(t.NamedTuple): - """Test fixture for make_section_title function.""" - - test_id: str - term_text: str - is_subsection: bool - expected: str - - -MAKE_SECTION_TITLE_FIXTURES: list[MakeSectionTitleFixture] = [ - MakeSectionTitleFixture( - test_id="base_examples", - term_text="examples:", - is_subsection=False, - expected="Examples", - ), - MakeSectionTitleFixture( - test_id="prefixed_with_examples_suffix", - term_text="Machine-readable output examples:", - is_subsection=False, - expected="Machine-Readable Output Examples", - ), - MakeSectionTitleFixture( - test_id="subsection_omits_examples", - term_text="Field-scoped examples:", - is_subsection=True, - expected="Field-Scoped", - ), - MakeSectionTitleFixture( - test_id="colon_pattern", - term_text="Machine-readable output: examples:", - is_subsection=False, - expected="Machine-Readable Output Examples", - ), - MakeSectionTitleFixture( - test_id="subsection_colon_pattern", - term_text="Machine-readable output: examples:", - is_subsection=True, - expected="Machine-Readable Output", - ), - MakeSectionTitleFixture( - test_id="base_examples_no_colon", - term_text="examples", - is_subsection=False, - expected="Examples", - ), -] - - -@pytest.mark.parametrize( - MakeSectionTitleFixture._fields, - MAKE_SECTION_TITLE_FIXTURES, - ids=[f.test_id for f in MAKE_SECTION_TITLE_FIXTURES], -) -def test_make_section_title( - test_id: str, - term_text: str, - is_subsection: bool, - expected: str, -) -> None: - """Test section title generation.""" - assert make_section_title(term_text, is_subsection=is_subsection) == expected - - -# --- transform_definition_list integration tests --- - - -def _make_dl_item(term: str, definition: str) -> nodes.definition_list_item: - """Create a definition list item for testing. - - Parameters - ---------- - term : str - The definition term text. - definition : str - The definition content text. - - Returns - ------- - nodes.definition_list_item - A definition list item with term and definition. - """ - item = nodes.definition_list_item() - term_node = nodes.term(text=term) - def_node = nodes.definition() - def_node += nodes.paragraph(text=definition) - item += term_node - item += def_node - return item - - -def test_transform_definition_list_single_examples() -> None: - """Single examples section creates one section node.""" - dl = nodes.definition_list() - dl += _make_dl_item("examples:", "vcspull ls") - - result = transform_definition_list(dl) - - assert len(result) == 1 - assert isinstance(result[0], nodes.section) - assert result[0]["ids"] == ["examples"] - - -def test_transform_definition_list_nested_examples() -> None: - """Base examples with category creates nested sections.""" - dl = nodes.definition_list() - dl += _make_dl_item("examples:", "vcspull ls") - dl += _make_dl_item("Machine-readable output examples:", "vcspull ls --json") - - result = transform_definition_list(dl) - - # Should have single parent section containing nested subsection - assert len(result) == 1 - parent = result[0] - assert isinstance(parent, nodes.section) - assert parent["ids"] == ["examples"] - - # Find nested subsection - subsections = [c for c in parent.children if isinstance(c, nodes.section)] - assert len(subsections) == 1 - assert subsections[0]["ids"] == ["machine-readable-output"] - - -def test_transform_definition_list_multiple_categories() -> None: - """Multiple example categories all nest under parent.""" - dl = nodes.definition_list() - dl += _make_dl_item("examples:", "vcspull ls") - dl += _make_dl_item("Field-scoped examples:", "vcspull ls --field name") - dl += _make_dl_item("Machine-readable output examples:", "vcspull ls --json") - - result = transform_definition_list(dl) - - assert len(result) == 1 - parent = result[0] - assert isinstance(parent, nodes.section) - - subsections = [c for c in parent.children if isinstance(c, nodes.section)] - assert len(subsections) == 2 - - -def test_transform_definition_list_preserves_non_examples() -> None: - """Non-example items preserved as definition list.""" - dl = nodes.definition_list() - dl += _make_dl_item("Usage:", "How to use this command") - dl += _make_dl_item("examples:", "vcspull ls") - - result = transform_definition_list(dl) - - # Should have both definition list (non-examples) and section (examples) - has_dl = any(isinstance(n, nodes.definition_list) for n in result) - has_section = any(isinstance(n, nodes.section) for n in result) - assert has_dl, "Non-example items should be preserved as definition list" - assert has_section, "Example items should become sections" - - -def test_transform_definition_list_no_examples() -> None: - """Definition list without examples returns empty list.""" - dl = nodes.definition_list() - dl += _make_dl_item("Usage:", "How to use") - dl += _make_dl_item("Options:", "Available options") - - result = transform_definition_list(dl) - - # All items are non-examples, should return definition list - assert len(result) == 1 - assert isinstance(result[0], nodes.definition_list) - - -def test_transform_definition_list_only_category_no_base() -> None: - """Single category example without base examples stays flat.""" - dl = nodes.definition_list() - dl += _make_dl_item("Machine-readable output examples:", "vcspull ls --json") - - result = transform_definition_list(dl) - - # Without base "examples:", no nesting - just single section - assert len(result) == 1 - assert isinstance(result[0], nodes.section) - # Should have full title since it's not nested - assert result[0]["ids"] == ["machine-readable-output-examples"] - - -def test_transform_definition_list_code_blocks_created() -> None: - """Each command line becomes a separate code block.""" - dl = nodes.definition_list() - dl += _make_dl_item("examples:", "cmd1\ncmd2\ncmd3") - - result = transform_definition_list(dl) - - section = result[0] - code_blocks = [c for c in section.children if isinstance(c, nodes.literal_block)] - assert len(code_blocks) == 3 - assert code_blocks[0].astext() == "$ cmd1" - assert code_blocks[1].astext() == "$ cmd2" - assert code_blocks[2].astext() == "$ cmd3" - - -# --- _is_usage_block tests --- - - -class IsUsageBlockFixture(t.NamedTuple): - """Test fixture for _is_usage_block function.""" - - test_id: str - node_type: str - node_text: str - expected: bool - - -IS_USAGE_BLOCK_FIXTURES: list[IsUsageBlockFixture] = [ - IsUsageBlockFixture( - test_id="literal_block_usage_lowercase", - node_type="literal_block", - node_text="usage: cmd [-h]", - expected=True, - ), - IsUsageBlockFixture( - test_id="literal_block_usage_uppercase", - node_type="literal_block", - node_text="Usage: vcspull sync", - expected=True, - ), - IsUsageBlockFixture( - test_id="literal_block_usage_leading_space", - node_type="literal_block", - node_text=" usage: cmd", - expected=True, - ), - IsUsageBlockFixture( - test_id="literal_block_not_usage", - node_type="literal_block", - node_text="some other text", - expected=False, - ), - IsUsageBlockFixture( - test_id="literal_block_usage_in_middle", - node_type="literal_block", - node_text="see usage: for more", - expected=False, - ), - IsUsageBlockFixture( - test_id="paragraph_with_usage", - node_type="paragraph", - node_text="usage: cmd", - expected=False, - ), - IsUsageBlockFixture( - test_id="section_node", - node_type="section", - node_text="", - expected=False, - ), -] - - -def _make_test_node(node_type: str, node_text: str) -> nodes.Node: - """Create a test node of the specified type. - - Parameters - ---------- - node_type : str - Type of node to create ("literal_block", "paragraph", "section"). - node_text : str - Text content for the node. - - Returns - ------- - nodes.Node - The created node. - """ - if node_type == "literal_block": - return nodes.literal_block(text=node_text) - if node_type == "paragraph": - return nodes.paragraph(text=node_text) - if node_type == "section": - return nodes.section() - msg = f"Unknown node type: {node_type}" - raise ValueError(msg) - - -@pytest.mark.parametrize( - IsUsageBlockFixture._fields, - IS_USAGE_BLOCK_FIXTURES, - ids=[f.test_id for f in IS_USAGE_BLOCK_FIXTURES], -) -def test_is_usage_block( - test_id: str, - node_type: str, - node_text: str, - expected: bool, -) -> None: - """Test usage block detection.""" - node = _make_test_node(node_type, node_text) - assert _is_usage_block(node) == expected - - -# --- _is_examples_section tests --- - - -class IsExamplesSectionFixture(t.NamedTuple): - """Test fixture for _is_examples_section function.""" - - test_id: str - node_type: str - section_ids: list[str] - expected: bool - - -IS_EXAMPLES_SECTION_FIXTURES: list[IsExamplesSectionFixture] = [ - IsExamplesSectionFixture( - test_id="section_with_examples_id", - node_type="section", - section_ids=["examples"], - expected=True, - ), - IsExamplesSectionFixture( - test_id="section_with_prefixed_examples", - node_type="section", - section_ids=["machine-readable-output-examples"], - expected=True, - ), - IsExamplesSectionFixture( - test_id="section_with_uppercase_examples", - node_type="section", - section_ids=["EXAMPLES"], - expected=True, - ), - IsExamplesSectionFixture( - test_id="section_without_examples", - node_type="section", - section_ids=["positional-arguments"], - expected=False, - ), - IsExamplesSectionFixture( - test_id="section_with_multiple_ids", - node_type="section", - section_ids=["main-id", "examples-alias"], - expected=True, - ), - IsExamplesSectionFixture( - test_id="section_empty_ids", - node_type="section", - section_ids=[], - expected=False, - ), - IsExamplesSectionFixture( - test_id="paragraph_node", - node_type="paragraph", - section_ids=[], - expected=False, - ), - IsExamplesSectionFixture( - test_id="literal_block_node", - node_type="literal_block", - section_ids=[], - expected=False, - ), -] - - -def _make_section_node(node_type: str, section_ids: list[str]) -> nodes.Node: - """Create a test node with optional section IDs. - - Parameters - ---------- - node_type : str - Type of node to create. - section_ids : list[str] - IDs to assign if creating a section. - - Returns - ------- - nodes.Node - The created node. - """ - if node_type == "section": - section = nodes.section() - section["ids"] = section_ids - return section - if node_type == "paragraph": - return nodes.paragraph() - if node_type == "literal_block": - return nodes.literal_block(text="examples") - msg = f"Unknown node type: {node_type}" - raise ValueError(msg) - - -@pytest.mark.parametrize( - IsExamplesSectionFixture._fields, - IS_EXAMPLES_SECTION_FIXTURES, - ids=[f.test_id for f in IS_EXAMPLES_SECTION_FIXTURES], -) -def test_is_examples_section( - test_id: str, - node_type: str, - section_ids: list[str], - expected: bool, -) -> None: - """Test examples section detection.""" - node = _make_section_node(node_type, section_ids) - assert _is_examples_section(node) == expected - - -# --- _reorder_nodes tests --- - - -def _make_usage_node(text: str = "usage: cmd [-h]") -> nodes.literal_block: - """Create a usage block node. - - Parameters - ---------- - text : str - Text content for the usage block. - - Returns - ------- - nodes.literal_block - A literal block node with usage text. - """ - return nodes.literal_block(text=text) - - -def _make_examples_section(section_id: str = "examples") -> nodes.section: - """Create an examples section node. - - Parameters - ---------- - section_id : str - The ID for the section. - - Returns - ------- - nodes.section - A section node with the specified ID. - """ - section = nodes.section() - section["ids"] = [section_id] - return section - - -def test_reorder_nodes_usage_after_examples() -> None: - """Usage block after examples gets moved before examples.""" - desc = nodes.paragraph(text="Description") - examples = _make_examples_section() - usage = _make_usage_node() - - # Create a non-examples section - args_section = nodes.section() - args_section["ids"] = ["arguments"] - - result = _reorder_nodes([desc, examples, usage, args_section]) - - # Should be: desc, usage, examples, args - assert len(result) == 4 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.section) - assert result[2]["ids"] == ["examples"] - assert isinstance(result[3], nodes.section) - assert result[3]["ids"] == ["arguments"] - - -def test_reorder_nodes_no_examples() -> None: - """Without examples, original order is preserved.""" - desc = nodes.paragraph(text="Description") - usage = _make_usage_node() - args = nodes.section() - args["ids"] = ["arguments"] - - result = _reorder_nodes([desc, usage, args]) - - # Order unchanged: desc, usage, args - assert len(result) == 3 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.section) - - -def test_reorder_nodes_usage_already_before_examples() -> None: - """When usage is already before examples, order is preserved.""" - desc = nodes.paragraph(text="Description") - usage = _make_usage_node() - examples = _make_examples_section() - args = nodes.section() - args["ids"] = ["arguments"] - - result = _reorder_nodes([desc, usage, examples, args]) - - # Order should be: desc, usage, examples, args - assert len(result) == 4 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.section) - assert result[2]["ids"] == ["examples"] - - -def test_reorder_nodes_empty_list() -> None: - """Empty input returns empty output.""" - result = _reorder_nodes([]) - assert result == [] - - -def test_reorder_nodes_multiple_usage_blocks() -> None: - """Multiple usage blocks are all moved before examples.""" - desc = nodes.paragraph(text="Description") - examples = _make_examples_section() - usage1 = _make_usage_node("usage: cmd1 [-h]") - usage2 = _make_usage_node("usage: cmd2 [-v]") - - result = _reorder_nodes([desc, examples, usage1, usage2]) - - # Should be: desc, usage1, usage2, examples - assert len(result) == 4 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.literal_block) - assert isinstance(result[3], nodes.section) - - -def test_reorder_nodes_multiple_examples_sections() -> None: - """Multiple examples sections are grouped together.""" - desc = nodes.paragraph(text="Description") - examples1 = _make_examples_section("examples") - usage = _make_usage_node() - examples2 = _make_examples_section("machine-readable-output-examples") - args = nodes.section() - args["ids"] = ["arguments"] - - result = _reorder_nodes([desc, examples1, usage, examples2, args]) - - # Should be: desc, usage, examples1, examples2, args - assert len(result) == 5 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert result[2]["ids"] == ["examples"] - assert result[3]["ids"] == ["machine-readable-output-examples"] - assert result[4]["ids"] == ["arguments"] - - -def test_reorder_nodes_preserves_non_examples_after() -> None: - """Non-examples nodes after examples stay at the end.""" - desc = nodes.paragraph(text="Description") - examples = _make_examples_section() - usage = _make_usage_node() - epilog = nodes.paragraph(text="Epilog") - - result = _reorder_nodes([desc, examples, usage, epilog]) - - # Should be: desc, usage, examples, epilog - assert len(result) == 4 - assert result[0].astext() == "Description" - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.section) - assert result[3].astext() == "Epilog" - - -# --- ExemplarConfig tests --- - - -def test_exemplar_config_defaults() -> None: - """ExemplarConfig has sensible defaults.""" - config = ExemplarConfig() - - assert config.examples_term_suffix == "examples" - assert config.examples_base_term == "examples" - assert config.examples_section_title == "Examples" - assert config.usage_pattern == "usage:" - assert config.command_prefix == "$ " - assert config.code_language == "console" - assert config.code_classes == ("highlight-console",) - assert config.usage_code_language == "cli-usage" - assert config.reorder_usage_before_examples is True - - -def test_exemplar_config_custom_values() -> None: - """ExemplarConfig accepts custom values.""" - config = ExemplarConfig( - examples_term_suffix="demos", - examples_base_term="demos", - examples_section_title="Demos", - usage_pattern="synopsis:", - command_prefix="> ", - code_language="bash", - code_classes=("highlight-bash",), - usage_code_language="cli-synopsis", - reorder_usage_before_examples=False, - ) - - assert config.examples_term_suffix == "demos" - assert config.examples_base_term == "demos" - assert config.examples_section_title == "Demos" - assert config.usage_pattern == "synopsis:" - assert config.command_prefix == "> " - assert config.code_language == "bash" - assert config.code_classes == ("highlight-bash",) - assert config.usage_code_language == "cli-synopsis" - assert config.reorder_usage_before_examples is False - - -# --- Config integration tests --- - - -def test_is_examples_term_with_custom_config() -> None: - """is_examples_term respects custom config.""" - config = ExemplarConfig(examples_term_suffix="demos") - - # Custom term should match - assert is_examples_term("demos:", config=config) is True - assert is_examples_term("Machine-readable output demos:", config=config) is True - - # Default term should not match - assert is_examples_term("examples:", config=config) is False - - -def test_is_base_examples_term_with_custom_config() -> None: - """is_base_examples_term respects custom config.""" - config = ExemplarConfig(examples_base_term="demos") - - # Custom term should match - assert is_base_examples_term("demos:", config=config) is True - assert is_base_examples_term("Demos", config=config) is True - - # Default term should not match - assert is_base_examples_term("examples:", config=config) is False - - # Prefixed term should not match (not base) - assert is_base_examples_term("Output demos:", config=config) is False - - -def test_make_section_id_with_custom_config() -> None: - """make_section_id respects custom config.""" - config = ExemplarConfig(examples_term_suffix="demos") - - assert make_section_id("demos:", config=config) == "demos" - assert ( - make_section_id("Machine-readable output demos:", config=config) - == "machine-readable-output-demos" - ) - assert ( - make_section_id("Field-scoped demos:", is_subsection=True, config=config) - == "field-scoped" - ) - - -def test_make_section_title_with_custom_config() -> None: - """make_section_title respects custom config.""" - config = ExemplarConfig( - examples_base_term="demos", - examples_term_suffix="demos", - examples_section_title="Demos", - ) - - assert make_section_title("demos:", config=config) == "Demos" - assert ( - make_section_title("Machine-readable output demos:", config=config) - == "Machine-Readable Output Demos" - ) - assert ( - make_section_title("Field-scoped demos:", is_subsection=True, config=config) - == "Field-Scoped" - ) - - -def test_is_usage_block_with_custom_config() -> None: - """_is_usage_block respects custom config.""" - config = ExemplarConfig(usage_pattern="synopsis:") - - # Custom pattern should match - assert ( - _is_usage_block(nodes.literal_block(text="synopsis: cmd [-h]"), config=config) - is True - ) - assert ( - _is_usage_block(nodes.literal_block(text="Synopsis: cmd"), config=config) - is True - ) - - # Default pattern should not match - assert ( - _is_usage_block(nodes.literal_block(text="usage: cmd [-h]"), config=config) - is False - ) - - -def test_is_examples_section_with_custom_config() -> None: - """_is_examples_section respects custom config.""" - config = ExemplarConfig(examples_term_suffix="demos") - - # Custom term should match - demos_section = nodes.section() - demos_section["ids"] = ["demos"] - assert _is_examples_section(demos_section, config=config) is True - - prefixed_demos = nodes.section() - prefixed_demos["ids"] = ["output-demos"] - assert _is_examples_section(prefixed_demos, config=config) is True - - # Default term should not match - examples_section = nodes.section() - examples_section["ids"] = ["examples"] - assert _is_examples_section(examples_section, config=config) is False - - -def test_reorder_nodes_disabled_via_config() -> None: - """Reordering can be disabled via config.""" - config = ExemplarConfig(reorder_usage_before_examples=False) - - desc = nodes.paragraph(text="Description") - examples = _make_examples_section() - usage = _make_usage_node() - - # Original order: desc, examples, usage - result = _reorder_nodes([desc, examples, usage], config=config) - - # Order should be preserved (not reordered) - assert len(result) == 3 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.section) # examples still in position 1 - assert isinstance(result[2], nodes.literal_block) # usage still at end - - -def test_transform_definition_list_with_custom_config() -> None: - """transform_definition_list respects custom config.""" - config = ExemplarConfig( - examples_term_suffix="demos", - examples_base_term="demos", - examples_section_title="Demos", - command_prefix="> ", - code_language="bash", - code_classes=("highlight-bash",), - ) - - dl = nodes.definition_list() - dl += _make_dl_item("demos:", "cmd1") - - result = transform_definition_list(dl, config=config) - - # Should create a section with "demos" id - assert len(result) == 1 - section = result[0] - assert isinstance(section, nodes.section) - assert section["ids"] == ["demos"] - - # Find the title - titles = [c for c in section.children if isinstance(c, nodes.title)] - assert len(titles) == 1 - assert titles[0].astext() == "Demos" - - # Find code blocks - code_blocks = [c for c in section.children if isinstance(c, nodes.literal_block)] - assert len(code_blocks) == 1 - assert code_blocks[0].astext() == "> cmd1" # Custom prefix - assert code_blocks[0]["language"] == "bash" - assert "highlight-bash" in code_blocks[0]["classes"] - - -# --- Parent reference maintenance tests --- - - -def test_process_node_maintains_parent_reference() -> None: - """Verify process_node maintains parent references after child replacement. - - When children are replaced in a container node, the docutils protocol - requires using extend() rather than direct assignment to node.children - to ensure parent-child relationships are properly maintained. - """ - # Create a container with ANSI-encoded text children - container = nodes.container() - text_with_ansi = nodes.Text("\033[32mgreen text\033[0m") - container += text_with_ansi - - # Process the node (will strip ANSI and replace children) - process_node(container) - - # Verify children have correct parent reference - for child in container.children: - assert child.parent is container, ( - f"Child {child!r} should have parent reference to container" - ) - - -def test_extract_sections_maintains_parent_reference() -> None: - """Verify _extract_sections_from_container maintains parent references. - - When remaining children are reassigned to the container, the docutils - protocol requires using extend() to maintain parent-child relationships. - """ - from sphinx_argparse_neo.nodes import argparse_program - - # Create container with mixed children - container = argparse_program() - para = nodes.paragraph(text="Description") - section = nodes.section() - section["ids"] = ["examples"] - - container += para - container += section - - # Extract sections - modified, _extracted = _extract_sections_from_container(container) - - # Verify remaining children have correct parent reference - for child in modified.children: - assert child.parent is modified, ( - f"Child {child!r} should have parent reference to modified container" - ) diff --git a/tests/docs/_ext/test_argparse_lexer.py b/tests/docs/_ext/test_argparse_lexer.py deleted file mode 100644 index 7a621f1093..0000000000 --- a/tests/docs/_ext/test_argparse_lexer.py +++ /dev/null @@ -1,825 +0,0 @@ -"""Tests for argparse_lexer Pygments extension.""" - -from __future__ import annotations - -import typing as t - -import pytest -from argparse_lexer import ( - ArgparseHelpLexer, - ArgparseLexer, - ArgparseUsageLexer, - tokenize_argparse, - tokenize_usage, -) - -# --- Helper to extract token type names --- - - -def get_tokens(text: str, lexer_class: type = ArgparseLexer) -> list[tuple[str, str]]: - """Get tokens as (type_name, value) tuples. - - Examples - -------- - >>> tokens = get_tokens("usage: cmd [-h]") - >>> any(t[0] == "Token.Name.Attribute" for t in tokens) - True - """ - lexer = lexer_class() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] - - -def get_usage_tokens(text: str) -> list[tuple[str, str]]: - """Get tokens using ArgparseUsageLexer. - - Examples - -------- - >>> tokens = get_usage_tokens("usage: cmd") - >>> tokens[0] - ('Token.Generic.Heading', 'usage:') - """ - return get_tokens(text, ArgparseUsageLexer) - - -def get_help_tokens(text: str) -> list[tuple[str, str]]: - """Get tokens using ArgparseHelpLexer. - - Examples - -------- - >>> tokens = get_help_tokens("positional arguments:") - >>> any("Subheading" in t[0] for t in tokens) - True - """ - return get_tokens(text, ArgparseHelpLexer) - - -# --- Token type fixtures --- - - -class TokenTypeFixture(t.NamedTuple): - """Test fixture for verifying specific token types.""" - - test_id: str - input_text: str - expected_token_type: str - expected_value: str - - -TOKEN_TYPE_FIXTURES: list[TokenTypeFixture] = [ - TokenTypeFixture( - test_id="usage_heading", - input_text="usage:", - expected_token_type="Token.Generic.Heading", - expected_value="usage:", - ), - TokenTypeFixture( - test_id="short_option", - input_text="-h", - expected_token_type="Token.Name.Attribute", - expected_value="-h", - ), - TokenTypeFixture( - test_id="short_option_v", - input_text="-v", - expected_token_type="Token.Name.Attribute", - expected_value="-v", - ), - TokenTypeFixture( - test_id="long_option", - input_text="--verbose", - expected_token_type="Token.Name.Tag", - expected_value="--verbose", - ), - TokenTypeFixture( - test_id="long_option_with_dashes", - input_text="--no-color", - expected_token_type="Token.Name.Tag", - expected_value="--no-color", - ), - TokenTypeFixture( - test_id="uppercase_metavar", - input_text="FILE", - expected_token_type="Token.Name.Variable", - expected_value="FILE", - ), - TokenTypeFixture( - test_id="uppercase_metavar_path", - input_text="PATH", - expected_token_type="Token.Name.Variable", - expected_value="PATH", - ), - TokenTypeFixture( - test_id="uppercase_metavar_with_underscore", - input_text="FILE_PATH", - expected_token_type="Token.Name.Variable", - expected_value="FILE_PATH", - ), - TokenTypeFixture( - test_id="command_name", - input_text="sync", - expected_token_type="Token.Name.Label", - expected_value="sync", - ), - TokenTypeFixture( - test_id="command_with_dash", - input_text="repo-name", - expected_token_type="Token.Name.Label", - expected_value="repo-name", - ), - TokenTypeFixture( - test_id="open_bracket", - input_text="[", - expected_token_type="Token.Punctuation", - expected_value="[", - ), - TokenTypeFixture( - test_id="close_bracket", - input_text="]", - expected_token_type="Token.Punctuation", - expected_value="]", - ), - TokenTypeFixture( - test_id="open_paren", - input_text="(", - expected_token_type="Token.Punctuation", - expected_value="(", - ), - TokenTypeFixture( - test_id="close_paren", - input_text=")", - expected_token_type="Token.Punctuation", - expected_value=")", - ), - TokenTypeFixture( - test_id="open_brace", - input_text="{", - expected_token_type="Token.Punctuation", - expected_value="{", - ), - TokenTypeFixture( - test_id="pipe_operator", - input_text="|", - expected_token_type="Token.Operator", - expected_value="|", - ), - TokenTypeFixture( - test_id="ellipsis", - input_text="...", - expected_token_type="Token.Punctuation", - expected_value="...", - ), -] - - -@pytest.mark.parametrize( - list(TokenTypeFixture._fields), - TOKEN_TYPE_FIXTURES, - ids=[f.test_id for f in TOKEN_TYPE_FIXTURES], -) -def test_token_type( - test_id: str, - input_text: str, - expected_token_type: str, - expected_value: str, -) -> None: - """Test individual token type detection.""" - tokens = get_usage_tokens(input_text) - # Find the expected token (skip whitespace) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t and v.strip()] - assert len(non_ws_tokens) >= 1, f"No non-whitespace tokens found for '{input_text}'" - token_type, token_value = non_ws_tokens[0] - assert token_type == expected_token_type, ( - f"Expected {expected_token_type}, got {token_type}" - ) - assert token_value == expected_value - - -# --- Choice fixtures --- - - -class ChoiceFixture(t.NamedTuple): - """Test fixture for choice enumeration patterns.""" - - test_id: str - input_text: str - expected_choices: list[str] - - -CHOICE_FIXTURES: list[ChoiceFixture] = [ - ChoiceFixture( - test_id="simple_choices", - input_text="{json,yaml,table}", - expected_choices=["json", "yaml", "table"], - ), - ChoiceFixture( - test_id="numeric_choices", - input_text="{1,2,3}", - expected_choices=["1", "2", "3"], - ), - ChoiceFixture( - test_id="auto_always_never", - input_text="{auto,always,never}", - expected_choices=["auto", "always", "never"], - ), - ChoiceFixture( - test_id="two_choices", - input_text="{a,b}", - expected_choices=["a", "b"], - ), -] - - -@pytest.mark.parametrize( - list(ChoiceFixture._fields), - CHOICE_FIXTURES, - ids=[f.test_id for f in CHOICE_FIXTURES], -) -def test_choices( - test_id: str, - input_text: str, - expected_choices: list[str], -) -> None: - """Test choice enumeration tokenization.""" - tokens = get_usage_tokens(input_text) - # Extract choice values (Name.Constant tokens) - choice_tokens = [v for t, v in tokens if t == "Token.Name.Constant"] - assert choice_tokens == expected_choices - - -# --- Mutex group fixtures --- - - -class MutexGroupFixture(t.NamedTuple): - """Test fixture for mutually exclusive group patterns.""" - - test_id: str - input_text: str - expected_options: list[str] - is_required: bool - - -MUTEX_GROUP_FIXTURES: list[MutexGroupFixture] = [ - MutexGroupFixture( - test_id="optional_short", - input_text="[-a | -b | -c]", - expected_options=["-a", "-b", "-c"], - is_required=False, - ), - MutexGroupFixture( - test_id="optional_long", - input_text="[--foo FOO | --bar BAR]", - expected_options=["--foo", "--bar"], - is_required=False, - ), - MutexGroupFixture( - test_id="required_long", - input_text="(--foo | --bar)", - expected_options=["--foo", "--bar"], - is_required=True, - ), - MutexGroupFixture( - test_id="required_with_metavar", - input_text="(--input FILE | --stdin)", - expected_options=["--input", "--stdin"], - is_required=True, - ), - MutexGroupFixture( - test_id="optional_output_formats", - input_text="[--json | --ndjson | --table]", - expected_options=["--json", "--ndjson", "--table"], - is_required=False, - ), -] - - -@pytest.mark.parametrize( - list(MutexGroupFixture._fields), - MUTEX_GROUP_FIXTURES, - ids=[f.test_id for f in MUTEX_GROUP_FIXTURES], -) -def test_mutex_groups( - test_id: str, - input_text: str, - expected_options: list[str], - is_required: bool, -) -> None: - """Test mutually exclusive group tokenization.""" - tokens = get_usage_tokens(input_text) - - # Check for proper brackets (required uses parens, optional uses brackets) - if is_required: - assert ("Token.Punctuation", "(") in tokens - assert ("Token.Punctuation", ")") in tokens - else: - assert ("Token.Punctuation", "[") in tokens - assert ("Token.Punctuation", "]") in tokens - - # Check pipe operators present - pipe_count = sum(1 for t, v in tokens if t == "Token.Operator" and v == "|") - assert pipe_count == len(expected_options) - 1 - - # Check options are present - for opt in expected_options: - if opt.startswith("--"): - assert ("Token.Name.Tag", opt) in tokens - else: - assert ("Token.Name.Attribute", opt) in tokens - - -# --- Nargs pattern fixtures --- - - -class NargsFixture(t.NamedTuple): - """Test fixture for nargs/variadic patterns.""" - - test_id: str - input_text: str - has_ellipsis: bool - has_metavar: str | None - - -NARGS_FIXTURES: list[NargsFixture] = [ - NargsFixture( - test_id="nargs_plus", - input_text="FILE ...", - has_ellipsis=True, - has_metavar="FILE", - ), - NargsFixture( - test_id="nargs_star", - input_text="[FILE ...]", - has_ellipsis=True, - has_metavar="FILE", - ), - NargsFixture( - test_id="nargs_question", - input_text="[--foo [FOO]]", - has_ellipsis=False, - has_metavar="FOO", - ), - NargsFixture( - test_id="nargs_plus_with_option", - input_text="[--bar X [X ...]]", - has_ellipsis=True, - has_metavar="X", - ), -] - - -@pytest.mark.parametrize( - list(NargsFixture._fields), - NARGS_FIXTURES, - ids=[f.test_id for f in NARGS_FIXTURES], -) -def test_nargs_patterns( - test_id: str, - input_text: str, - has_ellipsis: bool, - has_metavar: str | None, -) -> None: - """Test nargs/variadic pattern tokenization.""" - tokens = get_usage_tokens(input_text) - - # Check ellipsis - ellipsis_present = ("Token.Punctuation", "...") in tokens - assert ellipsis_present == has_ellipsis - - # Check metavar - if has_metavar: - assert ("Token.Name.Variable", has_metavar) in tokens - - -# --- Long option with value fixtures --- - - -class LongOptionValueFixture(t.NamedTuple): - """Test fixture for long options with = values.""" - - test_id: str - input_text: str - option: str - value: str - - -LONG_OPTION_VALUE_FIXTURES: list[LongOptionValueFixture] = [ - LongOptionValueFixture( - test_id="config_file", - input_text="--config=FILE", - option="--config", - value="FILE", - ), - LongOptionValueFixture( - test_id="log_level", - input_text="--log-level=DEBUG", - option="--log-level", - value="DEBUG", - ), - LongOptionValueFixture( - test_id="lowercase_value", - input_text="--output=path", - option="--output", - value="path", - ), -] - - -@pytest.mark.parametrize( - list(LongOptionValueFixture._fields), - LONG_OPTION_VALUE_FIXTURES, - ids=[f.test_id for f in LONG_OPTION_VALUE_FIXTURES], -) -def test_long_option_with_equals_value( - test_id: str, - input_text: str, - option: str, - value: str, -) -> None: - """Test long option with = value tokenization.""" - tokens = get_usage_tokens(input_text) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] - - assert len(non_ws_tokens) >= 3 - assert non_ws_tokens[0] == ("Token.Name.Tag", option) - assert non_ws_tokens[1] == ("Token.Operator", "=") - assert non_ws_tokens[2][1] == value - - -# --- Short option with value fixtures --- - - -class ShortOptionValueFixture(t.NamedTuple): - """Test fixture for short options with space-separated values.""" - - test_id: str - input_text: str - option: str - value: str - - -SHORT_OPTION_VALUE_FIXTURES: list[ShortOptionValueFixture] = [ - ShortOptionValueFixture( - test_id="config_path", - input_text="-c config-path", - option="-c", - value="config-path", - ), - ShortOptionValueFixture( - test_id="directory", - input_text="-d DIRECTORY", - option="-d", - value="DIRECTORY", - ), - ShortOptionValueFixture( - test_id="simple_name", - input_text="-r name", - option="-r", - value="name", - ), - ShortOptionValueFixture( - test_id="underscore_metavar", - input_text="-L socket_name", - option="-L", - value="socket_name", - ), - ShortOptionValueFixture( - test_id="multiple_underscores", - input_text="-f tmux_config_file", - option="-f", - value="tmux_config_file", - ), -] - - -@pytest.mark.parametrize( - list(ShortOptionValueFixture._fields), - SHORT_OPTION_VALUE_FIXTURES, - ids=[f.test_id for f in SHORT_OPTION_VALUE_FIXTURES], -) -def test_short_option_with_value( - test_id: str, - input_text: str, - option: str, - value: str, -) -> None: - """Test short option followed by value tokenization.""" - tokens = get_usage_tokens(input_text) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] - - assert len(non_ws_tokens) >= 2 - assert non_ws_tokens[0] == ("Token.Name.Attribute", option) - assert non_ws_tokens[1][1] == value - - -# --- Full usage string fixtures --- - - -class UsageStringFixture(t.NamedTuple): - """Test fixture for full usage string tokenization.""" - - test_id: str - input_text: str - expected_contains: list[tuple[str, str]] - - -USAGE_STRING_FIXTURES: list[UsageStringFixture] = [ - UsageStringFixture( - test_id="simple_usage", - input_text="usage: cmd [-h]", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "cmd"), - ("Token.Punctuation", "["), - ("Token.Name.Attribute", "-h"), - ("Token.Punctuation", "]"), - ], - ), - UsageStringFixture( - test_id="mutually_exclusive", - input_text="[--json | --ndjson | --table]", - expected_contains=[ - ("Token.Name.Tag", "--json"), - ("Token.Operator", "|"), - ("Token.Name.Tag", "--ndjson"), - ("Token.Operator", "|"), - ("Token.Name.Tag", "--table"), - ], - ), - UsageStringFixture( - test_id="subcommand", - input_text="usage: vcspull sync", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "vcspull"), - # Subcommands now use Name.Function per 30ea233 - ("Token.Name.Function", "sync"), - ], - ), - UsageStringFixture( - test_id="with_choices", - input_text="usage: cmd {a,b,c}", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Constant", "a"), - ("Token.Name.Constant", "b"), - ("Token.Name.Constant", "c"), - ], - ), - UsageStringFixture( - test_id="complex_usage", - input_text="usage: prog [-h] [--verbose] FILE ...", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "prog"), - ("Token.Name.Attribute", "-h"), - ("Token.Name.Tag", "--verbose"), - ("Token.Name.Variable", "FILE"), - ("Token.Punctuation", "..."), - ], - ), -] - - -@pytest.mark.parametrize( - list(UsageStringFixture._fields), - USAGE_STRING_FIXTURES, - ids=[f.test_id for f in USAGE_STRING_FIXTURES], -) -def test_usage_string( - test_id: str, - input_text: str, - expected_contains: list[tuple[str, str]], -) -> None: - """Test full usage string tokenization contains expected tokens.""" - tokens = get_usage_tokens(input_text) - for expected_type, expected_value in expected_contains: - assert (expected_type, expected_value) in tokens, ( - f"Expected ({expected_type}, {expected_value!r}) not found in tokens" - ) - - -# --- Section header fixtures --- - - -class SectionHeaderFixture(t.NamedTuple): - """Test fixture for section header recognition.""" - - test_id: str - input_text: str - expected_header: str - - -SECTION_HEADER_FIXTURES: list[SectionHeaderFixture] = [ - SectionHeaderFixture( - test_id="positional_arguments", - input_text="positional arguments:", - expected_header="positional arguments:", - ), - SectionHeaderFixture( - test_id="options", - input_text="options:", - expected_header="options:", - ), - SectionHeaderFixture( - test_id="optional_arguments", - input_text="optional arguments:", - expected_header="optional arguments:", - ), - SectionHeaderFixture( - test_id="custom_section", - input_text="advanced options:", - expected_header="advanced options:", - ), -] - - -@pytest.mark.parametrize( - list(SectionHeaderFixture._fields), - SECTION_HEADER_FIXTURES, - ids=[f.test_id for f in SECTION_HEADER_FIXTURES], -) -def test_section_headers( - test_id: str, - input_text: str, - expected_header: str, -) -> None: - """Test section header tokenization.""" - tokens = get_help_tokens(input_text) - # Section headers should be Generic.Subheading - # Strip newlines from token values (lexer may include trailing \n) - subheading_tokens = [ - v.strip() for t, v in tokens if t == "Token.Generic.Subheading" - ] - assert expected_header in subheading_tokens - - -# --- Full help output test --- - - -def test_full_help_output() -> None: - """Test full argparse -h output tokenization.""" - help_text = """\ -usage: vcspull sync [-h] [-c CONFIG] [-d DIRECTORY] - [--json | --ndjson | --table] - [repo-name] [path] - -positional arguments: - repo-name repository name filter - path path filter - -options: - -h, --help show this help message and exit - -c CONFIG, --config CONFIG - config file path - --json output as JSON -""" - tokens = get_help_tokens(help_text) - - # Check usage heading - assert ("Token.Generic.Heading", "usage:") in tokens - - # Check section headers - subheadings = [v for t, v in tokens if t == "Token.Generic.Subheading"] - assert "positional arguments:" in subheadings - assert "options:" in subheadings - - # Check options are recognized - assert ("Token.Name.Attribute", "-h") in tokens - assert ("Token.Name.Tag", "--help") in tokens - assert ("Token.Name.Tag", "--config") in tokens - assert ("Token.Name.Tag", "--json") in tokens - - # Check command/positional names - assert ("Token.Name.Label", "vcspull") in tokens - # Subcommands now use Name.Function per 30ea233 - assert ("Token.Name.Function", "sync") in tokens - - -# --- Real vcspull usage output test --- - - -def test_vcspull_sync_usage() -> None: - """Test real vcspull sync usage output tokenization.""" - usage_text = """\ -usage: vcspull sync [-h] [-c CONFIG] [-d DIRECTORY] - [--json | --ndjson | --table] [--color {auto,always,never}] - [--no-progress] [--verbose] - [repo-name] [path]""" - - tokens = get_usage_tokens(usage_text) - - expected = [ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "vcspull"), - # Subcommands now use Name.Function per 30ea233 - ("Token.Name.Function", "sync"), - ("Token.Name.Attribute", "-h"), - ("Token.Name.Attribute", "-c"), - ("Token.Name.Variable", "CONFIG"), - ("Token.Name.Attribute", "-d"), - ("Token.Name.Variable", "DIRECTORY"), - ("Token.Name.Tag", "--json"), - ("Token.Name.Tag", "--ndjson"), - ("Token.Name.Tag", "--table"), - ("Token.Name.Tag", "--color"), - ("Token.Name.Tag", "--no-progress"), - ("Token.Name.Tag", "--verbose"), - # Optional positional args in brackets also use Name.Function per 30ea233 - ("Token.Name.Function", "repo-name"), - ("Token.Name.Function", "path"), - ] - - for expected_type, expected_value in expected: - assert (expected_type, expected_value) in tokens, ( - f"Expected ({expected_type}, {expected_value!r}) not in tokens" - ) - - # Check choices are properly tokenized - assert ("Token.Name.Constant", "auto") in tokens - assert ("Token.Name.Constant", "always") in tokens - assert ("Token.Name.Constant", "never") in tokens - - -# --- tokenize_argparse helper function test --- - - -def test_tokenize_argparse_helper() -> None: - """Test the tokenize_argparse helper function.""" - result = tokenize_argparse("usage: cmd [-h]") - - assert result[0] == ("Token.Generic.Heading", "usage:") - assert ("Token.Name.Label", "cmd") in result - assert ("Token.Name.Attribute", "-h") in result - - -def test_tokenize_usage_helper() -> None: - """Test the tokenize_usage helper function.""" - result = tokenize_usage("usage: cmd [-h]") - - assert result[0] == ("Token.Generic.Heading", "usage:") - assert ("Token.Name.Label", "cmd") in result - assert ("Token.Name.Attribute", "-h") in result - - -# --- Lexer class selection tests --- - - -def test_argparse_lexer_usage_detection() -> None: - """Test ArgparseLexer handles usage lines correctly.""" - lexer = ArgparseLexer() - tokens = list(lexer.get_tokens("usage: cmd [-h]")) - token_types = [str(t) for t, v in tokens] - assert "Token.Generic.Heading" in token_types - - -def test_argparse_lexer_section_detection() -> None: - """Test ArgparseLexer handles section headers correctly.""" - lexer = ArgparseLexer() - tokens = list(lexer.get_tokens("positional arguments:")) - token_types = [str(t) for t, v in tokens] - assert "Token.Generic.Subheading" in token_types - - -def test_argparse_usage_lexer_standalone() -> None: - """Test ArgparseUsageLexer works standalone.""" - lexer = ArgparseUsageLexer() - tokens = list(lexer.get_tokens("usage: cmd [-h] --foo FILE")) - token_types = [str(t) for t, v in tokens] - - assert "Token.Generic.Heading" in token_types - assert "Token.Name.Label" in token_types # cmd - assert "Token.Name.Attribute" in token_types # -h - assert "Token.Name.Tag" in token_types # --foo - - -def test_argparse_help_lexer_multiline() -> None: - """Test ArgparseHelpLexer handles multiline help.""" - lexer = ArgparseHelpLexer() - help_text = """usage: cmd - -options: - -h help -""" - tokens = list(lexer.get_tokens(help_text)) - token_values = [v for t, v in tokens] - - assert "usage:" in token_values - assert "options:" in token_values or any( - "options:" in v for v in token_values if isinstance(v, str) - ) - - -def test_lowercase_metavar_with_underscores() -> None: - """Test lowercase metavars with underscores are fully captured. - - Regression test: previously `socket_name` was tokenized as `socket` + `_name`. - Example from tmuxp load usage. - """ - usage = "usage: prog [-L socket_name] [-S socket_path] [-f config_file]" - tokens = get_usage_tokens(usage) - - # All underscore metavars should be fully captured - assert ("Token.Name.Variable", "socket_name") in tokens - assert ("Token.Name.Variable", "socket_path") in tokens - assert ("Token.Name.Variable", "config_file") in tokens diff --git a/tests/docs/_ext/test_argparse_roles.py b/tests/docs/_ext/test_argparse_roles.py deleted file mode 100644 index c31e12691a..0000000000 --- a/tests/docs/_ext/test_argparse_roles.py +++ /dev/null @@ -1,439 +0,0 @@ -"""Tests for argparse_roles docutils extension.""" - -from __future__ import annotations - -import typing as t - -import pytest -from argparse_roles import ( - cli_choice_role, - cli_command_role, - cli_default_role, - cli_metavar_role, - cli_option_role, - normalize_options, - register_roles, -) -from docutils import nodes - -# --- normalize_options tests --- - - -def test_normalize_options_none() -> None: - """Test normalize_options with None input.""" - assert normalize_options(None) == {} - - -def test_normalize_options_dict() -> None: - """Test normalize_options with dict input.""" - opts = {"class": "custom"} - assert normalize_options(opts) == {"class": "custom"} - - -def test_normalize_options_empty_dict() -> None: - """Test normalize_options with empty dict input.""" - assert normalize_options({}) == {} - - -# --- CLI Option Role Tests --- - - -class OptionRoleFixture(t.NamedTuple): - """Test fixture for CLI option role.""" - - test_id: str - text: str - expected_classes: list[str] - - -OPTION_ROLE_FIXTURES: list[OptionRoleFixture] = [ - OptionRoleFixture( - test_id="long_option", - text="--verbose", - expected_classes=["cli-option", "cli-option-long"], - ), - OptionRoleFixture( - test_id="long_option_with_dash", - text="--no-color", - expected_classes=["cli-option", "cli-option-long"], - ), - OptionRoleFixture( - test_id="short_option", - text="-h", - expected_classes=["cli-option", "cli-option-short"], - ), - OptionRoleFixture( - test_id="short_option_v", - text="-v", - expected_classes=["cli-option", "cli-option-short"], - ), - OptionRoleFixture( - test_id="no_dash_prefix", - text="option", - expected_classes=["cli-option"], - ), -] - - -@pytest.mark.parametrize( - list(OptionRoleFixture._fields), - OPTION_ROLE_FIXTURES, - ids=[f.test_id for f in OPTION_ROLE_FIXTURES], -) -def test_cli_option_role( - test_id: str, - text: str, - expected_classes: list[str], -) -> None: - """Test CLI option role generates correct node classes.""" - node_list, messages = cli_option_role( - "cli-option", - f":cli-option:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == expected_classes - - -def test_cli_option_role_with_options() -> None: - """Test CLI option role accepts options parameter.""" - node_list, _messages = cli_option_role( - "cli-option", - ":cli-option:`--test`", - "--test", - 1, - None, - options={"class": "extra"}, - ) - - assert len(node_list) == 1 - # Options are normalized but classes come from role logic - assert "cli-option" in node_list[0]["classes"] - - -# --- CLI Metavar Role Tests --- - - -class MetavarRoleFixture(t.NamedTuple): - """Test fixture for CLI metavar role.""" - - test_id: str - text: str - - -METAVAR_ROLE_FIXTURES: list[MetavarRoleFixture] = [ - MetavarRoleFixture(test_id="file", text="FILE"), - MetavarRoleFixture(test_id="path", text="PATH"), - MetavarRoleFixture(test_id="directory", text="DIRECTORY"), - MetavarRoleFixture(test_id="config", text="CONFIG"), - MetavarRoleFixture(test_id="lowercase", text="value"), -] - - -@pytest.mark.parametrize( - list(MetavarRoleFixture._fields), - METAVAR_ROLE_FIXTURES, - ids=[f.test_id for f in METAVAR_ROLE_FIXTURES], -) -def test_cli_metavar_role( - test_id: str, - text: str, -) -> None: - """Test CLI metavar role generates correct node.""" - node_list, messages = cli_metavar_role( - "cli-metavar", - f":cli-metavar:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == ["cli-metavar"] - - -# --- CLI Command Role Tests --- - - -class CommandRoleFixture(t.NamedTuple): - """Test fixture for CLI command role.""" - - test_id: str - text: str - - -COMMAND_ROLE_FIXTURES: list[CommandRoleFixture] = [ - CommandRoleFixture(test_id="sync", text="sync"), - CommandRoleFixture(test_id="add", text="add"), - CommandRoleFixture(test_id="vcspull", text="vcspull"), - CommandRoleFixture(test_id="list", text="list"), - CommandRoleFixture(test_id="with_dash", text="repo-add"), -] - - -@pytest.mark.parametrize( - list(CommandRoleFixture._fields), - COMMAND_ROLE_FIXTURES, - ids=[f.test_id for f in COMMAND_ROLE_FIXTURES], -) -def test_cli_command_role( - test_id: str, - text: str, -) -> None: - """Test CLI command role generates correct node.""" - node_list, messages = cli_command_role( - "cli-command", - f":cli-command:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == ["cli-command"] - - -# --- CLI Default Role Tests --- - - -class DefaultRoleFixture(t.NamedTuple): - """Test fixture for CLI default role.""" - - test_id: str - text: str - - -DEFAULT_ROLE_FIXTURES: list[DefaultRoleFixture] = [ - DefaultRoleFixture(test_id="none", text="None"), - DefaultRoleFixture(test_id="quoted_auto", text='"auto"'), - DefaultRoleFixture(test_id="number", text="0"), - DefaultRoleFixture(test_id="empty_string", text='""'), - DefaultRoleFixture(test_id="true", text="True"), - DefaultRoleFixture(test_id="false", text="False"), -] - - -@pytest.mark.parametrize( - list(DefaultRoleFixture._fields), - DEFAULT_ROLE_FIXTURES, - ids=[f.test_id for f in DEFAULT_ROLE_FIXTURES], -) -def test_cli_default_role( - test_id: str, - text: str, -) -> None: - """Test CLI default role generates correct node.""" - node_list, messages = cli_default_role( - "cli-default", - f":cli-default:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == ["cli-default"] - - -# --- CLI Choice Role Tests --- - - -class ChoiceRoleFixture(t.NamedTuple): - """Test fixture for CLI choice role.""" - - test_id: str - text: str - - -CHOICE_ROLE_FIXTURES: list[ChoiceRoleFixture] = [ - ChoiceRoleFixture(test_id="json", text="json"), - ChoiceRoleFixture(test_id="yaml", text="yaml"), - ChoiceRoleFixture(test_id="table", text="table"), - ChoiceRoleFixture(test_id="auto", text="auto"), - ChoiceRoleFixture(test_id="always", text="always"), - ChoiceRoleFixture(test_id="never", text="never"), -] - - -@pytest.mark.parametrize( - list(ChoiceRoleFixture._fields), - CHOICE_ROLE_FIXTURES, - ids=[f.test_id for f in CHOICE_ROLE_FIXTURES], -) -def test_cli_choice_role( - test_id: str, - text: str, -) -> None: - """Test CLI choice role generates correct node.""" - node_list, messages = cli_choice_role( - "cli-choice", - f":cli-choice:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == ["cli-choice"] - - -# --- Register Roles Test --- - - -def test_register_roles() -> None: - """Test register_roles doesn't raise errors.""" - # This should not raise any exceptions - register_roles() - - -# --- Role Return Type Tests --- - - -def test_all_roles_return_correct_types() -> None: - """Test all roles return proper tuple of (nodes, messages).""" - role_functions = [ - cli_option_role, - cli_metavar_role, - cli_command_role, - cli_default_role, - cli_choice_role, - ] - - for role_func in role_functions: - result = role_func("test", ":test:`value`", "value", 1, None) - - assert isinstance(result, tuple), f"{role_func.__name__} should return tuple" - assert len(result) == 2, f"{role_func.__name__} should return 2-tuple" - - node_list, messages = result - assert isinstance(node_list, list), ( - f"{role_func.__name__} first element should be list" - ) - assert isinstance(messages, list), ( - f"{role_func.__name__} second element should be list" - ) - assert len(node_list) == 1, f"{role_func.__name__} should return one node" - assert len(messages) == 0, ( - f"{role_func.__name__} should return no error messages" - ) - - -# --- Node Structure Tests --- - - -def test_cli_option_node_structure() -> None: - """Test CLI option node has expected structure.""" - node_list, _ = cli_option_role( - "cli-option", - ":cli-option:`--test`", - "--test", - 1, - None, - ) - - node = node_list[0] - - # Check node type - assert isinstance(node, nodes.literal) - - # Check rawsource is preserved - assert node.rawsource == ":cli-option:`--test`" - - # Check text content - assert len(node.children) == 1 - assert isinstance(node.children[0], nodes.Text) - assert str(node.children[0]) == "--test" - - -def test_roles_with_none_content_parameter() -> None: - """Test roles handle None content parameter correctly.""" - node_list, messages = cli_option_role( - "cli-option", - ":cli-option:`--test`", - "--test", - 1, - None, - options=None, - content=None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - -def test_roles_with_empty_content_parameter() -> None: - """Test roles handle empty content parameter correctly.""" - node_list, messages = cli_option_role( - "cli-option", - ":cli-option:`--test`", - "--test", - 1, - None, - options={}, - content=[], - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - -# --- Edge Case Tests --- - - -def test_cli_option_role_empty_text() -> None: - """Test CLI option role with empty text.""" - node_list, _messages = cli_option_role( - "cli-option", - ":cli-option:``", - "", - 1, - None, - ) - - assert len(node_list) == 1 - assert node_list[0].astext() == "" - # No dash prefix, so only base class - assert node_list[0]["classes"] == ["cli-option"] - - -def test_cli_option_role_special_characters() -> None: - """Test CLI option role with special characters in text.""" - node_list, _messages = cli_option_role( - "cli-option", - ":cli-option:`--foo-bar_baz`", - "--foo-bar_baz", - 1, - None, - ) - - assert len(node_list) == 1 - assert node_list[0].astext() == "--foo-bar_baz" - assert "cli-option-long" in node_list[0]["classes"] diff --git a/tests/docs/_ext/test_cli_usage_lexer.py b/tests/docs/_ext/test_cli_usage_lexer.py deleted file mode 100644 index 3c32ebac6d..0000000000 --- a/tests/docs/_ext/test_cli_usage_lexer.py +++ /dev/null @@ -1,358 +0,0 @@ -"""Tests for cli_usage_lexer Pygments extension.""" - -from __future__ import annotations - -import typing as t - -import pytest -from cli_usage_lexer import ( - CLIUsageLexer, - tokenize_usage, -) - -# --- Helper to extract token type names --- - - -def get_tokens(text: str) -> list[tuple[str, str]]: - """Get tokens as (type_name, value) tuples.""" - lexer = CLIUsageLexer() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] - - -# --- Token type fixtures --- - - -class TokenTypeFixture(t.NamedTuple): - """Test fixture for verifying specific token types.""" - - test_id: str - input_text: str - expected_token_type: str - expected_value: str - - -TOKEN_TYPE_FIXTURES: list[TokenTypeFixture] = [ - TokenTypeFixture( - test_id="usage_heading", - input_text="usage:", - expected_token_type="Token.Generic.Heading", - expected_value="usage:", - ), - TokenTypeFixture( - test_id="short_option", - input_text="-h", - expected_token_type="Token.Name.Attribute", - expected_value="-h", - ), - TokenTypeFixture( - test_id="long_option", - input_text="--verbose", - expected_token_type="Token.Name.Tag", - expected_value="--verbose", - ), - TokenTypeFixture( - test_id="long_option_with_dashes", - input_text="--no-color", - expected_token_type="Token.Name.Tag", - expected_value="--no-color", - ), - TokenTypeFixture( - test_id="uppercase_metavar", - input_text="COMMAND", - expected_token_type="Token.Name.Constant", - expected_value="COMMAND", - ), - TokenTypeFixture( - test_id="uppercase_metavar_with_underscore", - input_text="FILE_PATH", - expected_token_type="Token.Name.Constant", - expected_value="FILE_PATH", - ), - TokenTypeFixture( - test_id="positional_arg", - input_text="repo-name", - expected_token_type="Token.Name.Label", - expected_value="repo-name", - ), - TokenTypeFixture( - test_id="command_name", - input_text="vcspull", - expected_token_type="Token.Name.Label", - expected_value="vcspull", - ), - TokenTypeFixture( - test_id="open_bracket", - input_text="[", - expected_token_type="Token.Punctuation", - expected_value="[", - ), - TokenTypeFixture( - test_id="close_bracket", - input_text="]", - expected_token_type="Token.Punctuation", - expected_value="]", - ), - TokenTypeFixture( - test_id="pipe_operator", - input_text="|", - expected_token_type="Token.Operator", - expected_value="|", - ), -] - - -@pytest.mark.parametrize( - TokenTypeFixture._fields, - TOKEN_TYPE_FIXTURES, - ids=[f.test_id for f in TOKEN_TYPE_FIXTURES], -) -def test_token_type( - test_id: str, - input_text: str, - expected_token_type: str, - expected_value: str, -) -> None: - """Test individual token type detection.""" - tokens = get_tokens(input_text) - # Find the expected token (skip whitespace) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t and v.strip()] - assert len(non_ws_tokens) >= 1, f"No non-whitespace tokens found for '{input_text}'" - token_type, token_value = non_ws_tokens[0] - assert token_type == expected_token_type, ( - f"Expected {expected_token_type}, got {token_type}" - ) - assert token_value == expected_value - - -# --- Short option with value fixtures --- - - -class ShortOptionValueFixture(t.NamedTuple): - """Test fixture for short options with values.""" - - test_id: str - input_text: str - option: str - value: str - - -SHORT_OPTION_VALUE_FIXTURES: list[ShortOptionValueFixture] = [ - ShortOptionValueFixture( - test_id="lowercase_value", - input_text="-c config-path", - option="-c", - value="config-path", - ), - ShortOptionValueFixture( - test_id="uppercase_value", - input_text="-d DIRECTORY", - option="-d", - value="DIRECTORY", - ), - ShortOptionValueFixture( - test_id="simple_value", - input_text="-r name", - option="-r", - value="name", - ), -] - - -@pytest.mark.parametrize( - ShortOptionValueFixture._fields, - SHORT_OPTION_VALUE_FIXTURES, - ids=[f.test_id for f in SHORT_OPTION_VALUE_FIXTURES], -) -def test_short_option_with_value( - test_id: str, - input_text: str, - option: str, - value: str, -) -> None: - """Test short option followed by value tokenization.""" - tokens = get_tokens(input_text) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] - - assert len(non_ws_tokens) >= 2 - assert non_ws_tokens[0] == ("Token.Name.Attribute", option) - # Value could be Name.Variable or Name.Constant depending on case - assert non_ws_tokens[1][1] == value - - -# --- Long option with value fixtures --- - - -class LongOptionValueFixture(t.NamedTuple): - """Test fixture for long options with = values.""" - - test_id: str - input_text: str - option: str - value: str - - -LONG_OPTION_VALUE_FIXTURES: list[LongOptionValueFixture] = [ - LongOptionValueFixture( - test_id="uppercase_value", - input_text="--config=FILE", - option="--config", - value="FILE", - ), - LongOptionValueFixture( - test_id="lowercase_value", - input_text="--output=path", - option="--output", - value="path", - ), -] - - -@pytest.mark.parametrize( - LongOptionValueFixture._fields, - LONG_OPTION_VALUE_FIXTURES, - ids=[f.test_id for f in LONG_OPTION_VALUE_FIXTURES], -) -def test_long_option_with_value( - test_id: str, - input_text: str, - option: str, - value: str, -) -> None: - """Test long option with = value tokenization.""" - tokens = get_tokens(input_text) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] - - assert len(non_ws_tokens) >= 3 - assert non_ws_tokens[0] == ("Token.Name.Tag", option) - assert non_ws_tokens[1] == ("Token.Operator", "=") - assert non_ws_tokens[2][1] == value - - -# --- Full usage string fixtures --- - - -class UsageStringFixture(t.NamedTuple): - """Test fixture for full usage string tokenization.""" - - test_id: str - input_text: str - expected_contains: list[tuple[str, str]] - - -USAGE_STRING_FIXTURES: list[UsageStringFixture] = [ - UsageStringFixture( - test_id="simple_usage", - input_text="usage: cmd [-h]", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "cmd"), - ("Token.Punctuation", "["), - ("Token.Name.Attribute", "-h"), - ("Token.Punctuation", "]"), - ], - ), - UsageStringFixture( - test_id="mutually_exclusive", - input_text="[--json | --ndjson | --table]", - expected_contains=[ - ("Token.Name.Tag", "--json"), - ("Token.Operator", "|"), - ("Token.Name.Tag", "--ndjson"), - ("Token.Operator", "|"), - ("Token.Name.Tag", "--table"), - ], - ), - UsageStringFixture( - test_id="subcommand", - input_text="usage: vcspull sync", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "vcspull"), - ("Token.Name.Label", "sync"), - ], - ), - UsageStringFixture( - test_id="positional_args", - input_text="[repo-name] [path]", - expected_contains=[ - ("Token.Punctuation", "["), - ("Token.Name.Label", "repo-name"), - ("Token.Punctuation", "]"), - ("Token.Punctuation", "["), - ("Token.Name.Label", "path"), - ("Token.Punctuation", "]"), - ], - ), -] - - -@pytest.mark.parametrize( - UsageStringFixture._fields, - USAGE_STRING_FIXTURES, - ids=[f.test_id for f in USAGE_STRING_FIXTURES], -) -def test_usage_string( - test_id: str, - input_text: str, - expected_contains: list[tuple[str, str]], -) -> None: - """Test full usage string tokenization contains expected tokens.""" - tokens = get_tokens(input_text) - for expected_type, expected_value in expected_contains: - assert (expected_type, expected_value) in tokens, ( - f"Expected ({expected_type}, {expected_value!r}) not found in tokens" - ) - - -# --- Real vcspull usage output test --- - - -def test_vcspull_sync_usage() -> None: - """Test real vcspull sync usage output tokenization.""" - usage_text = """\ -usage: vcspull sync [-h] [-c CONFIG] [-d DIRECTORY] - [--json | --ndjson | --table] [--color {auto,always,never}] - [--no-progress] [--verbose] - [repo-name] [path]""" - - tokens = get_tokens(usage_text) - - # Check key elements are present - # Note: DIRECTORY after -d is Name.Variable (option value), not Name.Constant - expected = [ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "vcspull"), - ("Token.Name.Label", "sync"), - ("Token.Name.Attribute", "-h"), - ("Token.Name.Attribute", "-c"), - ("Token.Name.Variable", "CONFIG"), # Option value, not standalone metavar - ("Token.Name.Attribute", "-d"), - ("Token.Name.Variable", "DIRECTORY"), # Option value, not standalone metavar - ("Token.Name.Tag", "--json"), - ("Token.Name.Tag", "--ndjson"), - ("Token.Name.Tag", "--table"), - ("Token.Name.Tag", "--color"), - ("Token.Name.Tag", "--no-progress"), - ("Token.Name.Tag", "--verbose"), - ("Token.Name.Label", "repo-name"), - ("Token.Name.Label", "path"), - ] - - for expected_type, expected_value in expected: - assert (expected_type, expected_value) in tokens, ( - f"Expected ({expected_type}, {expected_value!r}) not in tokens" - ) - - -# --- tokenize_usage helper function test --- - - -def test_tokenize_usage_helper() -> None: - """Test the tokenize_usage helper function.""" - result = tokenize_usage("usage: cmd [-h]") - - assert result[0] == ("Token.Generic.Heading", "usage:") - assert ("Token.Name.Label", "cmd") in result - assert ("Token.Name.Attribute", "-h") in result diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000000..b916b37b94 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,105 @@ +"""Tests for tmuxp.log module.""" + +from __future__ import annotations + +import logging +import sys + +import pytest + +from tmuxp.log import ( + LEVEL_COLORS, + DebugLogFormatter, + LogFormatter, + tmuxp_echo, +) + + +def test_level_colors_no_colorama() -> None: + """LEVEL_COLORS must be raw ANSI escape strings, not colorama objects.""" + for level, code in LEVEL_COLORS.items(): + assert code.startswith("\033["), ( + f"LEVEL_COLORS[{level!r}] should start with ANSI ESC, got {code!r}" + ) + + +def test_log_formatter_format_plain_text() -> None: + """LogFormatter.format() produces plain text without ANSI when unstylized.""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "test message" in output + assert "\033[" not in output + + +def test_debug_log_formatter_format_smoke() -> None: + """DebugLogFormatter.format() runs without error.""" + formatter = DebugLogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.DEBUG, + pathname="", + lineno=42, + msg="debug message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "debug message" in output + + +def test_timestamp_format_has_minutes() -> None: + """Timestamp format must use %M (minutes), not %m (month).""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="ts check", + args=(), + exc_info=None, + ) + formatter.format(record) + # asctime is set during format(); if %m were used, seconds portion would + # show month (01-12) instead of minutes (00-59) — we can't easily + # distinguish that directly, so just verify the format string constant. + # Inspect the source: date_format in LogFormatter.format is "%H:%M:%S" + import inspect + + import tmuxp.log as log_module + + src = inspect.getsource(log_module.LogFormatter.format) + assert '"%H:%M:%S"' in src, "Timestamp format must be %H:%M:%S (M = minutes)" + + +def test_tmuxp_echo_default_stdout(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stdout by default.""" + tmuxp_echo("hello stdout") + captured = capsys.readouterr() + assert captured.out == "hello stdout\n" + assert captured.err == "" + + +def test_tmuxp_echo_to_stderr(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stderr when file=sys.stderr.""" + tmuxp_echo("hello stderr", file=sys.stderr) + captured = capsys.readouterr() + assert captured.err == "hello stderr\n" + assert captured.out == "" + + +def test_tmuxp_echo_none_is_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo(None) produces no output.""" + tmuxp_echo(None) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index cf7cfc6371..6d7fda7fd1 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + import pytest from tmuxp.exc import TmuxpPluginException @@ -95,3 +97,15 @@ def test_libtmux_version_fail_incompatible() -> None: with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: LibtmuxVersionFailIncompatiblePlugin() assert "libtmux-incompatible-version-fail" in str(exc_info.value) + + +def test_plugin_version_check_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """_version_check() logs DEBUG with plugin name.""" + with caplog.at_level(logging.DEBUG, logger="tmuxp.plugin"): + AllVersionPassPlugin() + records = [ + r for r in caplog.records if r.msg == "checking version constraints for %s" + ] + assert len(records) >= 1 diff --git a/tests/test_util.py b/tests/test_util.py index baa592e9a8..098c8c212b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -2,6 +2,9 @@ from __future__ import annotations +import logging +import os +import pathlib import sys import typing as t @@ -9,7 +12,7 @@ from tmuxp import exc from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists -from tmuxp.util import get_session, run_before_script +from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script from .constants import FIXTURE_PATH @@ -166,3 +169,68 @@ def test_get_session_should_return_first_session_if_no_active_session( server.new_session(session_name="mysecondsession") assert get_session(server) == first_session + + +def test_get_pane_logs_debug_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """get_pane() logs DEBUG with tmux_pane extra when pane lookup fails.""" + session = server.new_session(session_name="test_pane_log") + window = session.active_window + + # Make active_pane raise Exception to trigger the logging path + monkeypatch.setattr( + type(window), + "active_pane", + property(lambda self: (_ for _ in ()).throw(Exception("mock pane error"))), + ) + + with ( + caplog.at_level(logging.DEBUG, logger="tmuxp.util"), + pytest.raises(exc.PaneNotFound), + ): + get_pane(window, current_pane=None) + + debug_records = [ + r + for r in caplog.records + if hasattr(r, "tmux_pane") and r.levelno == logging.DEBUG + ] + assert len(debug_records) >= 1 + assert debug_records[0].tmux_pane == "" + + +def test_oh_my_zsh_auto_title_logs_warning( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + tmp_path: t.Any, +) -> None: + """oh_my_zsh_auto_title() logs WARNING when DISABLE_AUTO_TITLE not set.""" + monkeypatch.setenv("SHELL", "/bin/zsh") + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + + # Create fake ~/.oh-my-zsh directory + fake_home = tmp_path / "home" + fake_home.mkdir() + oh_my_zsh_dir = fake_home / ".oh-my-zsh" + oh_my_zsh_dir.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + # Patch os.path.exists to return True for ~/.oh-my-zsh + original_exists = os.path.exists + + def patched_exists(path: str) -> bool: + if path == str(pathlib.Path("~/.oh-my-zsh").expanduser()): + return True + return original_exists(path) + + monkeypatch.setattr(os.path, "exists", patched_exists) + + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + oh_my_zsh_auto_title() + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "DISABLE_AUTO_TITLE" in warning_records[0].message diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 6b78dfcbd3..da95168f46 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import logging import os import pathlib import textwrap @@ -697,6 +698,7 @@ def test_window_index( def test_before_script_throw_error_if_retcode_error( server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test tmuxp configuration before_script when command fails.""" config_script_fails = test_utils.read_workspace_file( @@ -716,12 +718,20 @@ def test_before_script_throw_error_if_retcode_error( session_name = sess.name assert session_name is not None - with pytest.raises(exc.BeforeLoadScriptError): + with ( + caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), + pytest.raises(exc.BeforeLoadScriptError), + ): builder.build(session=sess) result = server.has_session(session_name) assert not result, "Kills session if before_script exits with errcode" + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) >= 1 + assert error_records[0].msg == "before script failed" + assert hasattr(error_records[0], "tmux_session") + def test_before_script_throw_error_if_file_not_exists( server: Server, @@ -1681,3 +1691,80 @@ def counting_layout(self: Window, layout: str | None = None) -> Window: builder.build() # 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6 assert call_count == 3 + + +def test_builder_logs_session_created( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder.build() logs INFO with tmux_session extra.""" + workspace = { + "session_name": "test_log_session", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + session_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_session") and r.msg == "session created" + ] + assert len(session_logs) >= 1 + assert session_logs[0].tmux_session == "test_log_session" + + # Verify workspace built log + built_logs = [r for r in caplog.records if r.msg == "workspace built"] + assert len(built_logs) >= 1 + + builder.session.kill() + + +def test_builder_logs_window_and_pane_creation( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder logs DEBUG with tmux_window and tmux_pane extra.""" + workspace = { + "session_name": "test_log_wp", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": [{"cmd": "echo hello"}]}, + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + window_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_window") and r.msg == "window created" + ] + assert len(window_logs) >= 1 + assert window_logs[0].tmux_window == "editor" + + pane_logs = [ + r for r in caplog.records if hasattr(r, "tmux_pane") and r.msg == "pane created" + ] + assert len(pane_logs) >= 1 + + cmd_logs = [r for r in caplog.records if r.msg == "sent command %s"] + assert len(cmd_logs) >= 1 + + builder.session.kill() diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 02ebcf5ffa..fc6d5ccd5b 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import pathlib import typing as t @@ -330,3 +331,49 @@ def test_validate_plugins() -> None: with pytest.raises(exc.WorkspaceError) as excinfo: validation.validate_schema(sconfig) assert excinfo.match("only supports list type") + + +def test_expand_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """expand() logs DEBUG with tmux_session extra.""" + workspace = {"session_name": "test_expand", "windows": [{"window_name": "main"}]} + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.expand(workspace, cwd=str(tmp_path)) + records = [r for r in caplog.records if r.msg == "expanding workspace config"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_expand" + + +def test_trickle_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """trickle() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_trickle", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.trickle(workspace) + records = [ + r for r in caplog.records if r.msg == "trickling down workspace defaults" + ] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_trickle" + + +def test_validate_schema_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """validate_schema() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_validate", + "windows": [{"window_name": "main"}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.validation"): + validation.validate_schema(workspace) + records = [r for r in caplog.records if r.msg == "validating workspace schema"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_validate" diff --git a/tests/workspace/test_finder.py b/tests/workspace/test_finder.py index dd0b270bb8..ab9a69dba4 100644 --- a/tests/workspace/test_finder.py +++ b/tests/workspace/test_finder.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import pathlib import typing as t @@ -11,6 +12,7 @@ from tmuxp import cli from tmuxp.cli.utils import tmuxp_echo from tmuxp.workspace.finders import ( + find_local_workspace_files, find_workspace_file, get_workspace_dir, get_workspace_dir_candidates, @@ -514,3 +516,53 @@ def test_get_workspace_dir_candidates_uses_private_path( path = candidate["path"] assert str(home) not in path, f"Path should be masked: {path}" assert path.startswith("~"), f"Path should start with ~: {path}" + + +def test_find_workspace_file_logs_warning_on_multiple( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """find_workspace_file() logs WARNING when multiple workspace files found.""" + project = tmp_path / "project" + project.mkdir() + + # Create multiple .tmuxp files in the same directory + (project / ".tmuxp.yaml").write_text("session_name: test") + (project / ".tmuxp.json").write_text('{"session_name": "test"}') + + monkeypatch.chdir(project) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.finders"): + find_workspace_file(str(project)) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "multiple workspace files found" in warning_records[0].message + assert hasattr(warning_records[0], "tmux_config_path") + + out = capsys.readouterr().out + assert "Multiple .tmuxp." in out + assert "undefined behavior" in out + + +def test_find_local_workspace_files_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """find_local_workspace_files() logs DEBUG with tmux_config_path extra.""" + project = tmp_path / "project" + project.mkdir() + (project / ".tmuxp.yaml").write_text("session_name: test") + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.finders"): + find_local_workspace_files(project, stop_at_home=False) + + records = [ + r + for r in caplog.records + if r.msg == "searching for local workspace files from %s" + ] + assert len(records) >= 1 + assert hasattr(records[0], "tmux_config_path") diff --git a/tests/workspace/test_freezer.py b/tests/workspace/test_freezer.py index 42fa6cc581..d42386ecef 100644 --- a/tests/workspace/test_freezer.py +++ b/tests/workspace/test_freezer.py @@ -2,9 +2,12 @@ from __future__ import annotations +import logging import time import typing +import pytest + from tests.fixtures import utils as test_utils from tmuxp._internal.config_reader import ConfigReader from tmuxp.workspace import freezer, validation @@ -106,3 +109,28 @@ def test_export_yaml( new_workspace_data = ConfigReader._from_file(yaml_workspace_file) assert config_fixture.sample_workspace.sample_workspace_dict == new_workspace_data + + +def test_freeze_logs_debug( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """freeze() logs DEBUG with tmux_session extra.""" + session_config = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/freezer/sample_workspace.yaml"), + ) + builder = WorkspaceBuilder(session_config=session_config, server=session.server) + builder.build(session=session) + + time.sleep(0.50) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.freezer"): + freezer.freeze(session) + + freeze_records = [r for r in caplog.records if r.msg == "freezing session"] + assert len(freeze_records) >= 1 + assert hasattr(freeze_records[0], "tmux_session") + + window_records = [r for r in caplog.records if r.msg == "frozen window"] + assert len(window_records) >= 1 + assert hasattr(window_records[0], "tmux_window") diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 7de727684b..0ea457e7c6 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import pytest @@ -139,3 +140,20 @@ def test_multisession_config( validation.validate_schema( importers.import_teamocil(multisession_config[session_name]), ) + + +def test_import_teamocil_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_teamocil() logs DEBUG record.""" + workspace = { + "session": { + "name": "test", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + records = [r for r in caplog.records if r.msg == "importing teamocil workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 23f567ae5d..457605f2ab 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import pytest @@ -60,3 +61,18 @@ def test_config_to_dict( assert importers.import_tmuxinator(tmuxinator_dict) == tmuxp_dict validation.validate_schema(importers.import_tmuxinator(tmuxinator_dict)) + + +def test_import_tmuxinator_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_tmuxinator() logs DEBUG record.""" + workspace = { + "name": "test", + "windows": [{"main": ["echo hi"]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + records = [r for r in caplog.records if r.msg == "importing tmuxinator workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" diff --git a/tests/workspace/test_progress.py b/tests/workspace/test_progress.py new file mode 100644 index 0000000000..336fa9dafd --- /dev/null +++ b/tests/workspace/test_progress.py @@ -0,0 +1,258 @@ +"""Tests for tmuxp workspace builder progress callback.""" + +from __future__ import annotations + +import typing as t + +import pytest +from libtmux.server import Server + +from tmuxp import exc +from tmuxp.workspace.builder import WorkspaceBuilder + + +def test_builder_on_progress_callback( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """WorkspaceBuilder calls on_progress at each build milestone.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "progress-test", + "windows": [{"window_name": "editor", "panes": [{"shell_command": []}]}], + } + + calls: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_progress=calls.append, + ) + builder.build() + + assert any("Session created:" in c for c in calls) + assert any("Creating window:" in c for c in calls) + assert any("Creating pane:" in c for c in calls) + assert "Workspace built" in calls + + +def test_builder_on_before_script_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_before_script callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-callback-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + called: list[bool] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_before_script=lambda: called.append(True), + ) + builder.build() + assert called == [] + + +def test_builder_on_script_output_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_script_output callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-output-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + lines: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_script_output=lines.append, + ) + builder.build() + assert lines == [] + + +def test_builder_on_build_event_sequence( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_build_event fires the full event sequence during build().""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "build-event-test", + "windows": [ + { + "window_name": "editor", + "panes": [{"shell_command": []}, {"shell_command": []}], + }, + {"window_name": "logs", "panes": [{"shell_command": []}]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert event_types[0] == "session_created" + assert event_types[-1] == "workspace_built" + assert event_types.count("window_started") == 2 + assert event_types.count("window_done") == 2 + assert event_types.count("pane_creating") == 3 # 2 panes + 1 pane + + created = next(e for e in events if e["event"] == "session_created") + assert created["window_total"] == 2 + assert created["session_pane_total"] == 3 # 2 panes + 1 pane + + +def test_builder_on_build_event_session_name( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event carries correct session name.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "name-check", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["name"] == "name-check" + assert created["window_total"] == 1 + + +def test_builder_on_build_event_session_pane_total( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event includes session_pane_total summing all windows' panes.""" + monkeypatch.delenv("TMUX", raising=False) + + pane: dict[str, list[object]] = {"shell_command": []} + session_config = { + "session_name": "pane-total-test", + "windows": [ + {"window_name": "w1", "panes": [pane, pane]}, + {"window_name": "w2", "panes": [pane]}, + {"window_name": "w3", "panes": [pane, pane, pane]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["session_pane_total"] == 6 # 2 + 1 + 3 + + +def test_builder_before_script_events( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_started fires before run; before_script_done fires in finally.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-events-test", + "before_script": "echo hello", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + bs_start_idx = event_types.index("before_script_started") + bs_done_idx = event_types.index("before_script_done") + win_idx = event_types.index("window_started") + assert bs_start_idx < bs_done_idx < win_idx + + +def test_builder_before_script_done_fires_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_done fires in finally even when the script fails.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-fail-test", + "before_script": "/bin/false", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + with pytest.raises(exc.BeforeLoadScriptError): + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + +def test_builder_on_build_event_pane_numbers( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """pane_creating events carry 1-based pane_num and correct pane_total.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "pane-num-test", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + {"shell_command": []}, + {"shell_command": []}, + ], + }, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + pane_events = [e for e in events if e["event"] == "pane_creating"] + assert len(pane_events) == 3 + assert [e["pane_num"] for e in pane_events] == [1, 2, 3] + assert all(e["pane_total"] == 3 for e in pane_events) diff --git a/uv.lock b/uv.lock index 1f975d6139..32264f4b46 100644 --- a/uv.lock +++ b/uv.lock @@ -39,16 +39,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -84,103 +84,119 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, - { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, - { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, - { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, - { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, - { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, - { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, - { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, - { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, - { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, - { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, - { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, - { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, - { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, - { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, - { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, - { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, - { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, - { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, - { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, - { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, - { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, - { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, - { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, - { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, - { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, - { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, - { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, - { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, - { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, - { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, - { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, - { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, - { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, - { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, - { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, - { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, - { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, - { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, - { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, - { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, - { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, - { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, - { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, - { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, - { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, - { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, - { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, - { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, - { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, - { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -207,115 +223,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, - { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, - { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, - { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, - { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, - { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, - { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, - { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, - { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, - { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, - { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, - { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, - { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [package.optional-dependencies] @@ -375,6 +391,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/f9/5d78d1dda9cb0f27d6f2305e95a58edbff935a62d53ec3227a3518cb4f72/gp_libs-0.0.17-py3-none-any.whl", hash = "sha256:7ce96d5e09980c0dc82062ab3e3b911600bd44da97a64fb78379f1af9a79d4d3", size = 16157, upload-time = "2025-12-07T22:44:48.036Z" }, ] +[[package]] +name = "gp-sphinx" +version = "0.0.1a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "gp-libs" }, + { name = "linkify-it-py" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-fonts" }, + { name = "sphinx-gptheme" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinxext-opengraph" }, + { name = "sphinxext-rediraffe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/57/7a8ea21c53c83e7c54b17610ed0c48e8db6254c2ff017c1e44ae4f7132ca/gp_sphinx-0.0.1a0.tar.gz", hash = "sha256:5cf583c06dffe6697b05a9a5f0593aa41cfe35fed8a1577324ccc87e0c0c92f7", size = 13989, upload-time = "2026-04-05T10:10:23.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/8e/5e0a0364be9c80e18bd07ec2bf43fd760c5938629035a356c172f1234daa/gp_sphinx-0.0.1a0-py3-none-any.whl", hash = "sha256:fb8310dd73ffb52827ed834f49d2e769ed3136359b54879aadd9d55ff7c6048d", size = 14399, upload-time = "2026-04-05T10:04:29.578Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -510,11 +554,11 @@ wheels = [ [[package]] name = "libtmux" -version = "0.53.1" +version = "0.55.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/99/0ac0f60d5b93a8a291be02ed1f3fcf70ff50c0526fa9a99eb462d74354b1/libtmux-0.53.1.tar.gz", hash = "sha256:0d9ca4bcf5c0fb7d7a1e4ce0c0cdcbcd7fb354a66819c3d60ccea779d83eac83", size = 413660, upload-time = "2026-02-19T00:44:24.761Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/85/99932ac9ddb90821778f8cabe32b81bbbec280dd1a14a457c512693fb11b/libtmux-0.55.0.tar.gz", hash = "sha256:cdc4aa564b2325618d73d57cb0d7d92475d02026dba2b96a94f87ad328e7e79d", size = 420859, upload-time = "2026-03-08T00:57:55.788Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/58/4a7195e692a4aedf88f3f2701db5a06e730447b504747b19385eb141b718/libtmux-0.53.1-py3-none-any.whl", hash = "sha256:8db49f32a1d5ac0f44ed6b76558c7a3baba701fbbbf6c66a31045f7f779b71a0", size = 78395, upload-time = "2026-02-19T00:44:22.961Z" }, + { url = "https://files.pythonhosted.org/packages/8b/34/b11ab24abb78c73a1b82f6471c2d71bdd1bf2c8f30768ed2f26f1dddc083/libtmux-0.55.0-py3-none-any.whl", hash = "sha256:4b746533856e022c759e5c5cae97f4932e85dae316a2afd4391d6d0e891d6ab8", size = 80094, upload-time = "2026-03-08T00:57:54.141Z" }, ] [[package]] @@ -669,7 +713,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -678,39 +722,51 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" }, + { url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" }, + { url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" }, + { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, ] [[package]] @@ -783,100 +839,100 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, - { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, - { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] @@ -890,11 +946,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -917,16 +973,16 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1033,7 +1089,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1041,9 +1097,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -1069,27 +1125,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, - { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, - { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, - { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, - { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, - { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, ] [[package]] @@ -1173,6 +1229,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, ] +[[package]] +name = "sphinx-argparse-neo" +version = "0.0.1a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/37/83215aabda61647f0fb0ab489e0c7227a59e041f565e9b44f2af073f6008/sphinx_argparse_neo-0.0.1a0.tar.gz", hash = "sha256:d40c931a687fe79dc465d850fb9904f552de575952b6dee291de61149c7bc66a", size = 37123, upload-time = "2026-04-05T10:10:23.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/4c/6b9ac99f0639eebcecef7d294c079b66c06d56f0cc9a285ebbe2b05c68c9/sphinx_argparse_neo-0.0.1a0-py3-none-any.whl", hash = "sha256:19cf9ba32d14ca686112c1d8509f268f0ab2b1822a003875de64bbd9449ab5ef", size = 41428, upload-time = "2026-04-05T10:04:31.212Z" }, +] + [[package]] name = "sphinx-autobuild" version = "2024.10.3" @@ -1271,6 +1342,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, ] +[[package]] +name = "sphinx-design" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, +] + +[[package]] +name = "sphinx-fonts" +version = "0.0.1a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/4f/be4fe35f90d0bc5090a8bd1367c53d063d5808e367e22274f16cc6978796/sphinx_fonts-0.0.1a0.tar.gz", hash = "sha256:9ca77ba151fa27963e90f899d92b1e43680e223efa3acdd3c532d5e4f0b29eed", size = 5628, upload-time = "2026-04-05T10:10:28.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/68/c8793bc5a08aee5644aed1ac0eb1ef2368cc61e31d4c1d6fd6cc52192a15/sphinx_fonts-0.0.1a0-py3-none-any.whl", hash = "sha256:aae888b35cc901ad2947c3d171a0bf02b724bc78d2677827673113c8c73e11fd", size = 4345, upload-time = "2026-04-05T10:09:11.134Z" }, +] + +[[package]] +name = "sphinx-gptheme" +version = "0.0.1a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "furo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/7c/71908e74939fd4d33d83bc39d31398deae895218dd319f626f6a3e4a1068/sphinx_gptheme-0.0.1a0.tar.gz", hash = "sha256:06f222f557dbd0e3256494f145cdbc1bc971d665e9203db19bc9c105283132ac", size = 13697, upload-time = "2026-04-05T10:10:29.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/ec/7fe7909d31da9007232a77ac5750da9b9329921e938c3d73d409e4caa4ec/sphinx_gptheme-0.0.1a0-py3-none-any.whl", hash = "sha256:da0e6bb047b01c93a7df2f81be693e46b0709a1960b250991597648f7b320dfa", size = 14690, upload-time = "2026-04-05T10:10:21.577Z" }, +] + [[package]] name = "sphinx-inline-tabs" version = "2025.12.21.14" @@ -1366,23 +1493,22 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] name = "tmuxp" -version = "1.64.2" +version = "1.67.0" source = { editable = "." } dependencies = [ - { name = "colorama" }, { name = "libtmux" }, { name = "pyyaml" }, ] @@ -1397,12 +1523,9 @@ dev = [ { name = "aafigure" }, { name = "codecov" }, { name = "coverage" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, + { name = "gp-sphinx" }, { name = "mypy" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pillow" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1410,44 +1533,25 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, { name = "ruff" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-argparse-neo" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, ] docs = [ { name = "aafigure" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "gp-sphinx" }, { name = "pillow" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-argparse-neo" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, ] lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1462,8 +1566,7 @@ testing = [ [package.metadata] requires-dist = [ - { name = "colorama", specifier = ">=0.3.9" }, - { name = "libtmux", specifier = "~=0.53.0" }, + { name = "libtmux", specifier = "~=0.55.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] @@ -1477,11 +1580,9 @@ dev = [ { name = "aafigure" }, { name = "codecov" }, { name = "coverage" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, + { name = "gp-sphinx", specifier = "==0.0.1a0" }, { name = "mypy" }, - { name = "myst-parser" }, { name = "pillow" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1489,37 +1590,23 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, { name = "ruff" }, - { name = "sphinx", specifier = "<9" }, + { name = "sphinx-argparse-neo", specifier = "==0.0.1a0" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, ] docs = [ { name = "aafigure" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, - { name = "myst-parser" }, + { name = "gp-sphinx", specifier = "==0.0.1a0" }, { name = "pillow" }, - { name = "sphinx", specifier = "<9" }, + { name = "sphinx-argparse-neo", specifier = "==0.0.1a0" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, ] lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1534,86 +1621,77 @@ testing = [ [[package]] name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, -] - -[[package]] -name = "types-colorama" -version = "0.4.15.20250801" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/37/af713e7d73ca44738c68814cbacf7a655aa40ddd2e8513d431ba78ace7b3/types_colorama-0.4.15.20250801.tar.gz", hash = "sha256:02565d13d68963d12237d3f330f5ecd622a3179f7b5b14ee7f16146270c357f5", size = 10437, upload-time = "2025-08-01T03:48:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/3a/44ccbbfef6235aeea84c74041dc6dfee6c17ff3ddba782a0250e41687ec7/types_colorama-0.4.15.20250801-py3-none-any.whl", hash = "sha256:b6e89bd3b250fdad13a8b6a465c933f4a5afe485ea2e2f104d739be50b13eea9", size = 10743, upload-time = "2025-08-01T03:48:21.774Z" }, +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 = "types-docutils" -version = "0.22.3.20260223" +version = "0.22.3.20260322" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/33/92c0129283363e3b3ba270bf6a2b7d077d949d2f90afc4abaf6e73578563/types_docutils-0.22.3.20260223.tar.gz", hash = "sha256:e90e868da82df615ea2217cf36dff31f09660daa15fc0f956af53f89c1364501", size = 57230, upload-time = "2026-02-23T04:11:21.806Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/c7/a4ae6a75d5b07d63089d5c04d450a0de4a5d48ffcb84b95659b22d3885fe/types_docutils-0.22.3.20260223-py3-none-any.whl", hash = "sha256:cc2d6b7560a28e351903db0989091474aa619ad287843a018324baee9c4d9a8f", size = 91969, upload-time = "2026-02-23T04:11:20.966Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" }, ] [[package]] name = "types-pygments" -version = "2.19.0.20251121" +version = "2.20.0.20260405" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-docutils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/3b/cd650700ce9e26b56bd1a6aa4af397bbbc1784e22a03971cb633cdb0b601/types_pygments-2.19.0.20251121.tar.gz", hash = "sha256:eef114fde2ef6265365522045eac0f8354978a566852f69e75c531f0553822b1", size = 18590, upload-time = "2025-11-21T03:03:46.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/28/7a11c06b290e370eca368dd59d9738a79657a7518f5a4021b1e187c1a16d/types_pygments-2.20.0.20260405.tar.gz", hash = "sha256:f06fe34d6457044ce7587a5a6cf73e6bc5c769c933cd9edf033379bcd7ed2897", size = 19342, upload-time = "2026-04-05T04:27:06.184Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/8a/9244b21f1d60dcc62e261435d76b02f1853b4771663d7ec7d287e47a9ba9/types_pygments-2.19.0.20251121-py3-none-any.whl", hash = "sha256:cb3bfde34eb75b984c98fb733ce4f795213bd3378f855c32e75b49318371bb25", size = 25674, upload-time = "2025-11-21T03:03:45.72Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/dabb479c2cda4fbed99a0f2045aee2bb91487c50c654a7ad6dfa327c5b82/types_pygments-2.20.0.20260405-py3-none-any.whl", hash = "sha256:79dc975f7a9c6cbfdcc32f3d31b7eb507d39a41031c3b2124f16fc2e42326954", size = 26688, upload-time = "2026-04-05T04:27:05.215Z" }, ] [[package]] @@ -1654,16 +1732,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.41.0" +version = "0.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/f2/368268300fb8af33743508d738ef7bb4d56afdb46c6d9c0fa3dd515df171/uvicorn-0.43.0.tar.gz", hash = "sha256:ab1652d2fb23abf124f36ccc399828558880def222c3cb3d98d24021520dc6e8", size = 85686, upload-time = "2026-04-03T18:37:48.984Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, + { url = "https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl", hash = "sha256:46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620", size = 68591, upload-time = "2026-04-03T18:37:47.64Z" }, ] [[package]]