diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9b6255914d..dd3eb58fd6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,14 +5,19 @@ on: branches: - master +permissions: + contents: read + id-token: write + jobs: build: runs-on: ubuntu-latest + environment: docs strategy: matrix: python-version: ['3.14'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Filter changed file paths to outputs uses: dorny/paths-filter@v3.0.2 id: changes @@ -34,7 +39,7 @@ jobs: run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 if: env.PUBLISH == 'true' with: enable-cache: true @@ -47,6 +52,10 @@ jobs: if: env.PUBLISH == 'true' run: uv sync --all-extras --dev + - name: Install just + if: env.PUBLISH == 'true' + uses: extractions/setup-just@v3 + - name: Print python versions if: env.PUBLISH == 'true' run: | @@ -56,19 +65,27 @@ jobs: - name: Build documentation if: env.PUBLISH == 'true' run: | - pushd docs; make SPHINXBUILD='uv run sphinx-build' html; popd + cd docs && just html - - name: Push documentation to S3 + - name: Configure AWS Credentials if: env.PUBLISH == 'true' - uses: jakejarvis/s3-sync-action@master + uses: aws-actions/configure-aws-credentials@v5 with: - args: --acl public-read --follow-symlinks --delete - env: - AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: 'us-west-1' # optional: defaults to us-east-1 - SOURCE_DIR: 'docs/_build/html' # optional: defaults to entire repository + role-to-assume: ${{ secrets.TMUXP_DOCS_ROLE_ARN }} + aws-region: us-east-1 + + - name: Push documentation to S3 + if: env.PUBLISH == 'true' + run: | + aws s3 sync docs/_build/html "s3://${{ secrets.TMUXP_DOCS_BUCKET }}" \ + --delete --follow-symlinks + + - name: Invalidate CloudFront + if: env.PUBLISH == 'true' + run: | + aws cloudfront create-invalidation \ + --distribution-id "${{ secrets.TMUXP_DOCS_DISTRIBUTION }}" \ + --paths "/index.html" "/objects.inv" "/searchindex.js" - name: Purge cache on Cloudflare if: env.PUBLISH == 'true' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2b2697b007..683b359067 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: python-version: ['3.14'] tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', 'master'] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 @@ -40,7 +40,7 @@ jobs: - name: Setup tmux build cache for tmux ${{ matrix.tmux-version }} id: tmux-build-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/tmux-builds/tmux-${{ matrix.tmux-version }} key: tmux-${{ matrix.tmux-version }} @@ -100,7 +100,7 @@ jobs: python-version: ['3.14'] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 diff --git a/.tmuxp.json b/.tmuxp.json index 694c2a9262..c2d1640c02 100644 --- a/.tmuxp.json +++ b/.tmuxp.json @@ -17,8 +17,8 @@ "focus": true }, "pane", - "make watch_mypy", - "make watch_test" + "just watch-mypy", + "just watch-test" ] }, { @@ -34,8 +34,8 @@ }, "pane", "pane", - "make start" + "just start" ] } ] -} +} \ No newline at end of file diff --git a/.tmuxp.yaml b/.tmuxp.yaml index 16d7044be3..b004edf622 100644 --- a/.tmuxp.yaml +++ b/.tmuxp.yaml @@ -11,8 +11,8 @@ windows: panes: - focus: true - pane - - make watch_mypy - - make watch_test + - just watch-mypy + - just watch-test - window_name: docs layout: main-horizontal options: @@ -22,4 +22,4 @@ windows: - focus: true - pane - pane - - make start + - just start diff --git a/.tool-versions b/.tool-versions index ecb9f91f62..a7778601d6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ -uv 0.9.17 +just 1.46.0 +uv 0.9.24 python 3.14 3.13.11 3.12.12 3.11.14 3.10.19 diff --git a/AGENTS.md b/AGENTS.md index 68cb1ac12e..3432304535 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,25 +9,25 @@ tmuxp is a session manager for tmux that allows users to save and load tmux sess ## Development Commands ### Testing -- `make test` or `uv run py.test` - Run all tests +- `just test` or `uv run py.test` - Run all tests - `uv run py.test tests/path/to/test.py::TestClass::test_method` - Run a single test - `uv run ptw .` - Continuous test runner with pytest-watcher - `uv run ptw . --now --doctest-modules` - Watch tests including doctests -- `make start` or `make watch_test` - Watch and run tests on file changes +- `just start` or `just watch-test` - Watch and run tests on file changes ### Code Quality -- `make ruff` or `uv run ruff check .` - Run linter +- `just ruff` or `uv run ruff check .` - Run linter - `uv run ruff check . --fix --show-fixes` - Fix linting issues automatically -- `make ruff_format` or `uv run ruff format .` - Format code -- `make mypy` or `uv run mypy` - Run type checking (strict mode enabled) -- `make watch_ruff` - Watch and lint on changes -- `make watch_mypy` - Watch and type check on changes +- `just ruff-format` or `uv run ruff format .` - Format code +- `just mypy` or `uv run mypy` - Run type checking (strict mode enabled) +- `just watch-ruff` - Watch and lint on changes +- `just watch-mypy` - Watch and type check on changes ### Documentation -- `make build_docs` - Build documentation -- `make serve_docs` - Serve docs locally at http://localhost:8013 -- `make dev_docs` - Watch and serve docs with auto-reload -- `make start_docs` - Alternative to dev_docs +- `just build-docs` - Build documentation +- `just serve-docs` - Serve docs locally at http://localhost:8013 +- `just dev-docs` - Watch and serve docs with auto-reload +- `just start-docs` - Alternative to dev_docs ### CLI Commands - `tmuxp load ` - Load a tmux session from config @@ -96,10 +96,11 @@ windows: ## Testing Guidelines +- **Use functional tests only**: Write tests as standalone functions, not classes. Avoid `class TestFoo:` groupings - use descriptive function names and file organization instead. - Use pytest fixtures from `tests/fixtures/` for tmux objects - Test plugins using mock packages in `tests/fixtures/pluginsystem/` - Use `retry_until` utilities for async tmux operations -- Run single tests with: `uv run py.test tests/file.py::TestClass::test_method` +- Run single tests with: `uv run py.test tests/file.py::test_function_name` - **Use libtmux fixtures**: Prefer `server`, `session`, `window`, `pane` fixtures over manual setup - **Avoid mocks when fixtures exist**: Use real tmux fixtures instead of `MagicMock` - **Use `tmp_path`** fixture instead of Python's `tempfile` @@ -115,10 +116,148 @@ 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 -## Important Notes from Cursor Rules +## Doctests + +**All functions and methods MUST have working doctests.** Doctests serve as both documentation and tests. + +**CRITICAL RULES:** +- Doctests MUST actually execute - never comment out function calls or similar +- Doctests MUST NOT be converted to `.. code-block::` as a workaround (code-blocks don't run) +- If you cannot create a working doctest, **STOP and ask for help** + +**Available tools for doctests:** +- `doctest_namespace` fixtures: `server`, `session`, `window`, `pane`, `tmp_path`, `test_utils` +- Ellipsis for variable output: `# doctest: +ELLIPSIS` +- Update `conftest.py` to add new fixtures to `doctest_namespace` + +**`# doctest: +SKIP` is NOT permitted** - it's just another workaround that doesn't test anything. Use the fixtures properly - tmux is required to run tests anyway. + +**Using fixtures in doctests:** +```python +>>> from tmuxp.workspace.builder import WorkspaceBuilder +>>> config = {'session_name': 'test', 'windows': [{'window_name': 'main'}]} +>>> builder = WorkspaceBuilder(session_config=config, server=server) # doctest: +ELLIPSIS +>>> builder.build() +>>> builder.session.name +'test' +``` + +**When output varies, use ellipsis:** +```python +>>> session.session_id # doctest: +ELLIPSIS +'$...' +>>> window.window_id # doctest: +ELLIPSIS +'@...' +``` + +**Additional guidelines:** +1. **Use narrative descriptions** for test sections rather than inline comments +2. **Move complex examples** to dedicated test files at `tests/examples//test_.py` +3. **Keep doctests simple and focused** on demonstrating usage +4. **Add blank lines between test sections** for improved readability + +## Documentation Standards + +### Code Blocks in Documentation + +When writing documentation (README, CHANGES, docs/), follow these rules for code blocks: + +**One command per code block.** This makes commands individually copyable. + +**Put explanations outside the code block**, not as comments inside. + +Good: + +Run the tests: + +```console +$ uv run pytest +``` + +Run with coverage: + +```console +$ uv run pytest --cov +``` + +Bad: + +```console +# Run the tests +$ uv run pytest + +# Run with coverage +$ uv run pytest --cov +``` + +## Important Notes - **QA every edit**: Run formatting and tests before committing -- **Doctest format**: Use narrative descriptions with blank lines between sections -- **Complex examples**: Move to `tests/examples//test_.py` -- **Minimum Python**: 3.9+ (as per README) +- **Minimum Python**: 3.10+ (per pyproject.toml) - **Minimum tmux**: 3.2+ (as per README) + +## CLI Color Semantics (Revision 1, 2026-01-04) + +The CLI uses semantic colors via the `Colors` class in `src/tmuxp/_internal/colors.py`. Colors are chosen based on **hierarchy level** and **semantic meaning**, not just data type. + +### Design Principles + +1. **Structural hierarchy**: Headers > Items > Details +2. **Semantic meaning**: What IS this element? +3. **Visual weight**: What should draw the eye first? +4. **Depth separation**: Parent elements should visually contain children + +Inspired by patterns from **jq** (object keys vs values), **ripgrep** (path/line/match distinction), and **mise/just** (semantic method names). + +### Hierarchy-Based Colors + +| Level | Element Type | Method | Color | Examples | +|-------|--------------|--------|-------|----------| +| **L0** | Section headers | `heading()` | Bright cyan + bold | "Local workspaces:", "Global workspaces:" | +| **L1** | Primary content | `highlight()` | Magenta + bold | Workspace names (braintree, .tmuxp) | +| **L2** | Supplementary info | `info()` | Cyan | Paths (~/.tmuxp, ~/project/.tmuxp.yaml) | +| **L3** | Metadata/labels | `muted()` | Blue | Source labels (Legacy:, XDG default:) | + +### Status-Based Colors (Override hierarchy when applicable) + +| Status | Method | Color | Examples | +|--------|--------|-------|----------| +| Success/Active | `success()` | Green | "active", "18 workspaces" | +| Warning | `warning()` | Yellow | Deprecation notices | +| Error | `error()` | Red | Error messages | + +### Example Output + +``` +Local workspaces: ← heading() bright_cyan+bold + .tmuxp ~/work/python/tmuxp/.tmuxp.yaml ← highlight() + info() + +Global workspaces (~/.tmuxp): ← heading() + info() + braintree ← highlight() + cihai ← highlight() + +Global workspace directories: ← heading() + Legacy: ~/.tmuxp (18 workspaces, active) ← muted() + info() + success() + XDG default: ~/.config/tmuxp (not found) ← muted() + info() + muted() +``` + +### Available Methods + +```python +colors = Colors() +colors.heading("Section:") # Cyan + bold (section headers) +colors.highlight("item") # Magenta + bold (primary content) +colors.info("/path/to/file") # Cyan (paths, supplementary info) +colors.muted("label:") # Blue (metadata, labels) +colors.success("ok") # Green (success states) +colors.warning("caution") # Yellow (warnings) +colors.error("failed") # Red (errors) +``` + +### Key Rules + +**Never use the same color for adjacent hierarchy levels.** If headers and items are both blue, they blend together. Each level must be visually distinct. + +**Avoid dim/faint styling.** The ANSI dim attribute (`\x1b[2m`) is too dark to read on black terminal backgrounds. This includes both standard and bright color variants with dim. + +**Bold may not render distinctly.** Some terminal/font combinations don't differentiate bold from normal weight. Don't rely on bold alone for visual distinction - pair it with color differences. diff --git a/CHANGES b/CHANGES index 5917db2d62..d0fdb67427 100644 --- a/CHANGES +++ b/CHANGES @@ -27,10 +27,73 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force // Usage: tmuxp@next load yoursession ``` -## tmuxp 1.63.0 (Yet to be released) +## tmuxp 1.64.0 (Yet to be released) +_Notes on the upcoming release will go here.__ + +## tmuxp 1.63.0 (2026-01-11) + +### Features + +#### CLI Colors (#1006) + +New semantic color output for all CLI commands: + +- New `--color` flag (auto/always/never) on root CLI for controlling color output +- Respects `NO_COLOR` and `FORCE_COLOR` environment variables per [no-color.org](https://no-color.org) standard +- Semantic color methods: `success()` (green), `warning()` (yellow), `error()` (red), `info()` (cyan), `highlight()` (magenta), `muted()` (blue) +- All commands updated with colored output: `load`, `ls`, `freeze`, `convert`, `import`, `edit`, `shell`, `debug-info` +- Interactive prompts enhanced with color support +- `PrivatePath` utility masks home directory as `~` for privacy protection in output +- Beautiful `--help` output with usage examples + +#### Search Command (#1006) + +New `tmuxp search` command for finding workspace files: + +- Field-scoped search with prefixes: `name:`, `session:`, `path:`, `window:`, `pane:` +- Matching options: `-i` (ignore-case), `-S` (smart-case), `-F` (fixed-strings), `-w` (word) +- Logic operators: `--any` for OR, `-v` for invert match +- Output formats: human (with match highlighting), `--json`, `--ndjson` for automation and piping to `jq` +- Searches local (cwd and parents) and global (~/.tmuxp/) workspaces + +#### Enhanced ls Command (#1006) + +New output options for `tmuxp ls`: + +- `--tree`: Display workspaces grouped by directory +- `--full`: Include complete parsed config content +- `--json` / `--ndjson`: Machine-readable output for automation and piping to `jq` +- Local workspace discovery from current directory and parents +- Source field distinguishes "local" vs "global" workspaces +- "Global workspace directories" section shows XDG vs legacy paths with status + +#### JSON Output for debug-info (#1006) + +- `tmuxp debug-info --json`: Structured JSON output for automation, issue reporting, and piping to `jq` + +### Development + +#### Makefile -> Justfile (#1005) + +- Migrate from `Makefile` to `justfile` for running development tasks +- Update documentation to reference `just` commands + +### Documentation + +#### pretty_argparse Sphinx extension (#1007) + +New Sphinx extension that enhances sphinx-argparse CLI documentation: + +- Strip ANSI escape codes from help text (FORCE_COLOR support) +- Transform examples into proper documentation sections +- Custom Pygments lexer (`cli-usage`) for usage block syntax highlighting +- Reorder sections so usage appears before examples + +- Migrate docs deployment to AWS OIDC authentication and AWS CLI + ## tmuxp 1.62.0 (2025-12-14) ### Breaking changes diff --git a/Makefile b/Makefile deleted file mode 100644 index 9749320440..0000000000 --- a/Makefile +++ /dev/null @@ -1,63 +0,0 @@ -PY_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]py$$' 2> /dev/null -TEST_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]\(yaml\|py\)$$' 2> /dev/null -DOC_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]rst\$\|.*[.]md\$\|.*[.]css\$\|.*[.]py\$\|mkdocs\.yml\|CHANGES\|TODO\|.*conf\.py' 2> /dev/null -SHELL := /bin/bash - -entr_warn: - @echo "----------------------------------------------------------" - @echo " ! File watching functionality non-operational ! " - @echo " " - @echo "Install entr(1) to automatically run tasks on file change." - @echo "See https://eradman.com/entrproject/ " - @echo "----------------------------------------------------------" - -test: - uv run py.test $(test) - -start: - $(MAKE) test; uv run ptw . - -watch_test: - if command -v entr > /dev/null; then ${TEST_FILES} | entr -c $(MAKE) test; else $(MAKE) test entr_warn; fi - -build_docs: - $(MAKE) -C docs html - -watch_docs: - if command -v entr > /dev/null; then ${DOC_FILES} | entr -c $(MAKE) build_docs; else $(MAKE) build_docs entr_warn; fi - -serve_docs: - $(MAKE) -C docs serve - -dev_docs: - $(MAKE) -j watch_docs serve_docs - -start_docs: - $(MAKE) -C docs start - -design_docs: - $(MAKE) -C docs design - -ruff_format: - uv run ruff format . - -ruff: - uv run ruff check . - -watch_ruff: - if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) ruff; else $(MAKE) ruff entr_warn; fi - -mypy: - uv run mypy `${PY_FILES}` - -watch_mypy: - if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) mypy; else $(MAKE) mypy entr_warn; fi - -format_markdown: - npx prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES - -monkeytype_create: - uv run monkeytype run `uv run which py.test` - -monkeytype_apply: - uv run monkeytype list-modules | xargs -n1 -I{} sh -c 'uv run monkeytype apply {}' diff --git a/conftest.py b/conftest.py index 1c6c63e6b6..0cd35869ca 100644 --- a/conftest.py +++ b/conftest.py @@ -98,18 +98,31 @@ def socket_name(request: pytest.FixtureRequest) -> str: return f"tmuxp_test{next(namer)}" +# Modules that actually need tmux fixtures in their doctests +DOCTEST_NEEDS_TMUX = { + "tmuxp.workspace.builder", +} + + @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, doctest_namespace: dict[str, t.Any], tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Harness pytest fixtures to doctests namespace.""" - if isinstance(request._pyfuncitem, DoctestItem) and shutil.which("tmux"): - doctest_namespace["server"] = request.getfixturevalue("server") - session: Session = request.getfixturevalue("session") - doctest_namespace["session"] = session - doctest_namespace["window"] = session.active_window - doctest_namespace["pane"] = session.active_pane + if isinstance(request._pyfuncitem, DoctestItem): + # Always provide lightweight fixtures doctest_namespace["test_utils"] = test_utils doctest_namespace["tmp_path"] = tmp_path + doctest_namespace["monkeypatch"] = monkeypatch + + # Only load expensive tmux fixtures for modules that need them + module_name = request._pyfuncitem.dtest.globs.get("__name__", "") + if module_name in DOCTEST_NEEDS_TMUX and shutil.which("tmux"): + doctest_namespace["server"] = request.getfixturevalue("server") + session: Session = request.getfixturevalue("session") + doctest_namespace["session"] = session + doctest_namespace["window"] = session.active_window + doctest_namespace["pane"] = session.active_pane diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 43f55d20d0..0000000000 --- a/docs/Makefile +++ /dev/null @@ -1,189 +0,0 @@ -# Makefile for Sphinx documentation -# -SHELL := /bin/bash -HTTP_PORT = 8031 -WATCH_FILES= find .. -type f -not -path '*/\.*' | grep -i '.*[.]\(rst\|md\)\$\|.*[.]py\$\|CHANGES\|TODO\|.*conf\.py' 2> /dev/null - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = uv run sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/tmuxp.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/tmuxp.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/tmuxp" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/tmuxp" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -redirects: - $(SPHINXBUILD) -b rediraffewritediff $(ALLSPHINXOPTS) $(BUILDDIR)/redirects - @echo - @echo "Build finished. The redirects are in rediraffe_redirects." - -checkbuild: - rm -rf $(BUILDDIR) - $(SPHINXBUILD) -n -q ./ $(BUILDDIR) - -watch: - if command -v entr > /dev/null; then ${WATCH_FILES} | entr -c $(MAKE) html; else $(MAKE) html; fi - -serve: - @echo '==============================================================' - @echo - @echo 'docs server running at http://localhost:${HTTP_PORT}/' - @echo - @echo '==============================================================' - @$(MAKE) serve_py3 - -serve_py3: - python -m http.server ${HTTP_PORT} --directory _build/html - -dev: - $(MAKE) -j watch serve - -start: - uv run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} $(O) - -design: - # This adds additional watch directories (for _static file changes) and disable incremental builds - uv run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} --watch "." -a $(O) diff --git a/docs/_ext/cli_usage_lexer.py b/docs/_ext/cli_usage_lexer.py new file mode 100644 index 0000000000..40170e3178 --- /dev/null +++ b/docs/_ext/cli_usage_lexer.py @@ -0,0 +1,115 @@ +"""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/pretty_argparse.py b/docs/_ext/pretty_argparse.py new file mode 100644 index 0000000000..a7c0357cb9 --- /dev/null +++ b/docs/_ext/pretty_argparse.py @@ -0,0 +1,680 @@ +"""Enhanced sphinx-argparse output formatting. + +This extension wraps sphinx-argparse's directive to: +1. Remove ANSI escape codes that may be present when FORCE_COLOR is set +2. Convert "examples:" definition lists into proper documentation sections +3. Nest category-specific examples under a parent Examples section +4. Apply cli-usage syntax highlighting to usage blocks +5. Reorder sections so usage appears before examples +""" + +from __future__ import annotations + +import re +import typing as t + +from docutils import nodes +from sphinxarg.ext import ArgParseDirective + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +_ANSI_RE = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + + +def strip_ansi(text: str) -> str: + r"""Remove ANSI escape codes from text. + + 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) + + +def is_examples_term(term_text: str) -> bool: + """Check if a definition term is an examples header. + + Parameters + ---------- + term_text : str + The text content of a definition term. + + 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 + """ + return term_text.lower().rstrip(":").endswith("examples") + + +def is_base_examples_term(term_text: str) -> bool: + """Check if a definition term is a base "examples:" header (no prefix). + + Parameters + ---------- + term_text : str + The text content of a definition term. + + 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 + """ + return term_text.lower().rstrip(":").strip() == "examples" + + +def make_section_id( + term_text: str, counter: int = 0, *, is_subsection: bool = False +) -> 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. + + Returns + ------- + str + A normalized section ID. + + Examples + -------- + >>> make_section_id("examples:") + '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' + """ + # Extract prefix before "examples" (e.g., "Machine-readable output") + lower_text = term_text.lower().rstrip(":") + if "examples" in lower_text: + prefix = lower_text.rsplit("examples", 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}-examples" + else: + section_id = "examples" + else: + section_id = "examples" + + # 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) -> 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. + + 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' + """ + # Remove trailing colon and normalize + text = term_text.rstrip(":").strip() + # Handle base "examples:" case + if text.lower() == "examples": + return "Examples" + + # Extract the prefix (category name) before "examples" + lower = text.lower() + if lower.endswith(": examples"): + prefix = text[: -len(": examples")] + elif lower.endswith(" examples"): + prefix = text[: -len(" examples")] + 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 "Examples" + return f"{titled_prefix} Examples" + + +def _create_example_section( + term_text: str, + def_node: nodes.definition, + *, + is_subsection: bool = False, +) -> 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. + + Returns + ------- + nodes.section + A section node with title and code blocks. + """ + section_id = make_section_id(term_text, is_subsection=is_subsection) + section_title = make_section_title(term_text, is_subsection=is_subsection) + + 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"$ {line}", + classes=["highlight-console"], + ) + code_block["language"] = "console" + section += code_block + + return section + + +def transform_definition_list(dl_node: nodes.definition_list) -> 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. + + Returns + ------- + list[nodes.Node] + Transformed nodes - code blocks for examples, original for others. + """ + # 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): + if is_base_examples_term(term_text): + 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] = [] + + # Flush non-example items first (if any appeared before examples) + 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 + ) + + # 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 + ) + 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) + result_nodes.append(section) + + return result_nodes + + +def process_node(node: nodes.Node) -> nodes.Node | list[nodes.Node]: + """Process a node: strip ANSI codes and transform examples. + + Parameters + ---------- + node : nodes.Node + A docutils node to process. + + Returns + ------- + nodes.Node | list[nodes.Node] + The processed node(s). + """ + # 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()) + ): + has_examples = True + break + if has_examples: + break + + if has_examples: + return transform_definition_list(node) + + # 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 "usage:") + is_usage_block = text.lstrip().lower().startswith("usage:") + + if needs_update or is_usage_block: + new_block = nodes.literal_block(text=text) + # Preserve attributes + for attr in ("language", "classes"): + if attr in node: + new_block[attr] = node[attr] + # Apply cli-usage language to usage blocks + if is_usage_block: + new_block["language"] = "cli-usage" + 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) + 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) + 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.children = new_children + + return node + + +def _is_usage_block(node: nodes.Node) -> bool: + """Check if a node is a usage literal block. + + Parameters + ---------- + node : nodes.Node + A docutils node to check. + + Returns + ------- + bool + True if this is a usage block (literal_block starting with "usage:"). + + Examples + -------- + >>> from docutils import nodes + >>> _is_usage_block(nodes.literal_block(text="usage: cmd [-h]")) + True + >>> _is_usage_block(nodes.literal_block(text="Usage: tmuxp load")) + 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 + """ + if not isinstance(node, nodes.literal_block): + return False + text = node.astext() + return text.lstrip().lower().startswith("usage:") + + +def _is_examples_section(node: nodes.Node) -> bool: + """Check if a node is an examples section. + + Parameters + ---------- + node : nodes.Node + A docutils node to check. + + Returns + ------- + bool + True if this is an examples section (section with "examples" 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 + """ + if not isinstance(node, nodes.section): + return False + ids: list[str] = node.get("ids", []) + return any("examples" in id_str.lower() for id_str in ids) + + +def _reorder_nodes(processed: list[nodes.Node]) -> list[nodes.Node]: + """Reorder nodes so usage 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. + + Parameters + ---------- + processed : list[nodes.Node] + List of processed docutils nodes. + + Returns + ------- + list[nodes.Node] + Reordered nodes with usage before examples. + + 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([]) + [] + """ + # First pass: check if there are any examples sections + has_examples = any(_is_examples_section(node) for node in processed) + if not has_examples: + # No examples, preserve original order + return processed + + usage_blocks: 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: + if _is_usage_block(node): + usage_blocks.append(node) + elif _is_examples_section(node): + 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_blocks + examples_sections + other_after_examples + ) + + +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.""" + result = super().run() + + processed: list[nodes.Node] = [] + for node in result: + processed_node = process_node(node) + if isinstance(processed_node, list): + processed.extend(processed_node) + else: + processed.append(processed_node) + + # Reorder: usage blocks before examples sections + return _reorder_nodes(processed) + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the clean argparse directive and CLI usage lexer. + + Parameters + ---------- + app : Sphinx + The Sphinx application object. + + Returns + ------- + dict + Extension metadata. + """ + # Override the default argparse directive + 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) + + return {"version": "2.0", "parallel_read_safe": True} diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html index 7b46e0bcea..97420c1adf 100644 --- a/docs/_templates/sidebar/projects.html +++ b/docs/_templates/sidebar/projects.html @@ -49,7 +49,7 @@

web - social-embed + social-embed

diff --git a/docs/api/cli/index.md b/docs/api/cli/index.md index 08f49a2131..9289503905 100644 --- a/docs/api/cli/index.md +++ b/docs/api/cli/index.md @@ -16,6 +16,7 @@ freeze import_config load ls +search shell utils ``` diff --git a/docs/api/cli/search.md b/docs/api/cli/search.md new file mode 100644 index 0000000000..bb9747e9d5 --- /dev/null +++ b/docs/api/cli/search.md @@ -0,0 +1,8 @@ +# tmuxp search - `tmuxp.cli.search` + +```{eval-rst} +.. automodule:: tmuxp.cli.search + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/api/internals/colors.md b/docs/api/internals/colors.md new file mode 100644 index 0000000000..b3577d9bae --- /dev/null +++ b/docs/api/internals/colors.md @@ -0,0 +1,14 @@ +# Colors - `tmuxp._internal.colors` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{eval-rst} +.. automodule:: tmuxp._internal.colors + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/api/internals/index.md b/docs/api/internals/index.md index 74b5fa0481..b96fc8657b 100644 --- a/docs/api/internals/index.md +++ b/docs/api/internals/index.md @@ -9,6 +9,8 @@ If you need an internal API stabilized please [file an issue](https://github.com ::: ```{toctree} +colors config_reader +private_path types ``` diff --git a/docs/api/internals/private_path.md b/docs/api/internals/private_path.md new file mode 100644 index 0000000000..d329e15169 --- /dev/null +++ b/docs/api/internals/private_path.md @@ -0,0 +1,14 @@ +# Private path - `tmuxp._internal.private_path` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{eval-rst} +.. automodule:: tmuxp._internal.private_path + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/cli/convert.md b/docs/cli/convert.md index 082f82abd6..3378cf8b39 100644 --- a/docs/cli/convert.md +++ b/docs/cli/convert.md @@ -2,8 +2,6 @@ # tmuxp convert -Convert between YAML and JSON - ```{eval-rst} .. argparse:: :module: tmuxp.cli diff --git a/docs/cli/debug-info.md b/docs/cli/debug-info.md index 5bb4fd4b62..fbe524c6ff 100644 --- a/docs/cli/debug-info.md +++ b/docs/cli/debug-info.md @@ -4,9 +4,6 @@ # tmuxp debug-info -Use to collect all relevant information for submitting an issue to -the project. - ```{eval-rst} .. argparse:: :module: tmuxp.cli diff --git a/docs/cli/index.md b/docs/cli/index.md index 3205cccc3b..156793a1e1 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -11,6 +11,7 @@ load shell ls +search ``` ```{toctree} diff --git a/docs/cli/ls.md b/docs/cli/ls.md index 4e5ec75fee..a65597db45 100644 --- a/docs/cli/ls.md +++ b/docs/cli/ls.md @@ -4,8 +4,6 @@ # tmuxp ls -List sessions. - ```{eval-rst} .. argparse:: :module: tmuxp.cli diff --git a/docs/cli/search.md b/docs/cli/search.md new file mode 100644 index 0000000000..2446a79de7 --- /dev/null +++ b/docs/cli/search.md @@ -0,0 +1,13 @@ +(cli-search)= + +(search-config)= + +# tmuxp search + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: search +``` diff --git a/docs/conf.py b/docs/conf.py index bbc5217346..cbd0d84299 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,6 +37,7 @@ "sphinx.ext.linkcode", "aafig", "sphinxarg.ext", # sphinx-argparse + "pretty_argparse", # Enhanced sphinx-argparse: strip ANSI, format examples "sphinx_inline_tabs", "sphinx_copybutton", "sphinxext.opengraph", diff --git a/docs/justfile b/docs/justfile new file mode 100644 index 0000000000..df6112c418 --- /dev/null +++ b/docs/justfile @@ -0,0 +1,206 @@ +# justfile for tmuxp documentation +# https://just.systems/ + +set shell := ["bash", "-uc"] + +# Configuration +http_port := "8031" +builddir := "_build" +sphinxopts := "" +sphinxbuild := "uv run sphinx-build" +sourcedir := "." + +# File patterns for watching +watch_files := "find .. -type f -not -path '*/\\.*' | grep -i '.*[.]\\(rst\\|md\\)$\\|.*[.]py$\\|CHANGES\\|TODO\\|.*conf\\.py' 2> /dev/null" + +# Sphinx options +allsphinxopts := "-d " + builddir + "/doctrees " + sphinxopts + " ." + +# List all available commands +default: + @just --list + +# Build HTML documentation +[group: 'build'] +html: + {{ sphinxbuild }} -b html {{ allsphinxopts }} {{ builddir }}/html + @echo "" + @echo "Build finished. The HTML pages are in {{ builddir }}/html." + +# Build directory HTML files +[group: 'build'] +dirhtml: + {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/dirhtml + @echo "" + @echo "Build finished. The HTML pages are in {{ builddir }}/dirhtml." + +# Build single HTML file +[group: 'build'] +singlehtml: + {{ sphinxbuild }} -b singlehtml {{ allsphinxopts }} {{ builddir }}/singlehtml + @echo "" + @echo "Build finished. The HTML page is in {{ builddir }}/singlehtml." + +# Build EPUB +[group: 'build'] +epub: + {{ sphinxbuild }} -b epub {{ allsphinxopts }} {{ builddir }}/epub + @echo "" + @echo "Build finished. The epub file is in {{ builddir }}/epub." + +# Build LaTeX files +[group: 'build'] +latex: + {{ sphinxbuild }} -b latex {{ allsphinxopts }} {{ builddir }}/latex + @echo "" + @echo "Build finished; the LaTeX files are in {{ builddir }}/latex." + +# Build PDF via LaTeX +[group: 'build'] +latexpdf: + {{ sphinxbuild }} -b latex {{ allsphinxopts }} {{ builddir }}/latex + @echo "Running LaTeX files through pdflatex..." + make -C {{ builddir }}/latex all-pdf + @echo "pdflatex finished; the PDF files are in {{ builddir }}/latex." + +# Build plain text files +[group: 'build'] +text: + {{ sphinxbuild }} -b text {{ allsphinxopts }} {{ builddir }}/text + @echo "" + @echo "Build finished. The text files are in {{ builddir }}/text." + +# Build man pages +[group: 'build'] +man: + {{ sphinxbuild }} -b man {{ allsphinxopts }} {{ builddir }}/man + @echo "" + @echo "Build finished. The manual pages are in {{ builddir }}/man." + +# Build JSON output +[group: 'build'] +json: + {{ sphinxbuild }} -b json {{ allsphinxopts }} {{ builddir }}/json + @echo "" + @echo "Build finished; now you can process the JSON files." + +# Clean build directory +[group: 'misc'] +[confirm] +clean: + rm -rf {{ builddir }}/* + +# Build HTML help files +[group: 'misc'] +htmlhelp: + {{ sphinxbuild }} -b htmlhelp {{ allsphinxopts }} {{ builddir }}/htmlhelp + @echo "" + @echo "Build finished; now you can run HTML Help Workshop with the .hhp project file in {{ builddir }}/htmlhelp." + +# Build Qt help files +[group: 'misc'] +qthelp: + {{ sphinxbuild }} -b qthelp {{ allsphinxopts }} {{ builddir }}/qthelp + @echo "" + @echo "Build finished; now you can run 'qcollectiongenerator' with the .qhcp project file in {{ builddir }}/qthelp." + +# Build Devhelp files +[group: 'misc'] +devhelp: + {{ sphinxbuild }} -b devhelp {{ allsphinxopts }} {{ builddir }}/devhelp + @echo "" + @echo "Build finished." + +# Build Texinfo files +[group: 'misc'] +texinfo: + {{ sphinxbuild }} -b texinfo {{ allsphinxopts }} {{ builddir }}/texinfo + @echo "" + @echo "Build finished. The Texinfo files are in {{ builddir }}/texinfo." + +# Build Info files from Texinfo +[group: 'misc'] +info: + {{ sphinxbuild }} -b texinfo {{ allsphinxopts }} {{ builddir }}/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C {{ builddir }}/texinfo info + @echo "makeinfo finished; the Info files are in {{ builddir }}/texinfo." + +# Build gettext catalogs +[group: 'misc'] +gettext: + {{ sphinxbuild }} -b gettext {{ sphinxopts }} . {{ builddir }}/locale + @echo "" + @echo "Build finished. The message catalogs are in {{ builddir }}/locale." + +# Check all external links +[group: 'validate'] +linkcheck: + {{ sphinxbuild }} -b linkcheck {{ allsphinxopts }} {{ builddir }}/linkcheck + @echo "" + @echo "Link check complete; look for any errors in the above output or in {{ builddir }}/linkcheck/output.txt." + +# Run doctests embedded in documentation +[group: 'validate'] +doctest: + {{ sphinxbuild }} -b doctest {{ allsphinxopts }} {{ builddir }}/doctest + @echo "Testing of doctests in the sources finished, look at the results in {{ builddir }}/doctest/output.txt." + +# Check build from scratch +[group: 'validate'] +checkbuild: + rm -rf {{ builddir }} + {{ sphinxbuild }} -n -q ./ {{ builddir }} + +# Build redirects configuration +[group: 'misc'] +redirects: + {{ sphinxbuild }} -b rediraffewritediff {{ allsphinxopts }} {{ builddir }}/redirects + @echo "" + @echo "Build finished. The redirects are in rediraffe_redirects." + +# Show changes overview +[group: 'misc'] +changes: + {{ sphinxbuild }} -b changes {{ allsphinxopts }} {{ builddir }}/changes + @echo "" + @echo "The overview file is in {{ builddir }}/changes." + +# Watch files and rebuild on change +[group: 'dev'] +watch: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + ${{ watch_files }} | entr -c just html + else + just html + fi + +# Serve documentation via Python http.server +[group: 'dev'] +serve: + @echo '==============================================================' + @echo '' + @echo 'docs server running at http://localhost:{{ http_port }}/' + @echo '' + @echo '==============================================================' + python -m http.server {{ http_port }} --directory {{ builddir }}/html + +# Watch and serve simultaneously +[group: 'dev'] +dev: + #!/usr/bin/env bash + set -euo pipefail + just watch & + just serve + +# Start sphinx-autobuild server +[group: 'dev'] +start: + uv run sphinx-autobuild "{{ 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 diff --git a/justfile b/justfile new file mode 100644 index 0000000000..3c2fd193da --- /dev/null +++ b/justfile @@ -0,0 +1,139 @@ +# justfile for tmuxp +# https://just.systems/ + +set shell := ["bash", "-uc"] + +# File patterns +py_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]py$' 2> /dev/null" +test_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]\\(yaml\\|py\\)$' 2> /dev/null" +doc_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]rst$\\|.*[.]md$\\|.*[.]css$\\|.*[.]py$\\|mkdocs\\.yml\\|CHANGES\\|TODO\\|.*conf\\.py' 2> /dev/null" + +# List all available commands +default: + @just --list + +# Run tests with pytest +[group: 'test'] +test *args: + uv run py.test {{ args }} + +# Run tests then start continuous testing with pytest-watcher +[group: 'test'] +start: + just test + uv run ptw . + +# Watch files and run tests on change (requires entr) +[group: 'test'] +watch-test: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ test_files }} | entr -c just test + else + just test + just _entr-warn + fi + +# Build documentation +[group: 'docs'] +build-docs: + just -f docs/justfile html + +# Watch files and rebuild docs on change +[group: 'docs'] +watch-docs: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ doc_files }} | entr -c just build-docs + else + just build-docs + just _entr-warn + fi + +# Serve documentation +[group: 'docs'] +serve-docs: + just -f docs/justfile serve + +# Watch and serve docs simultaneously +[group: 'docs'] +dev-docs: + #!/usr/bin/env bash + set -euo pipefail + just watch-docs & + just serve-docs + +# Start documentation server with auto-reload +[group: 'docs'] +start-docs: + just -f docs/justfile start + +# Start documentation design mode (watches static files) +[group: 'docs'] +design-docs: + just -f docs/justfile design + +# Format code with ruff +[group: 'lint'] +ruff-format: + uv run ruff format . + +# Run ruff linter +[group: 'lint'] +ruff: + uv run ruff check . + +# Watch files and run ruff on change +[group: 'lint'] +watch-ruff: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ py_files }} | entr -c just ruff + else + just ruff + just _entr-warn + fi + +# Run mypy type checker +[group: 'lint'] +mypy: + uv run mypy $({{ py_files }}) + +# Watch files and run mypy on change +[group: 'lint'] +watch-mypy: + #!/usr/bin/env bash + set -euo pipefail + if command -v entr > /dev/null; then + {{ py_files }} | entr -c just mypy + else + just mypy + just _entr-warn + fi + +# Format markdown files with prettier +[group: 'format'] +format-markdown: + npx prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES + +# Run monkeytype to collect runtime types +[group: 'typing'] +monkeytype-create: + uv run monkeytype run $(uv run which py.test) + +# Apply collected monkeytype annotations +[group: 'typing'] +monkeytype-apply: + uv run monkeytype list-modules | xargs -n1 -I{} sh -c 'uv run monkeytype apply {}' + +[private] +_entr-warn: + @echo "----------------------------------------------------------" + @echo " ! File watching functionality non-operational ! " + @echo " " + @echo "Install entr(1) to automatically run tasks on file change." + @echo "See https://eradman.com/entrproject/ " + @echo "----------------------------------------------------------" diff --git a/pyproject.toml b/pyproject.toml index ebf82b5def..233cca145e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.62.0" +version = "1.63.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 = [ @@ -85,6 +85,7 @@ dev = [ "mypy", "types-colorama", "types-docutils", + "types-Pygments", "types-PyYAML", ] @@ -121,6 +122,7 @@ lint = [ "mypy", "types-colorama", "types-docutils", + "types-Pygments", "types-PyYAML", ] @@ -139,6 +141,7 @@ omit = [ "*/_*", "pkg/*", "*/log.py", + "docs/_ext/*", ] [tool.coverage.report] @@ -170,6 +173,8 @@ module = [ "ptpython.*", "prompt_toolkit.*", "bpython", + "sphinxarg.*", + "cli_usage_lexer", ] ignore_missing_imports = true @@ -238,5 +243,5 @@ testpaths = [ ] filterwarnings = [ "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", - "ignore:invalid escape sequence:SyntaxWarning::", + "ignore:.*invalid escape sequence.*:SyntaxWarning:aafigure:", ] diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index a4426377b1..21713a5ce5 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -4,7 +4,7 @@ __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.62.0" +__version__ = "1.63.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py new file mode 100644 index 0000000000..3bc936cdaf --- /dev/null +++ b/src/tmuxp/_internal/colors.py @@ -0,0 +1,873 @@ +"""Color output utilities for tmuxp CLI. + +This module provides semantic color utilities following patterns from vcspull +and CPython's _colorize module. It includes low-level ANSI styling functions +and high-level semantic color utilities. + +Examples +-------- +Basic usage with automatic TTY detection (AUTO mode is the default). +In a TTY, colored text is returned; otherwise plain text: + +>>> colors = Colors() + +Force colors on or off: + +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' + +>>> colors = Colors(ColorMode.NEVER) +>>> colors.success("loaded") +'loaded' + +Environment variables NO_COLOR and FORCE_COLOR are respected. +NO_COLOR takes highest priority (disables even in ALWAYS mode): + +>>> monkeypatch.setenv("NO_COLOR", "1") +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") +'loaded' + +FORCE_COLOR enables colors in AUTO mode even without TTY: + +>>> import sys +>>> monkeypatch.delenv("NO_COLOR", raising=False) +>>> monkeypatch.setenv("FORCE_COLOR", "1") +>>> monkeypatch.setattr(sys.stdout, "isatty", lambda: False) +>>> colors = Colors(ColorMode.AUTO) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' +""" + +from __future__ import annotations + +import enum +import os +import re +import sys +import typing as t + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColour: TypeAlias = int | tuple[int, int, int] | str + + +class ColorMode(enum.Enum): + """Color output modes for CLI. + + Examples + -------- + >>> ColorMode.AUTO.value + 'auto' + >>> ColorMode.ALWAYS.value + 'always' + >>> ColorMode.NEVER.value + 'never' + """ + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + +class Colors: + r"""Semantic color utilities for CLI output. + + Provides semantic color methods (success, warning, error, etc.) that + conditionally apply ANSI colors based on the color mode and environment. + + Parameters + ---------- + mode : ColorMode + Color mode to use. Default is AUTO which detects TTY. + + Attributes + ---------- + SUCCESS : str + Color name for success messages (green) + WARNING : str + Color name for warning messages (yellow) + ERROR : str + Color name for error messages (red) + INFO : str + Color name for informational messages (cyan) + HEADING : str + Color name for section headers (bright_cyan) + HIGHLIGHT : str + Color name for highlighted/important text (magenta) + MUTED : str + Color name for subdued/secondary text (blue) + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("session loaded") + 'session loaded' + >>> colors.error("failed to load") + 'failed to load' + + >>> colors = Colors(ColorMode.ALWAYS) + >>> result = colors.success("ok") + + Check that result contains ANSI escape codes: + + >>> "\033[" in result + True + """ + + # Semantic color names (used with style()) + SUCCESS = "green" # Success, loaded, up-to-date + WARNING = "yellow" # Warnings, changes needed + ERROR = "red" # Errors, failures + INFO = "cyan" # Information, paths, supplementary (L2) + HEADING = "bright_cyan" # Section headers (L0) - brighter than INFO + HIGHLIGHT = "magenta" # Important labels, session names (L1) + MUTED = "blue" # Subdued info, secondary text (L3) + + def __init__(self, mode: ColorMode = ColorMode.AUTO) -> None: + """Initialize color manager. + + Parameters + ---------- + mode : ColorMode + Color mode to use (auto, always, never). Default is AUTO. + + Examples + -------- + >>> colors = Colors() + >>> colors.mode + + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._enabled + False + """ + self.mode = mode + self._enabled = self._should_enable() + + def _should_enable(self) -> bool: + """Determine if color should be enabled. + + Follows CPython-style precedence: + 1. NO_COLOR env var (any value) -> disable + 2. ColorMode.NEVER -> disable + 3. ColorMode.ALWAYS -> enable + 4. FORCE_COLOR env var (any value) -> enable + 5. TTY check -> enable if stdout is a terminal + + Returns + ------- + bool + True if colors should be enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors._should_enable() + False + """ + # NO_COLOR takes highest priority (standard convention) + if os.environ.get("NO_COLOR"): + return False + + if self.mode == ColorMode.NEVER: + return False + if self.mode == ColorMode.ALWAYS: + return True + + # AUTO mode: check FORCE_COLOR then TTY + if os.environ.get("FORCE_COLOR"): + return True + + return sys.stdout.isatty() + + def _colorize( + self, text: str, fg: str, bold: bool = False, dim: bool = False + ) -> str: + """Apply color using style() function. + + Parameters + ---------- + text : str + Text to colorize. + fg : str + Foreground color name (e.g., "green", "red"). + bold : bool + Whether to apply bold style. Default is False. + dim : bool + Whether to apply dim/faint style. Default is False. + + Returns + ------- + str + Colorized text if enabled, plain text otherwise. + + Examples + -------- + When colors are enabled, applies ANSI escape codes: + + >>> colors = Colors(ColorMode.ALWAYS) + >>> colors._colorize("test", "green") # doctest: +ELLIPSIS + '...' + + When colors are disabled, returns plain text: + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._colorize("test", "green") + 'test' + """ + if self._enabled: + return style(text, fg=fg, bold=bold, dim=dim) + return text + + def success(self, text: str, bold: bool = False) -> str: + """Format text as success (green). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("loaded") + 'loaded' + """ + return self._colorize(text, self.SUCCESS, bold) + + def warning(self, text: str, bold: bool = False) -> str: + """Format text as warning (yellow). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.warning("check config") + 'check config' + """ + return self._colorize(text, self.WARNING, bold) + + def error(self, text: str, bold: bool = False) -> str: + """Format text as error (red). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.error("failed") + 'failed' + """ + return self._colorize(text, self.ERROR, bold) + + def info(self, text: str, bold: bool = False) -> str: + """Format text as info (cyan). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.info("/path/to/config.yaml") + '/path/to/config.yaml' + """ + return self._colorize(text, self.INFO, bold) + + def highlight(self, text: str, bold: bool = True) -> str: + """Format text as highlighted (magenta, bold by default). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is True. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.highlight("my-session") + 'my-session' + """ + return self._colorize(text, self.HIGHLIGHT, bold) + + def muted(self, text: str) -> str: + """Format text as muted (blue). + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.muted("(optional)") + '(optional)' + """ + return self._colorize(text, self.MUTED, bold=False) + + def heading(self, text: str) -> str: + """Format text as a section heading (bright cyan, bold). + + Used for section headers like 'Local workspaces:' or 'Global workspaces:'. + Uses bright_cyan to visually distinguish from info() which uses cyan. + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.heading("Local workspaces:") + 'Local workspaces:' + """ + return self._colorize(text, self.HEADING, bold=True) + + # Formatting helpers for structured output + + def format_label(self, label: str) -> str: + """Format a label (key in key:value pair). + + Parameters + ---------- + label : str + Label text to format. + + Returns + ------- + str + Highlighted label text (bold magenta when colors enabled). + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_label("tmux path") + 'tmux path' + """ + return self.highlight(label, bold=True) + + def format_path(self, path: str) -> str: + """Format a file path with info color. + + Parameters + ---------- + path : str + Path string to format. + + Returns + ------- + str + Cyan-colored path when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_path("/usr/bin/tmux") + '/usr/bin/tmux' + """ + return self.info(path) + + def format_version(self, version: str) -> str: + """Format a version string. + + Parameters + ---------- + version : str + Version string to format. + + Returns + ------- + str + Cyan-colored version when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_version("3.2a") + '3.2a' + """ + return self.info(version) + + def format_separator(self, length: int = 25) -> str: + """Format a visual separator line. + + Parameters + ---------- + length : int + Length of the separator line. Default is 25. + + Returns + ------- + str + Muted (blue) separator line when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_separator() + '-------------------------' + >>> colors.format_separator(10) + '----------' + """ + return self.muted("-" * length) + + def format_kv(self, key: str, value: str) -> str: + """Format key: value pair with syntax highlighting. + + Parameters + ---------- + key : str + Key/label to highlight. + value : str + Value to display (not colorized, allows caller to format). + + Returns + ------- + str + Formatted "key: value" string with highlighted key. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_kv("tmux version", "3.2a") + 'tmux version: 3.2a' + """ + return f"{self.format_label(key)}: {value}" + + def format_tmux_option(self, line: str) -> str: + """Format tmux option line with syntax highlighting. + + Handles tmux show-options output formats: + - "key value" (space-separated) + - "key=value" (equals-separated) + - "key[index] value" (array-indexed options) + - "key" (empty array options with no value) + + Parameters + ---------- + line : str + Option line to format. + + Returns + ------- + str + Formatted line with highlighted key and info-colored value. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_tmux_option("status on") + 'status on' + >>> colors.format_tmux_option("base-index=1") + 'base-index=1' + >>> colors.format_tmux_option("pane-colours") + 'pane-colours' + >>> colors.format_tmux_option('status-format[0] "#[align=left]"') + 'status-format[0] "#[align=left]"' + """ + # Handle "key value" format (space-separated) - check first since values + # may contain '=' (e.g., status-format[0] "#[align=left]") + parts = line.split(None, 1) + if len(parts) == 2: + return f"{self.highlight(parts[0], bold=False)} {self.info(parts[1])}" + + # Handle key=value format (only for single-token lines) + if "=" in line: + key, val = line.split("=", 1) + return f"{self.highlight(key, bold=False)}={self.info(val)}" + + # Single word = key with no value (empty array option like pane-colours) + if len(parts) == 1 and parts[0]: + return self.highlight(parts[0], bold=False) + + # Empty or unparseable - return as-is + return line + + +def get_color_mode(color_arg: str | None = None) -> ColorMode: + """Convert CLI argument string to ColorMode enum. + + Parameters + ---------- + color_arg : str | None + Color mode argument from CLI (auto, always, never). + None defaults to AUTO. + + Returns + ------- + ColorMode + The determined color mode. Invalid values return AUTO. + + Examples + -------- + >>> get_color_mode(None) + + >>> get_color_mode("always") + + >>> get_color_mode("NEVER") + + >>> get_color_mode("invalid") + + """ + if color_arg is None: + return ColorMode.AUTO + + try: + return ColorMode(color_arg.lower()) + except ValueError: + return ColorMode.AUTO + + +# ANSI styling utilities (originally from click, via utils.py) + +_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + + +def strip_ansi(value: str) -> str: + r"""Clear ANSI escape codes from a string value. + + Parameters + ---------- + value : str + String potentially containing ANSI escape codes. + + Returns + ------- + str + String with ANSI codes removed. + + Examples + -------- + >>> strip_ansi("\033[32mgreen\033[0m") + 'green' + >>> strip_ansi("plain text") + 'plain text' + """ + return _ansi_re.sub("", value) + + +_ansi_colors = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "reset": 39, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} +_ansi_reset_all = "\033[0m" + + +def _interpret_color( + color: int | tuple[int, int, int] | str, + offset: int = 0, +) -> str: + """Convert color specification to ANSI escape code number. + + Parameters + ---------- + color : int | tuple[int, int, int] | str + Color as 256-color index, RGB tuple, or color name. + offset : int + Offset for background colors (10 for bg). + + Returns + ------- + str + ANSI escape code parameters. + + Examples + -------- + Color name returns base ANSI code: + + >>> _interpret_color("red") + '31' + + 256-color index returns extended format: + + >>> _interpret_color(196) + '38;5;196' + + RGB tuple returns 24-bit format: + + >>> _interpret_color((255, 128, 0)) + '38;2;255;128;0' + """ + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + if len(color) != 3: + msg = f"RGB color tuple must have exactly 3 values, got {len(color)}" + raise ValueError(msg) + r, g, b = color + for i, component in enumerate((r, g, b)): + if not isinstance(component, int): + msg = ( + f"RGB values must be integers, " + f"got {type(component).__name__} at index {i}" + ) + raise TypeError(msg) + if not 0 <= component <= 255: + msg = f"RGB values must be 0-255, got {component} at index {i}" + raise ValueError(msg) + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + +class UnknownStyleColor(Exception): + """Raised when encountering an unknown terminal style color. + + Examples + -------- + >>> try: + ... raise UnknownStyleColor("invalid_color") + ... except UnknownStyleColor as e: + ... "invalid_color" in str(e) + True + """ + + def __init__(self, color: CLIColour, *args: object, **kwargs: object) -> None: + return super().__init__(f"Unknown color {color!r}", *args, **kwargs) + + +def style( + text: t.Any, + fg: CLIColour | None = None, + bg: CLIColour | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + r"""Apply ANSI styling to text. + + Credit: click. + + Parameters + ---------- + text : Any + Text to style (will be converted to str). + fg : CLIColour | None + Foreground color (name, 256-index, or RGB tuple). + bg : CLIColour | None + Background color. + bold : bool | None + Apply bold style. + dim : bool | None + Apply dim style. + underline : bool | None + Apply underline style. + overline : bool | None + Apply overline style. + italic : bool | None + Apply italic style. + blink : bool | None + Apply blink style. + reverse : bool | None + Apply reverse video style. + strikethrough : bool | None + Apply strikethrough style. + reset : bool + Append reset code at end. Default True. + + Returns + ------- + str + Styled text with ANSI escape codes. + + Examples + -------- + >>> style("hello", fg="green") # doctest: +ELLIPSIS + '\x1b[32m...' + >>> "hello" in style("hello", fg="green") + True + """ + if not isinstance(text, str): + text = str(text) + + bits = [] + + if fg or fg == 0: + try: + bits.append(f"\033[{_interpret_color(fg)}m") + except (KeyError, ValueError, TypeError): + raise UnknownStyleColor(color=fg) from None + + if bg or bg == 0: + try: + bits.append(f"\033[{_interpret_color(bg, 10)}m") + except (KeyError, ValueError, TypeError): + raise UnknownStyleColor(color=bg) from None + + if bold: + bits.append("\033[1m") + if dim: + bits.append("\033[2m") + if underline is not None: + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") + if blink is not None: + bits.append(f"\033[{5 if blink else 25}m") + if reverse is not None: + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits) + + +def unstyle(text: str) -> str: + r"""Remove ANSI styling information from a string. + + Usually it's not necessary to use this function as tmuxp_echo function will + automatically remove styling if necessary. + + Credit: click. + + Parameters + ---------- + text : str + Text to remove style information from. + + Returns + ------- + str + Text with ANSI codes removed. + + Examples + -------- + >>> unstyle("\033[32mgreen\033[0m") + 'green' + """ + return strip_ansi(text) + + +def build_description( + intro: str, + example_blocks: t.Sequence[tuple[str | None, t.Sequence[str]]], +) -> str: + r"""Assemble help text with optional example sections. + + Parameters + ---------- + intro : str + The introductory description text. + example_blocks : sequence of (heading, commands) tuples + Each tuple contains an optional heading and a sequence of example commands. + If heading is None, the section is titled "examples:". + If heading is provided, it becomes the section title (without "examples:"). + + Returns + ------- + str + Formatted description with examples. + + Examples + -------- + >>> from tmuxp._internal.colors import build_description + >>> build_description("My tool.", [(None, ["mytool run"])]) + 'My tool.\n\nexamples:\n mytool run' + + >>> build_description("My tool.", [("sync", ["mytool sync repo"])]) + 'My tool.\n\nsync:\n mytool sync repo' + + >>> build_description("", [(None, ["cmd"])]) + 'examples:\n cmd' + """ + import textwrap + + sections: list[str] = [] + intro_text = textwrap.dedent(intro).strip() + if intro_text: + sections.append(intro_text) + + for heading, commands in example_blocks: + if not commands: + continue + title = "examples:" if heading is None else f"{heading}:" + lines = [title] + lines.extend(f" {command}" for command in commands) + sections.append("\n".join(lines)) + + return "\n\n".join(sections) diff --git a/src/tmuxp/_internal/private_path.py b/src/tmuxp/_internal/private_path.py new file mode 100644 index 0000000000..2ab8a998ae --- /dev/null +++ b/src/tmuxp/_internal/private_path.py @@ -0,0 +1,140 @@ +"""Privacy-aware path utilities for hiding sensitive directory information. + +This module provides utilities for masking user home directories in path output, +useful for logging, debugging, and displaying paths without exposing PII. +""" + +from __future__ import annotations + +import os +import pathlib +import typing as t + +if t.TYPE_CHECKING: + PrivatePathBase = pathlib.Path +else: + PrivatePathBase = type(pathlib.Path()) + + +class PrivatePath(PrivatePathBase): + """Path subclass that hides the user's home directory in textual output. + + The class behaves like :class:`pathlib.Path`, but normalizes string and + representation output to replace the current user's home directory with + ``~``. This is useful when logging or displaying paths that should not leak + potentially sensitive information. + + Examples + -------- + >>> from pathlib import Path + >>> home = Path.home() + + >>> PrivatePath(home) + PrivatePath('~') + + >>> PrivatePath(home / "projects" / "tmuxp") + PrivatePath('~/projects/tmuxp') + + >>> str(PrivatePath("/tmp/example")) + '/tmp/example' + + >>> f'config: {PrivatePath(home / ".tmuxp" / "config.yaml")}' # doctest: +ELLIPSIS + 'config: ~/.tmuxp/config.yaml' + """ + + def __new__(cls, *args: t.Any, **kwargs: t.Any) -> PrivatePath: + """Create a new PrivatePath instance.""" + return super().__new__(cls, *args, **kwargs) + + @classmethod + def _collapse_home(cls, value: str) -> str: + """Collapse the user's home directory to ``~`` in ``value``. + + Parameters + ---------- + value : str + Path string to process + + Returns + ------- + str + Path with home directory replaced by ``~`` if applicable + + Examples + -------- + >>> import pathlib + >>> home = str(pathlib.Path.home()) + >>> PrivatePath._collapse_home(home) + '~' + >>> PrivatePath._collapse_home(home + "/projects") + '~/projects' + >>> PrivatePath._collapse_home("/tmp/test") + '/tmp/test' + >>> PrivatePath._collapse_home("~/already/collapsed") + '~/already/collapsed' + """ + if value.startswith("~"): + return value + + home = str(pathlib.Path.home()) + if value == home: + return "~" + + separators = {os.sep} + if os.altsep: + separators.add(os.altsep) + + for sep in separators: + home_with_sep = home + sep + if value.startswith(home_with_sep): + return "~" + value[len(home) :] + + return value + + def __str__(self) -> str: + """Return string representation with home directory collapsed to ~.""" + original = pathlib.Path.__str__(self) + return self._collapse_home(original) + + def __repr__(self) -> str: + """Return repr with home directory collapsed to ~.""" + return f"{self.__class__.__name__}({str(self)!r})" + + +def collapse_home_in_string(text: str) -> str: + """Collapse home directory paths within a colon-separated string. + + Useful for processing PATH-like environment variables that may contain + multiple paths, some of which are under the user's home directory. + + Parameters + ---------- + text : str + String potentially containing paths separated by colons (or semicolons + on Windows) + + Returns + ------- + str + String with home directory paths collapsed to ``~`` + + Examples + -------- + >>> import pathlib + >>> home = str(pathlib.Path.home()) + >>> collapse_home_in_string(f"{home}/.local/bin:/usr/bin") # doctest: +ELLIPSIS + '~/.local/bin:/usr/bin' + >>> collapse_home_in_string("/usr/bin:/bin") + '/usr/bin:/bin' + >>> path_str = f"{home}/bin:{home}/.cargo/bin:/usr/bin" + >>> collapse_home_in_string(path_str) # doctest: +ELLIPSIS + '~/bin:~/.cargo/bin:/usr/bin' + """ + # Handle both Unix (:) and Windows (;) path separators + separator = ";" if os.name == "nt" else ":" + parts = text.split(separator) + collapsed = [PrivatePath._collapse_home(part) for part in parts] + return separator.join(collapsed) + + +__all__ = ["PrivatePath", "collapse_home_in_string"] diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 2b542face5..b05708321f 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -16,27 +16,136 @@ from tmuxp.__about__ import __version__ from tmuxp.log import setup_logger -from .convert import command_convert, create_convert_subparser -from .debug_info import command_debug_info, create_debug_info_subparser -from .edit import command_edit, create_edit_subparser -from .freeze import CLIFreezeNamespace, command_freeze, create_freeze_subparser +from ._colors import build_description +from ._formatter import TmuxpHelpFormatter, create_themed_formatter +from .convert import CONVERT_DESCRIPTION, command_convert, create_convert_subparser +from .debug_info import ( + DEBUG_INFO_DESCRIPTION, + CLIDebugInfoNamespace, + command_debug_info, + create_debug_info_subparser, +) +from .edit import EDIT_DESCRIPTION, command_edit, create_edit_subparser +from .freeze import ( + FREEZE_DESCRIPTION, + CLIFreezeNamespace, + command_freeze, + create_freeze_subparser, +) from .import_config import ( + IMPORT_DESCRIPTION, command_import_teamocil, command_import_tmuxinator, create_import_subparser, ) -from .load import CLILoadNamespace, command_load, create_load_subparser -from .ls import command_ls, create_ls_subparser -from .shell import CLIShellNamespace, command_shell, create_shell_subparser +from .load import ( + LOAD_DESCRIPTION, + CLILoadNamespace, + command_load, + create_load_subparser, +) +from .ls import LS_DESCRIPTION, CLILsNamespace, command_ls, create_ls_subparser +from .search import ( + SEARCH_DESCRIPTION, + CLISearchNamespace, + command_search, + create_search_subparser, +) +from .shell import ( + SHELL_DESCRIPTION, + CLIShellNamespace, + command_shell, + create_shell_subparser, +) from .utils import tmuxp_echo logger = logging.getLogger(__name__) +CLI_DESCRIPTION = build_description( + """ + tmuxp - tmux session manager. + + Manage and launch tmux sessions from YAML/JSON workspace files. + """, + ( + ( + "load", + [ + "tmuxp load myproject", + "tmuxp load ./workspace.yaml", + "tmuxp load -d myproject", + "tmuxp load -y dev staging", + ], + ), + ( + "freeze", + [ + "tmuxp freeze mysession", + "tmuxp freeze mysession -o session.yaml", + ], + ), + ( + "ls", + [ + "tmuxp ls", + "tmuxp ls --tree", + "tmuxp ls --full", + "tmuxp ls --json", + ], + ), + ( + "search", + [ + "tmuxp search dev", + "tmuxp search name:myproject", + "tmuxp search -i DEV", + "tmuxp search --json dev", + ], + ), + ( + "shell", + [ + "tmuxp shell", + "tmuxp shell -L mysocket", + "tmuxp shell -c 'print(server.sessions)'", + ], + ), + ( + "convert", + [ + "tmuxp convert workspace.yaml", + "tmuxp convert workspace.json", + ], + ), + ( + "import", + [ + "tmuxp import teamocil ~/.teamocil/project.yml", + "tmuxp import tmuxinator ~/.tmuxinator/project.yml", + ], + ), + ( + "edit", + [ + "tmuxp edit myproject", + ], + ), + ( + "debug-info", + [ + "tmuxp debug-info", + "tmuxp debug-info --json", + ], + ), + ), +) + if t.TYPE_CHECKING: import pathlib from typing import TypeAlias CLIVerbosity: TypeAlias = t.Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + CLIColorMode: TypeAlias = t.Literal["auto", "always", "never"] CLISubparserName: TypeAlias = t.Literal[ "ls", "load", @@ -44,6 +153,7 @@ "convert", "edit", "import", + "search", "shell", "debug-info", ] @@ -52,7 +162,15 @@ def create_parser() -> argparse.ArgumentParser: """Create CLI :class:`argparse.ArgumentParser` for tmuxp.""" - parser = argparse.ArgumentParser(prog="tmuxp") + # Use factory to create themed formatter with auto-detected color mode + # This respects NO_COLOR, FORCE_COLOR env vars and TTY detection + formatter_class = create_themed_formatter() + + parser = argparse.ArgumentParser( + prog="tmuxp", + description=CLI_DESCRIPTION, + formatter_class=formatter_class, + ) parser.add_argument( "--version", "-V", @@ -67,41 +185,80 @@ def create_parser() -> argparse.ArgumentParser: choices=["debug", "info", "warning", "error", "critical"], help='log level (debug, info, warning, error, critical) (default "info")', ) + parser.add_argument( + "--color", + choices=["auto", "always", "never"], + default="auto", + help="when to use colors: auto (default), always, or never", + ) subparsers = parser.add_subparsers(dest="subparser_name") - load_parser = subparsers.add_parser("load", help="load tmuxp workspaces") + load_parser = subparsers.add_parser( + "load", + help="load tmuxp workspaces", + description=LOAD_DESCRIPTION, + formatter_class=formatter_class, + ) create_load_subparser(load_parser) shell_parser = subparsers.add_parser( "shell", help="launch python shell for tmux server, session, window and pane", + description=SHELL_DESCRIPTION, + formatter_class=formatter_class, ) create_shell_subparser(shell_parser) import_parser = subparsers.add_parser( "import", help="import workspaces from teamocil and tmuxinator.", + description=IMPORT_DESCRIPTION, + formatter_class=formatter_class, ) create_import_subparser(import_parser) convert_parser = subparsers.add_parser( "convert", help="convert workspace files between yaml and json.", + description=CONVERT_DESCRIPTION, + formatter_class=formatter_class, ) create_convert_subparser(convert_parser) debug_info_parser = subparsers.add_parser( "debug-info", help="print out all diagnostic info", + description=DEBUG_INFO_DESCRIPTION, + formatter_class=formatter_class, ) create_debug_info_subparser(debug_info_parser) - ls_parser = subparsers.add_parser("ls", help="list workspaces in tmuxp directory") + ls_parser = subparsers.add_parser( + "ls", + help="list workspaces in tmuxp directory", + description=LS_DESCRIPTION, + formatter_class=formatter_class, + ) create_ls_subparser(ls_parser) - edit_parser = subparsers.add_parser("edit", help="run $EDITOR on workspace file") + search_parser = subparsers.add_parser( + "search", + help="search workspace files by name, session, path, or content", + description=SEARCH_DESCRIPTION, + formatter_class=formatter_class, + ) + create_search_subparser(search_parser) + + edit_parser = subparsers.add_parser( + "edit", + help="run $EDITOR on workspace file", + description=EDIT_DESCRIPTION, + formatter_class=formatter_class, + ) create_edit_subparser(edit_parser) freeze_parser = subparsers.add_parser( "freeze", help="freeze a live tmux session to a tmuxp workspace file", + description=FREEZE_DESCRIPTION, + formatter_class=formatter_class, ) create_freeze_subparser(freeze_parser) @@ -112,6 +269,7 @@ class CLINamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp root-level CLI.""" log_level: CLIVerbosity + color: CLIColorMode subparser_name: CLISubparserName import_subparser_name: CLIImportSubparserName | None version: bool @@ -163,25 +321,32 @@ def cli(_args: list[str] | None = None) -> None: command_import_teamocil( workspace_file=args.workspace_file, parser=parser, + color=args.color, ) elif import_subparser_name == "tmuxinator": command_import_tmuxinator( workspace_file=args.workspace_file, parser=parser, + color=args.color, ) elif args.subparser_name == "convert": command_convert( workspace_file=args.workspace_file, answer_yes=args.answer_yes, parser=parser, + color=args.color, ) elif args.subparser_name == "debug-info": - command_debug_info(parser=parser) + command_debug_info( + args=CLIDebugInfoNamespace(**vars(args)), + parser=parser, + ) elif args.subparser_name == "edit": command_edit( workspace_file=args.workspace_file, parser=parser, + color=args.color, ) elif args.subparser_name == "freeze": command_freeze( @@ -189,7 +354,15 @@ def cli(_args: list[str] | None = None) -> None: parser=parser, ) elif args.subparser_name == "ls": - command_ls(parser=parser) + command_ls( + args=CLILsNamespace(**vars(args)), + parser=parser, + ) + elif args.subparser_name == "search": + command_search( + args=CLISearchNamespace(**vars(args)), + parser=parser, + ) def startup(config_dir: pathlib.Path) -> None: diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py new file mode 100644 index 0000000000..9932218fb6 --- /dev/null +++ b/src/tmuxp/cli/_colors.py @@ -0,0 +1,32 @@ +"""Backward-compatible re-exports from _internal.colors. + +This module re-exports color utilities from their new location in _internal.colors +for backward compatibility with existing imports. + +.. deprecated:: + Import directly from tmuxp._internal.colors instead. +""" + +from __future__ import annotations + +from tmuxp._internal.colors import ( + ColorMode, + Colors, + UnknownStyleColor, + build_description, + get_color_mode, + strip_ansi, + style, + unstyle, +) + +__all__ = [ + "ColorMode", + "Colors", + "UnknownStyleColor", + "build_description", + "get_color_mode", + "strip_ansi", + "style", + "unstyle", +] diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py new file mode 100644 index 0000000000..9dfceaf29e --- /dev/null +++ b/src/tmuxp/cli/_formatter.py @@ -0,0 +1,373 @@ +"""Custom help formatter for tmuxp CLI with colorized examples. + +This module provides a custom argparse formatter that colorizes example +sections in help output, similar to vcspull's formatter. + +Examples +-------- +>>> from tmuxp.cli._formatter import TmuxpHelpFormatter +>>> TmuxpHelpFormatter # doctest: +ELLIPSIS + +""" + +from __future__ import annotations + +import argparse +import re +import typing as t + +# Options that expect a value (set externally or via --option=value) +OPTIONS_EXPECTING_VALUE = frozenset( + { + "-f", + "--file", + "-s", + "--socket-name", + "-S", + "--socket-path", + "-L", + "--log-level", + "-c", + "--command", + "-t", + "--target", + "-o", + "--output", + # Note: -d is --detached (flag-only), not a value option + "--color", + "-w", + "--workspace", + } +) + +# Standalone flag options (no value) +OPTIONS_FLAG_ONLY = frozenset( + { + "-h", + "--help", + "-V", + "--version", + "-y", + "--yes", + "-n", + "--no", + "-d", + "--detached", + "-2", + "-8", + "-a", + "--append", + "--json", + "--raw", + } +) + + +class TmuxpHelpFormatter(argparse.RawDescriptionHelpFormatter): + """Help formatter with colorized examples for tmuxp CLI. + + This formatter extends RawDescriptionHelpFormatter to preserve formatting + of description text while adding syntax highlighting to example sections. + + The formatter uses a `_theme` attribute (set externally) to apply colors. + If no theme is set, the formatter falls back to plain text output. + + Examples + -------- + >>> formatter = TmuxpHelpFormatter("tmuxp") + >>> formatter # doctest: +ELLIPSIS + <...TmuxpHelpFormatter object at ...> + """ + + # Theme for colorization, set by create_themed_formatter() or externally + _theme: HelpTheme | None = None + + def _fill_text(self, text: str, width: int, indent: str) -> str: + """Fill text, colorizing examples sections if theme is available. + + Parameters + ---------- + text : str + Text to format. + width : int + Maximum line width. + indent : str + Indentation prefix. + + Returns + ------- + str + Formatted text, with colorized examples if theme is set. + + Examples + -------- + Without theme, returns text via parent formatter: + + >>> formatter = TmuxpHelpFormatter("test") + >>> formatter._fill_text("hello", 80, "") + 'hello' + """ + theme = getattr(self, "_theme", None) + if not text or theme is None: + return super()._fill_text(text, width, indent) + + lines = text.splitlines(keepends=True) + formatted_lines: list[str] = [] + in_examples_block = False + expect_value = False + + for line in lines: + if line.strip() == "": + in_examples_block = False + expect_value = False + formatted_lines.append(f"{indent}{line}") + continue + + has_newline = line.endswith("\n") + stripped_line = line.rstrip("\n") + leading_length = len(stripped_line) - len(stripped_line.lstrip(" ")) + leading = stripped_line[:leading_length] + content = stripped_line[leading_length:] + content_lower = content.lower() + # Recognize example section headings: + # - "examples:" starts the examples block + # - "X examples:" or "X:" are sub-section headings within examples + is_examples_start = content_lower == "examples:" + is_category_in_block = ( + in_examples_block and content.endswith(":") and not content[0].isspace() + ) + is_section_heading = ( + content_lower.endswith("examples:") or is_category_in_block + ) and not is_examples_start + + if is_section_heading or is_examples_start: + formatted_content = f"{theme.heading}{content}{theme.reset}" + in_examples_block = True + expect_value = False + elif in_examples_block: + colored_content = self._colorize_example_line( + content, + theme=theme, + expect_value=expect_value, + ) + expect_value = colored_content.expect_value + formatted_content = colored_content.text + else: + formatted_content = stripped_line + + newline = "\n" if has_newline else "" + formatted_lines.append(f"{indent}{leading}{formatted_content}{newline}") + + return "".join(formatted_lines) + + class _ColorizedLine(t.NamedTuple): + """Result of colorizing an example line.""" + + text: str + expect_value: bool + + def _colorize_example_line( + self, + content: str, + *, + theme: t.Any, + expect_value: bool, + ) -> _ColorizedLine: + """Colorize a single example command line. + + Parameters + ---------- + content : str + The line content to colorize. + theme : Any + Theme object with color attributes (prog, action, etc.). + expect_value : bool + Whether the previous token expects a value. + + Returns + ------- + _ColorizedLine + Named tuple with colorized text and updated expect_value state. + + Examples + -------- + With an empty theme (no colors), returns text unchanged: + + >>> formatter = TmuxpHelpFormatter("test") + >>> theme = HelpTheme.from_colors(None) + >>> result = formatter._colorize_example_line( + ... "tmuxp load", theme=theme, expect_value=False + ... ) + >>> result.text + 'tmuxp load' + >>> result.expect_value + False + """ + parts: list[str] = [] + expecting_value = expect_value + first_token = True + colored_subcommand = False + + for match in re.finditer(r"\s+|\S+", content): + token = match.group() + if token.isspace(): + parts.append(token) + continue + + if expecting_value: + color = theme.label + expecting_value = False + elif token.startswith("--"): + color = theme.long_option + expecting_value = ( + token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE + ) + elif token.startswith("-"): + color = theme.short_option + expecting_value = ( + token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE + ) + elif first_token: + color = theme.prog + elif not colored_subcommand: + color = theme.action + colored_subcommand = True + else: + color = None + + first_token = False + + if color: + parts.append(f"{color}{token}{theme.reset}") + else: + parts.append(token) + + return self._ColorizedLine(text="".join(parts), expect_value=expecting_value) + + +class HelpTheme(t.NamedTuple): + """Theme colors for help output. + + Examples + -------- + >>> from tmuxp.cli._formatter import HelpTheme + >>> theme = HelpTheme.from_colors(None) + >>> theme.reset + '' + """ + + prog: str + action: str + long_option: str + short_option: str + label: str + heading: str + reset: str + + @classmethod + def from_colors(cls, colors: t.Any) -> HelpTheme: + """Create theme from Colors instance. + + Parameters + ---------- + colors : Colors | None + Colors instance, or None for no colors. + + Returns + ------- + HelpTheme + Theme with ANSI codes if colors enabled, empty strings otherwise. + + Examples + -------- + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> from tmuxp.cli._formatter import HelpTheme + >>> colors = Colors(ColorMode.NEVER) + >>> theme = HelpTheme.from_colors(colors) + >>> theme.reset + '' + """ + if colors is None or not colors._enabled: + return cls( + prog="", + action="", + long_option="", + short_option="", + label="", + heading="", + reset="", + ) + + # Import style here to avoid circular import + from tmuxp.cli._colors import style + + return cls( + prog=style("", fg="magenta", bold=True).removesuffix("\033[0m"), + action=style("", fg="cyan").removesuffix("\033[0m"), + long_option=style("", fg="green").removesuffix("\033[0m"), + short_option=style("", fg="green").removesuffix("\033[0m"), + label=style("", fg="yellow").removesuffix("\033[0m"), + heading=style("", fg="blue").removesuffix("\033[0m"), + reset="\033[0m", + ) + + +def create_themed_formatter( + colors: t.Any | None = None, +) -> type[TmuxpHelpFormatter]: + """Create a help formatter class with theme bound. + + This factory creates a formatter subclass with the theme injected, + allowing colorized help output without modifying argparse internals. + + When no colors argument is provided, uses AUTO mode which respects + NO_COLOR, FORCE_COLOR environment variables and TTY detection. + + Parameters + ---------- + colors : Colors | None + Colors instance for styling. If None, uses ColorMode.AUTO. + + Returns + ------- + type[TmuxpHelpFormatter] + Formatter class with theme bound. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> from tmuxp.cli._formatter import create_themed_formatter, HelpTheme + + With explicit colors enabled: + + >>> colors = Colors(ColorMode.ALWAYS) + >>> formatter_cls = create_themed_formatter(colors) + >>> formatter = formatter_cls("test") + >>> formatter._theme is not None + True + + With colors disabled: + + >>> colors = Colors(ColorMode.NEVER) + >>> formatter_cls = create_themed_formatter(colors) + >>> formatter = formatter_cls("test") + >>> formatter._theme is None + True + """ + # Import here to avoid circular import at module load + from tmuxp.cli._colors import ColorMode, Colors + + if colors is None: + colors = Colors(ColorMode.AUTO) + + # Create theme if colors are enabled, None otherwise + theme = HelpTheme.from_colors(colors) if colors._enabled else None + + class ThemedTmuxpHelpFormatter(TmuxpHelpFormatter): + """TmuxpHelpFormatter with theme pre-configured.""" + + def __init__(self, prog: str, **kwargs: t.Any) -> None: + super().__init__(prog, **kwargs) + self._theme = theme + + return ThemedTmuxpHelpFormatter diff --git a/src/tmuxp/cli/_output.py b/src/tmuxp/cli/_output.py new file mode 100644 index 0000000000..7ac8df92ef --- /dev/null +++ b/src/tmuxp/cli/_output.py @@ -0,0 +1,173 @@ +"""Output formatting utilities for tmuxp CLI. + +Provides structured output modes (JSON, NDJSON) alongside human-readable output. + +Examples +-------- +>>> from tmuxp.cli._output import OutputMode, OutputFormatter, get_output_mode + +Get output mode from flags: + +>>> get_output_mode(json_flag=False, ndjson_flag=False) + +>>> get_output_mode(json_flag=True, ndjson_flag=False) + +>>> get_output_mode(json_flag=False, ndjson_flag=True) + + +NDJSON takes precedence over JSON: + +>>> get_output_mode(json_flag=True, ndjson_flag=True) + +""" + +from __future__ import annotations + +import enum +import json +import sys +import typing as t + + +class OutputMode(enum.Enum): + """Output format modes for CLI commands. + + Examples + -------- + >>> OutputMode.HUMAN.value + 'human' + >>> OutputMode.JSON.value + 'json' + >>> OutputMode.NDJSON.value + 'ndjson' + """ + + HUMAN = "human" + JSON = "json" + NDJSON = "ndjson" + + +class OutputFormatter: + """Manage output formatting for different modes (human, JSON, NDJSON). + + Parameters + ---------- + mode : OutputMode + The output mode to use (human, json, ndjson). Default is HUMAN. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.mode + + + >>> formatter = OutputFormatter() + >>> formatter.mode + + """ + + def __init__(self, mode: OutputMode = OutputMode.HUMAN) -> None: + """Initialize the output formatter.""" + self.mode = mode + self._json_buffer: list[dict[str, t.Any]] = [] + + def emit(self, data: dict[str, t.Any]) -> None: + """Emit a data event. + + In NDJSON mode, immediately writes one JSON object per line. + In JSON mode, buffers data for later output as a single array. + In HUMAN mode, does nothing (use emit_text for human output). + + Parameters + ---------- + data : dict + Event data to emit as JSON. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit({"name": "test", "path": "/tmp"}) + >>> len(formatter._json_buffer) + 1 + """ + if self.mode == OutputMode.NDJSON: + # Stream one JSON object per line immediately + sys.stdout.write(json.dumps(data) + "\n") + sys.stdout.flush() + elif self.mode == OutputMode.JSON: + # Buffer for later output as single array + self._json_buffer.append(data) + # Human mode: handled by specific command implementations + + def emit_text(self, text: str) -> None: + """Emit human-readable text (only in HUMAN mode). + + Parameters + ---------- + text : str + Text to output. + + Examples + -------- + >>> import io + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit_text("This won't print") # No output in JSON mode + """ + if self.mode == OutputMode.HUMAN: + sys.stdout.write(text + "\n") + sys.stdout.flush() + + def finalize(self) -> None: + """Finalize output (flush JSON buffer if needed). + + In JSON mode, outputs the buffered data as a formatted JSON array. + In other modes, does nothing. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit({"name": "test1"}) + >>> formatter.emit({"name": "test2"}) + >>> len(formatter._json_buffer) + 2 + >>> # formatter.finalize() would print the JSON array + """ + if self.mode == OutputMode.JSON and self._json_buffer: + sys.stdout.write(json.dumps(self._json_buffer, indent=2) + "\n") + sys.stdout.flush() + self._json_buffer.clear() + + +def get_output_mode(json_flag: bool, ndjson_flag: bool) -> OutputMode: + """Determine output mode from command flags. + + NDJSON takes precedence over JSON if both are specified. + + Parameters + ---------- + json_flag : bool + Whether --json was specified. + ndjson_flag : bool + Whether --ndjson was specified. + + Returns + ------- + OutputMode + The determined output mode. + + Examples + -------- + >>> get_output_mode(json_flag=False, ndjson_flag=False) + + >>> get_output_mode(json_flag=True, ndjson_flag=False) + + >>> get_output_mode(json_flag=False, ndjson_flag=True) + + >>> get_output_mode(json_flag=True, ndjson_flag=True) + + """ + if ndjson_flag: + return OutputMode.NDJSON + if json_flag: + return OutputMode.JSON + return OutputMode.HUMAN diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index f537f5b3d4..97d2d8cd25 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -9,14 +9,33 @@ from tmuxp import exc from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath 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 +CONVERT_DESCRIPTION = build_description( + """ + Convert workspace files between YAML and JSON format. + """, + ( + ( + None, + [ + "tmuxp convert workspace.yaml", + "tmuxp convert workspace.json", + "tmuxp convert -y workspace.yaml", + ], + ), + ), +) + if t.TYPE_CHECKING: import argparse AllowedFileTypes = t.Literal["json", "yaml"] + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] def create_convert_subparser( @@ -59,8 +78,12 @@ def command_convert( workspace_file: str | pathlib.Path, answer_yes: bool, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp convert`` convert a tmuxp config between JSON and YAML.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file( workspace_file, workspace_dir=get_workspace_dir(), @@ -90,8 +113,15 @@ def command_convert( if ( not answer_yes - and prompt_yes_no(f"Convert to <{workspace_file}> to {to_filetype}?") - and prompt_yes_no(f"Save workspace to {newfile}?") + and prompt_yes_no( + f"Convert {colors.info(str(PrivatePath(workspace_file)))} to " + f"{colors.highlight(to_filetype)}?", + color_mode=color_mode, + ) + and prompt_yes_no( + f"Save workspace to {colors.info(str(PrivatePath(newfile)))}?", + color_mode=color_mode, + ) ): answer_yes = True @@ -100,4 +130,8 @@ def command_convert( new_workspace, encoding=locale.getpreferredencoding(False), ) - print(f"New workspace file saved to <{newfile}>.") # NOQA: T201 RUF100 + print( # NOQA: T201 RUF100 + 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 fdd55c83fd..e4e28eeb24 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import os import pathlib import platform @@ -9,84 +10,256 @@ import sys import typing as t -from colorama import Fore from libtmux.__about__ import __version__ as libtmux_version from libtmux.common import get_version, tmux_cmd from tmuxp.__about__ import __version__ +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string +from ._colors import Colors, build_description, get_color_mode from .utils import tmuxp_echo +DEBUG_INFO_DESCRIPTION = build_description( + """ + Print diagnostic information for debugging and issue reports. + """, + ( + ( + None, + [ + "tmuxp debug-info", + ], + ), + ( + "Machine-readable output", + [ + "tmuxp debug-info --json", + ], + ), + ), +) + if t.TYPE_CHECKING: - import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] tmuxp_path = pathlib.Path(__file__).parent.parent +class CLIDebugInfoNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp debug-info command.""" + + color: CLIColorModeLiteral + output_json: bool + + def create_debug_info_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: """Augment :class:`argparse.ArgumentParser` with ``debug-info`` subcommand.""" + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) return parser -def command_debug_info( - parser: argparse.ArgumentParser | None = None, -) -> None: - """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" +def _private(path: pathlib.Path | str | None) -> str: + """Privacy-mask a path by collapsing home directory to ~. - def prepend_tab(strings: list[str]) -> list[str]: - """Prepend tab to strings in list.""" - return [f"\t{x}" for x in strings] + Parameters + ---------- + path : pathlib.Path | str | None + Path to mask. - def output_break() -> str: - """Generate output break.""" - return "-" * 25 + Returns + ------- + str + Path with home directory replaced by ~. - def format_tmux_resp(std_resp: tmux_cmd) -> str: - """Format tmux command response for tmuxp stdout.""" - return "\n".join( - [ - "\n".join(prepend_tab(std_resp.stdout)), - Fore.RED, - "\n".join(prepend_tab(std_resp.stderr)), - Fore.RESET, - ], - ) + Examples + -------- + >>> _private(None) + '' + >>> _private('') + '' + >>> _private('/usr/bin/tmux') + '/usr/bin/tmux' + """ + if path is None or path == "": + return "" + return str(PrivatePath(path)) + + +def _collect_debug_info() -> dict[str, t.Any]: + """Collect debug information as a structured dictionary. + + All paths are privacy-masked using PrivatePath (home → ~). + + Returns + ------- + dict[str, Any] + Debug information with environment, versions, paths, and tmux state. + + Examples + -------- + >>> data = _collect_debug_info() + >>> 'environment' in data + True + >>> 'tmux_version' in data + True + """ + # Collect tmux command outputs + sessions_resp = tmux_cmd("list-sessions") + windows_resp = tmux_cmd("list-windows") + panes_resp = tmux_cmd("list-panes") + global_opts_resp = tmux_cmd("show-options", "-g") + window_opts_resp = tmux_cmd("show-window-options", "-g") + return { + "environment": { + "dist": platform.platform(), + "arch": platform.machine(), + "uname": list(platform.uname()[:3]), + "version": platform.version(), + }, + "python_version": " ".join(sys.version.split("\n")), + "system_path": collapse_home_in_string(os.environ.get("PATH", "")), + "tmux_version": str(get_version()), + "libtmux_version": libtmux_version, + "tmuxp_version": __version__, + "tmux_path": _private(shutil.which("tmux")), + "tmuxp_path": _private(tmuxp_path), + "shell": _private(os.environ.get("SHELL", "")), + "tmux": { + "sessions": sessions_resp.stdout, + "windows": windows_resp.stdout, + "panes": panes_resp.stdout, + "global_options": global_opts_resp.stdout, + "window_options": window_opts_resp.stdout, + }, + } + + +def _format_human_output(data: dict[str, t.Any], colors: Colors) -> str: + """Format debug info as human-readable colored output. + + Parameters + ---------- + data : dict[str, Any] + Debug information dictionary. + colors : Colors + Color manager for formatting. + + Returns + ------- + str + Formatted human-readable output. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> data = { + ... "environment": { + ... "dist": "Linux", + ... "arch": "x86_64", + ... "uname": ["Linux", "host", "6.0"], + ... "version": "#1 SMP", + ... }, + ... "python_version": "3.12.0", + ... "system_path": "/usr/bin", + ... "tmux_version": "3.4", + ... "libtmux_version": "0.40.0", + ... "tmuxp_version": "1.50.0", + ... "tmux_path": "/usr/bin/tmux", + ... "tmuxp_path": "~/tmuxp", + ... "shell": "/bin/bash", + ... "tmux": { + ... "sessions": [], + ... "windows": [], + ... "panes": [], + ... "global_options": [], + ... "window_options": [], + ... }, + ... } + >>> output = _format_human_output(data, colors) + >>> "environment" in output + True + """ + + def format_tmux_section(lines: list[str]) -> str: + """Format tmux command output with syntax highlighting.""" + formatted_lines = [] + for line in lines: + formatted = colors.format_tmux_option(line) + formatted_lines.append(f"\t{formatted}") + return "\n".join(formatted_lines) + + env = data["environment"] + env_items = [ + f"\t{colors.format_kv('dist', env['dist'])}", + f"\t{colors.format_kv('arch', env['arch'])}", + f"\t{colors.format_kv('uname', '; '.join(env['uname']))}", + f"\t{colors.format_kv('version', env['version'])}", + ] + + tmux_data = data["tmux"] output = [ - output_break(), - "environment:\n{}".format( - "\n".join( - prepend_tab( - [ - f"dist: {platform.platform()}", - f"arch: {platform.machine()}", - "uname: {}".format("; ".join(platform.uname()[:3])), - f"version: {platform.version()}", - ], - ), - ), - ), - output_break(), - "python version: {}".format(" ".join(sys.version.split("\n"))), - "system PATH: {}".format(os.environ["PATH"]), - f"tmux version: {get_version()}", - f"libtmux version: {libtmux_version}", - f"tmuxp version: {__version__}", - "tmux path: {}".format(shutil.which("tmux")), - f"tmuxp path: {tmuxp_path}", - "shell: {}".format(os.environ["SHELL"]), - output_break(), - "tmux sessions:\n{}".format(format_tmux_resp(tmux_cmd("list-sessions"))), - "tmux windows:\n{}".format(format_tmux_resp(tmux_cmd("list-windows"))), - "tmux panes:\n{}".format(format_tmux_resp(tmux_cmd("list-panes"))), - "tmux global options:\n{}".format( - format_tmux_resp(tmux_cmd("show-options", "-g")), - ), - "tmux window options:\n{}".format( - format_tmux_resp(tmux_cmd("show-window-options", "-g")), + colors.format_separator(), + f"{colors.format_label('environment')}:\n" + "\n".join(env_items), + colors.format_separator(), + colors.format_kv("python version", data["python_version"]), + colors.format_kv("system PATH", data["system_path"]), + colors.format_kv("tmux version", colors.format_version(data["tmux_version"])), + colors.format_kv( + "libtmux version", colors.format_version(data["libtmux_version"]) ), + colors.format_kv("tmuxp version", colors.format_version(data["tmuxp_version"])), + colors.format_kv("tmux path", colors.format_path(data["tmux_path"])), + colors.format_kv("tmuxp path", colors.format_path(data["tmuxp_path"])), + colors.format_kv("shell", data["shell"]), + colors.format_separator(), + f"{colors.format_label('tmux sessions')}:\n" + + format_tmux_section(tmux_data["sessions"]), + f"{colors.format_label('tmux windows')}:\n" + + format_tmux_section(tmux_data["windows"]), + f"{colors.format_label('tmux panes')}:\n" + + format_tmux_section(tmux_data["panes"]), + f"{colors.format_label('tmux global options')}:\n" + + format_tmux_section(tmux_data["global_options"]), + f"{colors.format_label('tmux window options')}:\n" + + format_tmux_section(tmux_data["window_options"]), ] - tmuxp_echo("\n".join(output)) + return "\n".join(output) + + +def command_debug_info( + args: CLIDebugInfoNamespace | None = None, + 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 + + # Get color mode (only used for human output) + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Collect debug info + data = _collect_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() + else: + tmuxp_echo(_format_human_output(data, colors)) diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index 075ca201dd..006ad6bb12 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -6,11 +6,32 @@ import subprocess import typing as t +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.finders import find_workspace_file +from ._colors import Colors, build_description, get_color_mode + +EDIT_DESCRIPTION = build_description( + """ + Open tmuxp workspace file in your system editor ($EDITOR). + """, + ( + ( + None, + [ + "tmuxp edit myproject", + "tmuxp edit ./workspace.yaml", + ], + ), + ), +) + if t.TYPE_CHECKING: import argparse import pathlib + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] def create_edit_subparser( @@ -29,9 +50,20 @@ def create_edit_subparser( def command_edit( workspace_file: str | pathlib.Path, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp edit``, open tmuxp workspace file in system editor.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file(workspace_file) sys_editor = os.environ.get("EDITOR", "vim") + print( # NOQA: T201 RUF100 + colors.muted("Opening ") + + colors.info(str(PrivatePath(workspace_file))) + + colors.muted(" in ") + + colors.highlight(sys_editor, bold=False) + + colors.muted("..."), + ) subprocess.call([sys_editor, workspace_file]) diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index ebffaab633..9b48ebf01e 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -13,21 +13,42 @@ from tmuxp import exc, util from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.exc import TmuxpException from tmuxp.workspace import freezer 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 +FREEZE_DESCRIPTION = build_description( + """ + Freeze a live tmux session to a tmuxp workspace file. + """, + ( + ( + None, + [ + "tmuxp freeze mysession", + "tmuxp freeze mysession -o session.yaml", + "tmuxp freeze -f json mysession", + "tmuxp freeze -y mysession", + ], + ), + ), +) + if t.TYPE_CHECKING: - from typing import TypeAlias, TypeGuard + from typing import TypeGuard - CLIOutputFormatLiteral: TypeAlias = t.Literal["yaml", "json"] + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + CLIOutputFormatLiteral: t.TypeAlias = t.Literal["yaml", "json"] class CLIFreezeNamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp freeze command.""" + color: CLIColorModeLiteral session_name: str socket_name: str | None socket_path: str | None @@ -106,6 +127,9 @@ def command_freeze( If SESSION_NAME is provided, snapshot that session. Otherwise, use the current session. """ + color_mode = get_color_mode(args.color) + colors = Colors(color_mode) + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) try: @@ -117,7 +141,7 @@ def command_freeze( if not session: raise exc.SessionNotFound except TmuxpException as e: - print(e) # NOQA: T201 RUF100 + print(colors.error(str(e))) # NOQA: T201 RUF100 return frozen_workspace = freezer.freeze(session) @@ -126,21 +150,26 @@ def command_freeze( if not args.quiet: print( # NOQA: T201 RUF100 - "---------------------------------------------------------------" - "\n" - "Freeze does its best to snapshot live tmux sessions.\n", + colors.format_separator(63) + + "\n" + + colors.muted("Freeze does its best to snapshot live tmux sessions.") + + "\n", ) if not ( args.answer_yes or prompt_yes_no( "The new workspace will require adjusting afterwards. Save workspace file?", + color_mode=color_mode, ) ): if not args.quiet: print( # NOQA: T201 RUF100 - "tmuxp has examples in JSON and YAML format at " - "\n" - "View tmuxp docs at .", + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info("") + + ".", ) sys.exit() @@ -156,11 +185,16 @@ def command_freeze( ), ) dest_prompt = prompt( - f"Save to: {save_to}", + f"Save to: {PrivatePath(save_to)}", default=save_to, + color_mode=color_mode, ) if not args.force and os.path.exists(dest_prompt): - print(f"{dest_prompt} exists. Pick a new filename.") # NOQA: T201 RUF100 + print( # NOQA: T201 RUF100 + colors.warning(f"{PrivatePath(dest_prompt)} exists.") + + " " + + colors.muted("Pick a new filename."), + ) continue dest = dest_prompt @@ -192,6 +226,7 @@ def extract_workspace_format( ), choices=t.cast("list[str]", valid_workspace_formats), default="yaml", + color_mode=color_mode, ) assert is_valid_ext(workspace_format_) workspace_format = workspace_format_ @@ -206,7 +241,10 @@ def extract_workspace_format( elif workspace_format == "json": workspace = configparser.dump(fmt="json", indent=2) - if args.answer_yes or prompt_yes_no(f"Save to {dest}?"): + if args.answer_yes or prompt_yes_no( + f"Save to {PrivatePath(dest)}?", + color_mode=color_mode, + ): destdir = os.path.dirname(dest) if not os.path.isdir(destdir): os.makedirs(destdir) @@ -216,4 +254,6 @@ def extract_workspace_format( ) if not args.quiet: - print(f"Saved to {dest}.") # NOQA: T201 RUF100 + print( # NOQA: T201 RUF100 + 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 fc34b8965d..63c2d24a30 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -1,4 +1,4 @@ -"""CLI for ``tmuxp shell`` subcommand.""" +"""CLI for ``tmuxp import`` subcommand.""" from __future__ import annotations @@ -9,13 +9,38 @@ import typing as t from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import importers from tmuxp.workspace.finders import find_workspace_file +from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo +IMPORT_DESCRIPTION = build_description( + """ + Import workspaces from teamocil and tmuxinator configuration files. + """, + ( + ( + "teamocil", + [ + "tmuxp import teamocil ~/.teamocil/project.yml", + ], + ), + ( + "tmuxinator", + [ + "tmuxp import tmuxinator ~/.tmuxinator/project.yml", + ], + ), + ), +) + if t.TYPE_CHECKING: import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] def get_tmuxinator_dir() -> pathlib.Path: @@ -141,8 +166,12 @@ def import_config( workspace_file: str, importfunc: ImportConfigFn, parser: argparse.ArgumentParser | None = None, + colors: Colors | None = None, ) -> None: """Import a configuration from a workspace_file.""" + if colors is None: + colors = Colors(ColorMode.AUTO) + existing_workspace_file = ConfigReader._from_file(pathlib.Path(workspace_file)) cfg_reader = ConfigReader(importfunc(existing_workspace_file)) @@ -150,6 +179,7 @@ def import_config( "Convert to", choices=["yaml", "json"], default="yaml", + color_mode=colors.mode, ) if workspace_file_format == "yaml": @@ -157,25 +187,32 @@ def import_config( elif workspace_file_format == "json": new_config = cfg_reader.dump("json", indent=2) else: - sys.exit("Unknown config format.") + sys.exit(colors.error("Unknown config format.")) tmuxp_echo( - new_config + "---------------------------------------------------------------" - "\n" - "Configuration import does its best to convert files.\n", + new_config + + colors.format_separator(63) + + "\n" + + colors.muted("Configuration import does its best to convert files.") + + "\n", ) if prompt_yes_no( "The new config *WILL* require adjusting afterwards. Save config?", + color_mode=colors.mode, ): dest = None while not dest: dest_path = prompt( - f"Save to [{os.getcwd()}]", + f"Save to [{PrivatePath(os.getcwd())}]", value_proc=_resolve_path_no_overwrite, + color_mode=colors.mode, ) # dest = dest_prompt - if prompt_yes_no(f"Save to {dest_path}?"): + if prompt_yes_no( + f"Save to {PrivatePath(dest_path)}?", + color_mode=colors.mode, + ): dest = dest_path pathlib.Path(dest).write_text( @@ -183,12 +220,16 @@ def import_config( encoding=locale.getpreferredencoding(False), ) - tmuxp_echo(f"Saved to {dest}.") + tmuxp_echo( + colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", + ) else: tmuxp_echo( - "tmuxp has examples in JSON and YAML format at " - "\n" - "View tmuxp docs at ", + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info(""), ) sys.exit() @@ -196,31 +237,39 @@ def import_config( def command_import_tmuxinator( workspace_file: str, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp import tmuxinator`` subcommand. Converts a tmuxinator config from workspace_file to tmuxp format and import it into tmuxp. """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file( workspace_file, workspace_dir=get_tmuxinator_dir(), ) - import_config(workspace_file, importers.import_tmuxinator) + import_config(workspace_file, importers.import_tmuxinator, colors=colors) def command_import_teamocil( workspace_file: str, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp import teamocil`` subcommand. Convert a teamocil config from workspace_file to tmuxp format and import it into tmuxp. """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file( workspace_file, workspace_dir=get_teamocil_dir(), ) - import_config(workspace_file, importers.import_teamocil) + import_config(workspace_file, importers.import_teamocil, colors=colors) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index e9339493f0..3e6edbd2b7 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -15,11 +15,32 @@ from tmuxp import exc, log, util from tmuxp._internal import config_reader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import loader from tmuxp.workspace.builder import WorkspaceBuilder from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir -from .utils import prompt_choices, prompt_yes_no, style, tmuxp_echo +from ._colors import ColorMode, Colors, build_description, get_color_mode +from .utils import prompt_choices, prompt_yes_no, tmuxp_echo + +LOAD_DESCRIPTION = build_description( + """ + Load tmuxp workspace file(s) and create or attach to a tmux session. + """, + ( + ( + None, + [ + "tmuxp load myproject", + "tmuxp load ./workspace.yaml", + "tmuxp load -d myproject", + "tmuxp load -y dev staging", + "tmuxp load -L other-socket myproject", + "tmuxp load -a myproject", + ], + ), + ), +) if t.TYPE_CHECKING: from typing import TypeAlias @@ -30,6 +51,7 @@ from tmuxp.types import StrPath CLIColorsLiteral: TypeAlias = t.Literal[56, 88] + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] class OptionOverrides(TypedDict): """Optional argument overrides for tmuxp load.""" @@ -47,13 +69,49 @@ class CLILoadNamespace(argparse.Namespace): tmux_config_file: str | None new_session_name: str | None answer_yes: bool | None + detached: bool append: bool | None colors: CLIColorsLiteral | None + color: CLIColorModeLiteral log_file: str | None -def load_plugins(session_config: dict[str, t.Any]) -> list[t.Any]: - """Load and return plugins in workspace.""" +def load_plugins( + session_config: dict[str, t.Any], + colors: Colors | None = None, +) -> list[t.Any]: + """Load and return plugins in workspace. + + Parameters + ---------- + session_config : dict + Session configuration dictionary. + colors : Colors | None + Colors instance for output formatting. If None, uses AUTO mode. + + Returns + ------- + list + List of loaded plugin instances. + + Examples + -------- + Empty config returns empty list: + + >>> from tmuxp.cli.load import load_plugins + >>> load_plugins({'session_name': 'test'}) + [] + + With explicit Colors instance: + + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> load_plugins({'session_name': 'test'}, colors=colors) + [] + """ + if colors is None: + colors = Colors(ColorMode.AUTO) + plugins = [] if "plugins" in session_config: for plugin in session_config["plugins"]: @@ -61,11 +119,11 @@ def load_plugins(session_config: dict[str, t.Any]) -> list[t.Any]: module_name = plugin.split(".") module_name = ".".join(module_name[:-1]) plugin_name = plugin.split(".")[-1] - except Exception as error: + except AttributeError as error: tmuxp_echo( - style("[Plugin Error] ", fg="red") - + f"Couldn't load {plugin}\n" - + style(f"{error}", fg="yellow"), + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), ) sys.exit(1) @@ -74,38 +132,35 @@ def load_plugins(session_config: dict[str, t.Any]) -> list[t.Any]: plugins.append(plugin()) except exc.TmuxpPluginException as error: if not prompt_yes_no( - "{}Skip loading {}?".format( - style( - str(error), - fg="yellow", - ), - plugin_name, - ), + f"{colors.warning(str(error))}Skip loading {plugin_name}?", default=True, + color_mode=colors.mode, ): tmuxp_echo( - style("[Not Skipping] ", fg="yellow") - + "Plugin versions constraint not met. Exiting...", + colors.warning("[Not Skipping]") + + " Plugin versions constraint not met. Exiting...", ) sys.exit(1) - except Exception as error: + except (ImportError, AttributeError) as error: tmuxp_echo( - style("[Plugin Error] ", fg="red") - + f"Couldn't load {plugin}\n" - + style(f"{error}", fg="yellow"), + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), ) sys.exit(1) return plugins -def _reattach(builder: WorkspaceBuilder) -> None: +def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: """ Reattach session (depending on env being inside tmux already or not). Parameters ---------- builder: :class:`workspace.builder.WorkspaceBuilder` + colors : Colors | None + Optional Colors instance for styled output. Notes ----- @@ -120,7 +175,7 @@ def _reattach(builder: WorkspaceBuilder) -> None: plugin.reattach(builder.session) proc = builder.session.cmd("display-message", "-p", "'#S'") for line in proc.stdout: - print(line) # NOQA: T201 RUF100 + print(colors.info(line) if colors else line) # NOQA: T201 RUF100 if "TMUX" in os.environ: builder.session.switch_client() @@ -152,19 +207,22 @@ def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: builder.session.attach() -def _load_detached(builder: WorkspaceBuilder) -> None: +def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: """ Load workspace in new session but don't attach. Parameters ---------- builder: :class:`workspace.builder.WorkspaceBuilder` + colors : Colors | None + Optional Colors instance for styled output. """ builder.build() assert builder.session is not None - print("Session created in detached state.") # NOQA: T201 RUF100 + msg = "Session created in detached state." + print(colors.info(msg) if colors else msg) # NOQA: T201 RUF100 def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: @@ -197,13 +255,14 @@ def _setup_plugins(builder: WorkspaceBuilder) -> Session: def load_workspace( workspace_file: StrPath, socket_name: str | None = None, - socket_path: None = None, + socket_path: str | None = None, tmux_config_file: str | None = None, new_session_name: str | None = None, colors: int | None = None, detached: bool = False, answer_yes: bool = False, append: bool = False, + cli_colors: Colors | None = None, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -217,9 +276,8 @@ def load_workspace( ``tmux -S `` new_session_name: str, options ``tmux new -s `` - colors : str, optional - '-2' - Force tmux to support 256 colors + colors : int, optional + Force tmux to support 256 or 88 colors. detached : bool Force detached state. default False. answer_yes : bool @@ -228,6 +286,8 @@ def load_workspace( append : bool Assume current when given prompt to append windows in same session. Default False. + cli_colors : Colors, optional + Colors instance for CLI output formatting. If None, uses AUTO mode. Notes ----- @@ -276,13 +336,18 @@ def load_workspace( behalf. An exception raised during this process means it's not easy to predict how broken the session is. """ + # Initialize CLI colors if not provided + if cli_colors is None: + cli_colors = Colors(ColorMode.AUTO) + # get the canonical path, eliminating any symlinks if isinstance(workspace_file, (str, os.PathLike)): workspace_file = pathlib.Path(workspace_file) tmuxp_echo( - style("[Loading] ", fg="green") - + style(str(workspace_file), fg="blue", bold=True), + cli_colors.info("[Loading]") + + " " + + cli_colors.highlight(str(PrivatePath(workspace_file))), ) # ConfigReader allows us to open a yaml or json file as a dict @@ -313,11 +378,14 @@ def load_workspace( try: # load WorkspaceBuilder object for tmuxp workspace / tmux server builder = WorkspaceBuilder( session_config=expanded_workspace, - plugins=load_plugins(expanded_workspace), + plugins=load_plugins(expanded_workspace, colors=cli_colors), server=t, ) except exc.EmptyWorkspaceException: - tmuxp_echo(f"{workspace_file} is empty or parsed no workspace data") + tmuxp_echo( + cli_colors.warning("[Warning]") + + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", + ) return None session_name = expanded_workspace["session_name"] @@ -327,18 +395,17 @@ def load_workspace( if not detached and ( answer_yes or prompt_yes_no( - "{} is already running. Attach?".format( - style(session_name, fg="green"), - ), + f"{cli_colors.highlight(session_name)} is already running. Attach?", default=True, + color_mode=cli_colors.mode, ) ): - _reattach(builder) + _reattach(builder, cli_colors) return None try: if detached: - _load_detached(builder) + _load_detached(builder, cli_colors) return _setup_plugins(builder) if append: @@ -360,14 +427,14 @@ def load_workspace( "Or (a)ppend windows in the current active session?\n[y/n/a]" ) options = ["y", "n", "a"] - choice = prompt_choices(msg, choices=options) + 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) + _load_detached(builder, cli_colors) else: _load_attached(builder, detached) @@ -375,20 +442,22 @@ def load_workspace( import traceback tmuxp_echo(traceback.format_exc()) - tmuxp_echo(str(e)) + tmuxp_echo(cli_colors.error("[Error]") + f" {e}") choice = prompt_choices( - "Error loading workspace. (k)ill, (a)ttach, (d)etach?", + 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("Session killed.") + tmuxp_echo(cli_colors.muted("Session killed.")) elif choice == "a": - _reattach(builder) + _reattach(builder, cli_colors) else: sys.exit() @@ -517,34 +586,27 @@ def command_load( """ util.oh_my_zsh_auto_title() + # Create Colors instance based on CLI --color flag + cli_colors = Colors(get_color_mode(args.color)) + if args.log_file: logfile_handler = logging.FileHandler(args.log_file) logfile_handler.setFormatter(log.LogFormatter()) - from . import logger - - logger.addHandler(logfile_handler) - - tmux_options = { - "socket_name": args.socket_name, - "socket_path": args.socket_path, - "tmux_config_file": args.tmux_config_file, - "new_session_name": args.new_session_name, - "answer_yes": args.answer_yes, - "colors": args.colors, - "detached": args.detached, - "append": args.append, - } + # 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) if args.workspace_files is None or len(args.workspace_files) == 0: - tmuxp_echo("Enter at least one config") + tmuxp_echo(cli_colors.error("Enter at least one config")) if parser is not None: parser.print_help() sys.exit() return last_idx = len(args.workspace_files) - 1 - original_detached_option = tmux_options.pop("detached") - original_new_session_name = tmux_options.pop("new_session_name") + original_detached_option = args.detached + original_new_session_name = args.new_session_name for idx, workspace_file in enumerate(args.workspace_files): workspace_file = find_workspace_file( @@ -561,7 +623,13 @@ def command_load( load_workspace( workspace_file, - detached=detached, + socket_name=args.socket_name, + socket_path=args.socket_path, + tmux_config_file=args.tmux_config_file, new_session_name=new_session_name, - **tmux_options, + colors=args.colors, + detached=detached, + answer_yes=args.answer_yes or False, + append=args.append or False, + cli_colors=cli_colors, ) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index e1a8c7a610..4111f5cbf1 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -1,32 +1,648 @@ -"""CLI for ``tmuxp ls`` subcommand.""" +"""CLI for ``tmuxp ls`` subcommand. + +List and display workspace configuration files. + +Examples +-------- +>>> from tmuxp.cli.ls import WorkspaceInfo + +Create workspace info from file path: + +>>> import pathlib +>>> ws = WorkspaceInfo( +... name="dev", +... path="~/.tmuxp/dev.yaml", +... format="yaml", +... size=256, +... mtime="2024-01-15T10:30:00", +... session_name="development", +... source="global", +... ) +>>> ws["name"] +'dev' +>>> ws["source"] +'global' +""" from __future__ import annotations -import os +import argparse +import datetime +import json +import pathlib import typing as t +import yaml + +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS -from tmuxp.workspace.finders import get_workspace_dir +from tmuxp.workspace.finders import ( + find_local_workspace_files, + get_workspace_dir, + get_workspace_dir_candidates, +) + +from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, OutputMode, get_output_mode + +LS_DESCRIPTION = build_description( + """ + List workspace files in the tmuxp configuration directory. + """, + ( + ( + None, + [ + "tmuxp ls", + "tmuxp ls --tree", + "tmuxp ls --full", + ], + ), + ( + "Machine-readable output", + [ + "tmuxp ls --json", + "tmuxp ls --json --full", + "tmuxp ls --ndjson", + "tmuxp ls --json | jq '.workspaces[].name'", + ], + ), + ), +) if t.TYPE_CHECKING: - import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +class WorkspaceInfo(t.TypedDict): + """Workspace file information for JSON output. + + Attributes + ---------- + name : str + Workspace name (file stem without extension). + path : str + Path to workspace file (with ~ contraction). + format : str + File format (yaml or json). + size : int + File size in bytes. + mtime : str + Modification time in ISO format. + session_name : str | None + Session name from config if parseable. + source : str + Source location: "local" (cwd/parents) or "global" (~/.tmuxp/). + """ + + name: str + path: str + format: str + size: int + mtime: str + session_name: str | None + source: str + + +class CLILsNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp ls command. + + Examples + -------- + >>> ns = CLILsNamespace() + >>> ns.color = "auto" + >>> ns.color + 'auto' + """ + + color: CLIColorModeLiteral + tree: bool + output_json: bool + output_ndjson: bool + full: bool def create_ls_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: - """Augment :class:`argparse.ArgumentParser` with ``ls`` subcommand.""" + """Augment :class:`argparse.ArgumentParser` with ``ls`` subcommand. + + Parameters + ---------- + parser : argparse.ArgumentParser + The parser to augment. + + Returns + ------- + argparse.ArgumentParser + The augmented parser. + + Examples + -------- + >>> import argparse + >>> parser = argparse.ArgumentParser() + >>> result = create_ls_subparser(parser) + >>> result is parser + True + """ + parser.add_argument( + "--tree", + action="store_true", + help="display workspaces grouped by directory", + ) + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) + parser.add_argument( + "--ndjson", + action="store_true", + dest="output_ndjson", + help="output as NDJSON (one JSON per line)", + ) + parser.add_argument( + "--full", + action="store_true", + help="include full config content in output", + ) return parser +def _get_workspace_info( + filepath: pathlib.Path, + *, + source: str = "global", + include_config: bool = False, +) -> dict[str, t.Any]: + """Extract metadata from a workspace file. + + Parameters + ---------- + filepath : pathlib.Path + Path to the workspace file. + source : str + Source location: "local" or "global". Default "global". + include_config : bool + If True, include full parsed config content. Default False. + + Returns + ------- + dict[str, Any] + Workspace metadata dictionary. Includes 'config' key when include_config=True. + + Examples + -------- + >>> content = "session_name: test-session" + chr(10) + "windows: []" + >>> yaml_file = tmp_path / "test.yaml" + >>> _ = yaml_file.write_text(content) + >>> info = _get_workspace_info(yaml_file) + >>> info['session_name'] + 'test-session' + >>> info['format'] + 'yaml' + >>> info['source'] + 'global' + >>> info_local = _get_workspace_info(yaml_file, source="local") + >>> info_local['source'] + 'local' + >>> info_full = _get_workspace_info(yaml_file, include_config=True) + >>> 'config' in info_full + True + >>> info_full['config']['session_name'] + 'test-session' + """ + stat = filepath.stat() + ext = filepath.suffix.lower() + file_format = "json" if ext == ".json" else "yaml" + + # Try to extract session_name and optionally full config + session_name: str | None = None + config_content: dict[str, t.Any] | None = None + try: + config = ConfigReader.from_file(filepath) + if isinstance(config.content, dict): + session_name = config.content.get("session_name") + if include_config: + config_content = config.content + except (yaml.YAMLError, json.JSONDecodeError, OSError): + # If we can't parse it, just skip session_name + pass + + result: dict[str, t.Any] = { + "name": filepath.stem, + "path": str(PrivatePath(filepath)), + "format": file_format, + "size": stat.st_size, + "mtime": datetime.datetime.fromtimestamp( + stat.st_mtime, + tz=datetime.timezone.utc, + ).isoformat(), + "session_name": session_name, + "source": source, + } + + if include_config: + result["config"] = config_content + + return result + + +def _render_config_tree(config: dict[str, t.Any], colors: Colors) -> list[str]: + """Render config windows/panes as tree lines for human output. + + Parameters + ---------- + config : dict[str, Any] + Parsed config content. + colors : Colors + Color manager. + + Returns + ------- + list[str] + Lines of formatted tree output. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> config = { + ... "session_name": "dev", + ... "windows": [ + ... {"window_name": "editor", "layout": "main-horizontal"}, + ... {"window_name": "shell"}, + ... ], + ... } + >>> lines = _render_config_tree(config, colors) + >>> "editor" in lines[0] + True + >>> "shell" in lines[1] + True + """ + lines: list[str] = [] + windows = config.get("windows", []) + + for i, window in enumerate(windows): + if not isinstance(window, dict): + continue + + is_last_window = i == len(windows) - 1 + prefix = "└── " if is_last_window else "├── " + child_prefix = " " if is_last_window else "│ " + + # Window line + window_name = window.get("window_name", f"window {i}") + layout = window.get("layout", "") + layout_info = f" [{layout}]" if layout else "" + lines.append(f"{prefix}{colors.info(window_name)}{colors.muted(layout_info)}") + + # Panes + panes = window.get("panes", []) + for j, pane in enumerate(panes): + is_last_pane = j == len(panes) - 1 + pane_prefix = "└── " if is_last_pane else "├── " + + # Get pane command summary + if isinstance(pane, dict): + cmds = pane.get("shell_command", []) + if isinstance(cmds, str): + cmd_str = cmds + elif isinstance(cmds, list) and cmds: + cmd_str = str(cmds[0]) + else: + cmd_str = "" + elif isinstance(pane, str): + cmd_str = pane + else: + cmd_str = "" + + # Truncate long commands + if len(cmd_str) > 40: + cmd_str = cmd_str[:37] + "..." + + pane_info = f": {cmd_str}" if cmd_str else "" + lines.append( + f"{child_prefix}{pane_prefix}{colors.muted(f'pane {j}')}{pane_info}" + ) + + return lines + + +def _render_global_workspace_dirs( + formatter: OutputFormatter, + colors: Colors, + global_dir_candidates: list[dict[str, t.Any]], +) -> None: + """Render global workspace directories section. + + Parameters + ---------- + formatter : OutputFormatter + Output formatter. + colors : Colors + Color manager. + global_dir_candidates : list[dict[str, Any]] + List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> candidates = [ + ... {"path": "~/.tmuxp", "source": "Legacy", "exists": True, + ... "workspace_count": 5, "active": True}, + ... {"path": "~/.config/tmuxp", "source": "XDG", "exists": False, + ... "workspace_count": 0, "active": False}, + ... ] + >>> _render_global_workspace_dirs(formatter, colors, candidates) + + Global workspace directories: + Legacy: ~/.tmuxp (5 workspaces, active) + XDG: ~/.config/tmuxp (not found) + """ + formatter.emit_text("") + formatter.emit_text(colors.heading("Global workspace directories:")) + for candidate in global_dir_candidates: + path = candidate["path"] + source = candidate.get("source", "") + source_prefix = f"{source}: " if source else "" + if candidate["exists"]: + count = candidate["workspace_count"] + status = f"{count} workspace{'s' if count != 1 else ''}" + if candidate["active"]: + status += ", active" + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.success(status)})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} ({status})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.muted('not found')})" + ) + + +def _output_flat( + workspaces: list[dict[str, t.Any]], + formatter: OutputFormatter, + colors: Colors, + *, + full: bool = False, + global_dir_candidates: list[dict[str, t.Any]] | None = None, +) -> None: + """Output workspaces in flat list format. + + Groups workspaces by source (local vs global) for human output. + + Parameters + ---------- + workspaces : list[dict[str, Any]] + Workspaces to display. + formatter : OutputFormatter + Output formatter. + colors : Colors + Color manager. + full : bool + If True, show full config details in tree format. Default False. + global_dir_candidates : list[dict[str, Any]] | None + List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> workspaces = [{"name": "dev", "path": "~/.tmuxp/dev.yaml", "source": "global"}] + >>> _output_flat(workspaces, formatter, colors) + Global workspaces: + dev + """ + # Separate by source for human output grouping + local_workspaces = [ws for ws in workspaces if ws["source"] == "local"] + global_workspaces = [ws for ws in workspaces if ws["source"] == "global"] + + def output_workspace(ws: dict[str, t.Any], show_path: bool) -> None: + """Output a single workspace.""" + formatter.emit(ws) + path_info = f" {colors.info(ws['path'])}" if show_path else "" + formatter.emit_text(f" {colors.highlight(ws['name'])}{path_info}") + + # With --full, show config tree + if full and ws.get("config"): + for line in _render_config_tree(ws["config"], colors): + formatter.emit_text(f" {line}") + + # Output local workspaces first (closest to user's context) + if local_workspaces: + formatter.emit_text(colors.heading("Local workspaces:")) + for ws in local_workspaces: + output_workspace(ws, show_path=True) + + # Output global workspaces with active directory in header + if global_workspaces: + if local_workspaces: + formatter.emit_text("") # Blank line separator + + # Find active directory for header + active_dir = "" + if global_dir_candidates: + for candidate in global_dir_candidates: + if candidate["active"]: + active_dir = candidate["path"] + break + + if active_dir: + formatter.emit_text(colors.heading(f"Global workspaces ({active_dir}):")) + else: + formatter.emit_text(colors.heading("Global workspaces:")) + + for ws in global_workspaces: + output_workspace(ws, show_path=False) + + # Output global workspace directories section + if global_dir_candidates: + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) + + +def _output_tree( + workspaces: list[dict[str, t.Any]], + formatter: OutputFormatter, + colors: Colors, + *, + full: bool = False, + global_dir_candidates: list[dict[str, t.Any]] | None = None, +) -> None: + """Output workspaces grouped by directory (tree view). + + Parameters + ---------- + workspaces : list[dict[str, Any]] + Workspaces to display. + formatter : OutputFormatter + Output formatter. + colors : Colors + Color manager. + full : bool + If True, show full config details in tree format. Default False. + global_dir_candidates : list[dict[str, Any]] | None + List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> workspaces = [{"name": "dev", "path": "~/.tmuxp/dev.yaml", "source": "global"}] + >>> _output_tree(workspaces, formatter, colors) + + ~/.tmuxp + dev + """ + # Group by parent directory + by_directory: dict[str, list[dict[str, t.Any]]] = {} + for ws in workspaces: + # Extract parent directory from path + parent = str(pathlib.Path(ws["path"]).parent) + by_directory.setdefault(parent, []).append(ws) + + # Output grouped + for directory in sorted(by_directory.keys()): + dir_workspaces = by_directory[directory] + + # Human output: directory header + formatter.emit_text(f"\n{colors.highlight(directory)}") + + for ws in dir_workspaces: + # JSON/NDJSON output + formatter.emit(ws) + + # Human output: indented workspace name + ws_name = ws["name"] + ws_session = ws.get("session_name") + session_info = "" + if ws_session and ws_session != ws_name: + session_info = f" {colors.muted(f'→ {ws_session}')}" + formatter.emit_text(f" {colors.highlight(ws_name)}{session_info}") + + # With --full, show config tree + if full and ws.get("config"): + for line in _render_config_tree(ws["config"], colors): + formatter.emit_text(f" {line}") + + # Output global workspace directories section + if global_dir_candidates: + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) + + def command_ls( + args: CLILsNamespace | None = None, parser: argparse.ArgumentParser | None = None, ) -> None: - """Entrypoint for ``tmuxp ls`` subcommand.""" - tmuxp_dir = get_workspace_dir() - if os.path.exists(tmuxp_dir) and os.path.isdir(tmuxp_dir): - for f in sorted(os.listdir(tmuxp_dir)): - stem, ext = os.path.splitext(f) - if os.path.isdir(f) or ext not in VALID_WORKSPACE_DIR_FILE_EXTENSIONS: - continue - print(stem) # NOQA: T201 RUF100 + """Entrypoint for ``tmuxp ls`` subcommand. + + Lists both local workspaces (from cwd and parent directories) and + global workspaces (from ~/.tmuxp/). + + Parameters + ---------- + args : CLILsNamespace | None + Parsed command-line arguments. + parser : argparse.ArgumentParser | None + The argument parser (unused but required by CLI interface). + + Examples + -------- + >>> # 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) + + # Determine output mode and options + output_json = args.output_json if args else False + output_ndjson = args.output_ndjson if args else False + tree = args.tree if args else False + full = args.full if args else False + output_mode = get_output_mode(output_json, output_ndjson) + formatter = OutputFormatter(output_mode) + + # Get global workspace directory candidates + global_dir_candidates = get_workspace_dir_candidates() + + # 1. Collect local workspace files (cwd and parents) + local_files = find_local_workspace_files() + workspaces: list[dict[str, t.Any]] = [ + _get_workspace_info(f, source="local", include_config=full) for f in local_files + ] + + # 2. Collect global workspace files (~/.tmuxp/) + tmuxp_dir = pathlib.Path(get_workspace_dir()) + if tmuxp_dir.exists() and tmuxp_dir.is_dir(): + workspaces.extend( + _get_workspace_info(f, source="global", include_config=full) + for f in sorted(tmuxp_dir.iterdir()) + if not f.is_dir() + and f.suffix.lower() in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ) + + if not workspaces: + formatter.emit_text(colors.warning("No workspaces found.")) + # Still show global workspace directories even with no workspaces + if output_mode == OutputMode.HUMAN: + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) + elif output_mode == OutputMode.JSON: + # Output structured JSON with empty workspaces + output_data = { + "workspaces": [], + "global_workspace_dirs": global_dir_candidates, + } + sys.stdout.write(json.dumps(output_data, indent=2) + "\n") + sys.stdout.flush() + # NDJSON: just output nothing for empty workspaces + return + + # JSON mode: output structured object instead of using formatter + if output_mode == OutputMode.JSON: + output_data = { + "workspaces": workspaces, + "global_workspace_dirs": global_dir_candidates, + } + sys.stdout.write(json.dumps(output_data, indent=2) + "\n") + sys.stdout.flush() + return + + # Human and NDJSON output + if tree: + _output_tree( + workspaces, + formatter, + colors, + full=full, + global_dir_candidates=global_dir_candidates, + ) + else: + _output_flat( + workspaces, + formatter, + colors, + full=full, + global_dir_candidates=global_dir_candidates, + ) + + formatter.finalize() diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py new file mode 100644 index 0000000000..840e48a4a7 --- /dev/null +++ b/src/tmuxp/cli/search.py @@ -0,0 +1,1300 @@ +"""CLI for ``tmuxp search`` subcommand. + +Search workspace configuration files by name, session, path, and content. + +Examples +-------- +>>> from tmuxp.cli.search import SearchToken, normalize_fields + +Parse field aliases to canonical names: + +>>> normalize_fields(["s", "name"]) +('session_name', 'name') + +Create search tokens from query terms: + +>>> from tmuxp.cli.search import parse_query_terms, DEFAULT_FIELDS +>>> tokens = parse_query_terms(["name:dev", "editor"], default_fields=DEFAULT_FIELDS) +>>> tokens[0] +SearchToken(fields=('name',), pattern='dev') +>>> tokens[1] +SearchToken(fields=('name', 'session_name', 'path', 'window', 'pane'), pattern='editor') +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import typing as t + +import yaml + +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS +from tmuxp.workspace.finders import find_local_workspace_files, get_workspace_dir + +from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, get_output_mode + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + +#: Field name aliases for search queries +FIELD_ALIASES: dict[str, str] = { + "name": "name", + "n": "name", + "session": "session_name", + "session_name": "session_name", + "s": "session_name", + "path": "path", + "p": "path", + "window": "window", + "w": "window", + "pane": "pane", +} + +#: Valid field names after alias resolution +VALID_FIELDS: frozenset[str] = frozenset( + {"name", "session_name", "path", "window", "pane"} +) + +#: Default fields to search when no field prefix is specified +DEFAULT_FIELDS: tuple[str, ...] = ("name", "session_name", "path", "window", "pane") + + +class SearchToken(t.NamedTuple): + """Parsed search token with target fields and raw pattern. + + Attributes + ---------- + fields : tuple[str, ...] + Canonical field names to search (e.g., ('name', 'session_name')). + pattern : str + Raw search pattern before regex compilation. + + Examples + -------- + >>> token = SearchToken(fields=("name",), pattern="dev") + >>> token.fields + ('name',) + >>> token.pattern + 'dev' + """ + + fields: tuple[str, ...] + pattern: str + + +class SearchPattern(t.NamedTuple): + """Compiled search pattern with regex and metadata. + + Attributes + ---------- + fields : tuple[str, ...] + Canonical field names to search. + raw : str + Original pattern string before compilation. + regex : re.Pattern[str] + Compiled regex pattern for matching. + + Examples + -------- + >>> import re + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> pattern.fields + ('name',) + >>> bool(pattern.regex.search("development")) + True + """ + + fields: tuple[str, ...] + raw: str + regex: re.Pattern[str] + + +class InvalidFieldError(ValueError): + """Raised when an invalid field name is specified. + + Examples + -------- + >>> raise InvalidFieldError("invalid") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... + """ + + def __init__(self, field: str) -> None: + valid = ", ".join(sorted(FIELD_ALIASES.keys())) + super().__init__(f"Unknown search field: '{field}'. Valid fields: {valid}") + self.field = field + + +def normalize_fields(fields: list[str] | None) -> tuple[str, ...]: + """Normalize field names using aliases. + + Parameters + ---------- + fields : list[str] | None + Field names or aliases to normalize. If None, returns DEFAULT_FIELDS. + + Returns + ------- + tuple[str, ...] + Tuple of canonical field names. + + Raises + ------ + InvalidFieldError + If a field name is not recognized. + + Examples + -------- + >>> normalize_fields(None) + ('name', 'session_name', 'path', 'window', 'pane') + + >>> normalize_fields(["s", "n"]) + ('session_name', 'name') + + >>> normalize_fields(["session_name", "path"]) + ('session_name', 'path') + + >>> normalize_fields(["invalid"]) # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... + """ + if fields is None: + return DEFAULT_FIELDS + + result: list[str] = [] + for field in fields: + field_lower = field.lower() + if field_lower not in FIELD_ALIASES: + raise InvalidFieldError(field) + canonical = FIELD_ALIASES[field_lower] + if canonical not in result: + result.append(canonical) + + return tuple(result) + + +def _parse_field_prefix(term: str) -> tuple[str | None, str]: + """Extract field prefix from a search term. + + Parameters + ---------- + term : str + Search term, possibly with field prefix (e.g., "name:dev"). + + Returns + ------- + tuple[str | None, str] + Tuple of (field_prefix, pattern). field_prefix is None if no prefix. + + Examples + -------- + >>> _parse_field_prefix("name:dev") + ('name', 'dev') + + >>> _parse_field_prefix("s:myproject") + ('s', 'myproject') + + >>> _parse_field_prefix("development") + (None, 'development') + + >>> _parse_field_prefix("path:/home/user") + ('path', '/home/user') + + >>> _parse_field_prefix("window:") + ('window', '') + """ + if ":" not in term: + return None, term + + # Split on first colon only + prefix, _, pattern = term.partition(":") + prefix_lower = prefix.lower() + + # Check if prefix is a valid field alias + if prefix_lower in FIELD_ALIASES: + return prefix, pattern + + # Not a valid field prefix, treat entire term as pattern + return None, term + + +def parse_query_terms( + terms: list[str], + *, + default_fields: tuple[str, ...] = DEFAULT_FIELDS, +) -> list[SearchToken]: + """Parse query terms into search tokens. + + Each term can optionally have a field prefix (e.g., "name:dev"). + Terms without prefixes search the default fields. + + Parameters + ---------- + terms : list[str] + Query terms to parse. + default_fields : tuple[str, ...] + Fields to search when no prefix is specified. + + Returns + ------- + list[SearchToken] + List of parsed search tokens. + + Raises + ------ + InvalidFieldError + If a field prefix is not recognized. + + Examples + -------- + >>> tokens = parse_query_terms(["dev"]) + >>> tokens[0].fields + ('name', 'session_name', 'path', 'window', 'pane') + >>> tokens[0].pattern + 'dev' + + >>> tokens = parse_query_terms(["name:dev", "s:prod"]) + >>> tokens[0] + SearchToken(fields=('name',), pattern='dev') + >>> tokens[1] + SearchToken(fields=('session_name',), pattern='prod') + + >>> tokens = parse_query_terms(["window:editor", "shell"]) + >>> tokens[0].fields + ('window',) + >>> tokens[1].fields + ('name', 'session_name', 'path', 'window', 'pane') + + Unknown prefixes are treated as literal patterns (allows URLs, etc.): + + >>> tokens = parse_query_terms(["http://example.com"]) + >>> tokens[0].pattern + 'http://example.com' + >>> tokens[0].fields # Searches default fields + ('name', 'session_name', 'path', 'window', 'pane') + """ + result: list[SearchToken] = [] + + for term in terms: + if not term: + continue + + prefix, pattern = _parse_field_prefix(term) + + # Validate and resolve field prefix, or use defaults + fields = normalize_fields([prefix]) if prefix is not None else default_fields + + if pattern: # Skip empty patterns + result.append(SearchToken(fields=fields, pattern=pattern)) + + return result + + +def _has_uppercase(pattern: str) -> bool: + """Check if pattern contains uppercase letters. + + Used for smart-case detection. + + Parameters + ---------- + pattern : str + Pattern to check. + + Returns + ------- + bool + True if pattern contains at least one uppercase letter. + + Examples + -------- + >>> _has_uppercase("dev") + False + + >>> _has_uppercase("Dev") + True + + >>> _has_uppercase("DEV") + True + + >>> _has_uppercase("123") + False + + >>> _has_uppercase("") + False + """ + return any(c.isupper() for c in pattern) + + +def compile_search_patterns( + tokens: list[SearchToken], + *, + ignore_case: bool = False, + smart_case: bool = False, + fixed_strings: bool = False, + word_regexp: bool = False, +) -> list[SearchPattern]: + """Compile search tokens into regex patterns. + + Parameters + ---------- + tokens : list[SearchToken] + Parsed search tokens to compile. + ignore_case : bool + If True, always ignore case. Default False. + smart_case : bool + If True, ignore case unless pattern has uppercase. Default False. + fixed_strings : bool + If True, treat patterns as literal strings, not regex. Default False. + word_regexp : bool + If True, match whole words only. Default False. + + Returns + ------- + list[SearchPattern] + List of compiled search patterns. + + Raises + ------ + re.error + If a pattern is invalid regex (when fixed_strings=False). + + Examples + -------- + Basic compilation: + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens) + >>> patterns[0].raw + 'dev' + >>> bool(patterns[0].regex.search("development")) + True + + Case-insensitive matching: + + >>> tokens = [SearchToken(fields=("name",), pattern="DEV")] + >>> patterns = compile_search_patterns(tokens, ignore_case=True) + >>> bool(patterns[0].regex.search("development")) + True + + Smart-case (uppercase = case-sensitive): + + >>> tokens = [SearchToken(fields=("name",), pattern="Dev")] + >>> patterns = compile_search_patterns(tokens, smart_case=True) + >>> bool(patterns[0].regex.search("Developer")) + True + >>> bool(patterns[0].regex.search("developer")) + False + + Smart-case (lowercase = case-insensitive): + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens, smart_case=True) + >>> bool(patterns[0].regex.search("DEVELOPMENT")) + True + + Fixed strings (escape regex metacharacters): + + >>> tokens = [SearchToken(fields=("name",), pattern="dev.*")] + >>> patterns = compile_search_patterns(tokens, fixed_strings=True) + >>> bool(patterns[0].regex.search("dev.*project")) + True + >>> bool(patterns[0].regex.search("development")) + False + + Word boundaries: + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens, word_regexp=True) + >>> bool(patterns[0].regex.search("my dev project")) + True + >>> bool(patterns[0].regex.search("development")) + False + """ + result: list[SearchPattern] = [] + + for token in tokens: + pattern_str = token.pattern + + # Escape for literal matching if requested + if fixed_strings: + pattern_str = re.escape(pattern_str) + + # Add word boundaries if requested + if word_regexp: + pattern_str = rf"\b{pattern_str}\b" + + # Determine case sensitivity + flags = 0 + if ignore_case or (smart_case and not _has_uppercase(token.pattern)): + flags |= re.IGNORECASE + + compiled = re.compile(pattern_str, flags) + result.append( + SearchPattern( + fields=token.fields, + raw=token.pattern, + regex=compiled, + ) + ) + + return result + + +class WorkspaceFields(t.TypedDict): + """Extracted searchable fields from a workspace file. + + Attributes + ---------- + name : str + Workspace name (file stem without extension). + path : str + Path to workspace file (with ~ contraction). + session_name : str + Session name from config, or empty string if not found. + windows : list[str] + List of window names from config. + panes : list[str] + List of pane commands/shell_commands from config. + + Examples + -------- + >>> fields: WorkspaceFields = { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim", "git status"], + ... } + >>> fields["name"] + 'dev' + """ + + name: str + path: str + session_name: str + windows: list[str] + panes: list[str] + + +class WorkspaceSearchResult(t.TypedDict): + """Search result for a workspace that matched. + + Attributes + ---------- + filepath : str + Absolute path to the workspace file. + source : str + Source location: "local" or "global". + fields : WorkspaceFields + Extracted searchable fields. + matches : dict[str, list[str]] + Mapping of field name to matched strings for highlighting. + + Examples + -------- + >>> result: WorkspaceSearchResult = { + ... "filepath": "/home/user/.tmuxp/dev.yaml", + ... "source": "global", + ... "fields": { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor"], + ... "panes": [], + ... }, + ... "matches": {"name": ["dev"]}, + ... } + >>> result["source"] + 'global' + """ + + filepath: str + source: str + fields: WorkspaceFields + matches: dict[str, list[str]] + + +def extract_workspace_fields(filepath: pathlib.Path) -> WorkspaceFields: + """Extract searchable fields from a workspace file. + + Parses the workspace configuration and extracts name, path, session_name, + window names, and pane commands for searching. + + Parameters + ---------- + filepath : pathlib.Path + Path to the workspace file. + + Returns + ------- + WorkspaceFields + Dictionary of extracted fields. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> content = ''' + ... session_name: my-project + ... windows: + ... - window_name: editor + ... panes: + ... - vim + ... - shell_command: git status + ... - window_name: shell + ... ''' + >>> with tempfile.NamedTemporaryFile( + ... suffix='.yaml', delete=False, mode='w' + ... ) as f: + ... _ = f.write(content) + ... temp_path = pathlib.Path(f.name) + >>> fields = extract_workspace_fields(temp_path) + >>> fields["session_name"] + 'my-project' + >>> sorted(fields["windows"]) + ['editor', 'shell'] + >>> 'vim' in fields["panes"] + True + >>> temp_path.unlink() + """ + # Basic fields from file + name = filepath.stem + path = str(PrivatePath(filepath)) + + # Try to parse config for session_name, windows, panes + session_name = "" + windows: list[str] = [] + panes: list[str] = [] + + try: + config = ConfigReader.from_file(filepath) + if isinstance(config.content, dict): + session_name = str(config.content.get("session_name", "")) + + # Extract window names and pane commands + for window in config.content.get("windows", []): + if not isinstance(window, dict): + continue + + # Window name + if window_name := window.get("window_name"): + windows.append(str(window_name)) + + # Pane commands + for pane in window.get("panes", []): + if isinstance(pane, str): + panes.append(pane) + elif isinstance(pane, dict): + # shell_command can be str or list + cmds = pane.get("shell_command", []) + if isinstance(cmds, str): + panes.append(cmds) + elif isinstance(cmds, list): + panes.extend(str(cmd) for cmd in cmds if cmd) + except (yaml.YAMLError, json.JSONDecodeError, OSError): + # If config parsing fails, continue with empty content fields + pass + + return WorkspaceFields( + name=name, + path=path, + session_name=session_name, + windows=windows, + panes=panes, + ) + + +def _get_field_values(fields: WorkspaceFields, field_name: str) -> list[str]: + """Get values for a field, normalizing to list. + + Parameters + ---------- + fields : WorkspaceFields + Extracted workspace fields. + field_name : str + Canonical field name to retrieve. + + Returns + ------- + list[str] + List of values for the field. + + Examples + -------- + >>> fields: WorkspaceFields = { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim"], + ... } + >>> _get_field_values(fields, "name") + ['dev'] + >>> _get_field_values(fields, "windows") + ['editor', 'shell'] + >>> _get_field_values(fields, "window") + ['editor', 'shell'] + """ + # Handle field name aliasing (window -> windows, pane -> panes) + if field_name == "window": + field_name = "windows" + elif field_name == "pane": + field_name = "panes" + + # Access fields directly for type safety + if field_name == "name": + return [fields["name"]] if fields["name"] else [] + if field_name == "path": + return [fields["path"]] if fields["path"] else [] + if field_name == "session_name": + return [fields["session_name"]] if fields["session_name"] else [] + if field_name == "windows": + return fields["windows"] + if field_name == "panes": + return fields["panes"] + + return [] + + +def evaluate_match( + fields: WorkspaceFields, + patterns: list[SearchPattern], + *, + match_any: bool = False, +) -> tuple[bool, dict[str, list[str]]]: + """Evaluate if workspace fields match search patterns. + + Parameters + ---------- + fields : WorkspaceFields + Extracted workspace fields to search. + patterns : list[SearchPattern] + Compiled search patterns. + match_any : bool + If True, match if ANY pattern matches (OR logic). + If False, ALL patterns must match (AND logic). Default False. + + Returns + ------- + tuple[bool, dict[str, list[str]]] + Tuple of (matched, {field_name: [matched_strings]}). + The matches dict contains actual matched text for highlighting. + + Examples + -------- + >>> import re + >>> fields: WorkspaceFields = { + ... "name": "dev-project", + ... "path": "~/.tmuxp/dev-project.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim", "git status"], + ... } + + Single pattern match: + + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> matched, matches = evaluate_match(fields, [pattern]) + >>> matched + True + >>> "name" in matches + True + + AND logic (default) - all patterns must match: + + >>> p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + >>> p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + >>> matched, _ = evaluate_match(fields, [p1, p2], match_any=False) + >>> matched + False + + OR logic - any pattern can match: + + >>> matched, _ = evaluate_match(fields, [p1, p2], match_any=True) + >>> matched + True + + Window field search: + + >>> p_win = SearchPattern( + ... fields=("window",), + ... raw="editor", + ... regex=re.compile("editor"), + ... ) + >>> matched, matches = evaluate_match(fields, [p_win]) + >>> matched + True + >>> "window" in matches + True + """ + all_matches: dict[str, list[str]] = {} + pattern_results: list[bool] = [] + + for pattern in patterns: + pattern_matched = False + + for field_name in pattern.fields: + values = _get_field_values(fields, field_name) + + for value in values: + if match := pattern.regex.search(value): + pattern_matched = True + # Store matched text for highlighting + if field_name not in all_matches: + all_matches[field_name] = [] + all_matches[field_name].append(match.group()) + + pattern_results.append(pattern_matched) + + # Apply match logic + if match_any: + final_matched = any(pattern_results) + else: + final_matched = all(pattern_results) if pattern_results else False + + return final_matched, all_matches + + +def find_search_matches( + workspaces: list[tuple[pathlib.Path, str]], + patterns: list[SearchPattern], + *, + match_any: bool = False, + invert_match: bool = False, +) -> list[WorkspaceSearchResult]: + """Find workspaces matching search patterns. + + Parameters + ---------- + workspaces : list[tuple[pathlib.Path, str]] + List of (filepath, source) tuples to search. Source is "local" or "global". + patterns : list[SearchPattern] + Compiled search patterns. + match_any : bool + If True, match if ANY pattern matches (OR logic). Default False (AND). + invert_match : bool + If True, return workspaces that do NOT match. Default False. + + Returns + ------- + list[WorkspaceSearchResult] + List of matching workspace results with match information. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> import re + >>> content = "session_name: dev-session" + chr(10) + "windows: []" + >>> with tempfile.NamedTemporaryFile( + ... suffix='.yaml', delete=False, mode='w' + ... ) as f: + ... _ = f.write(content) + ... temp_path = pathlib.Path(f.name) + + >>> pattern = SearchPattern( + ... fields=("session_name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> results = find_search_matches([(temp_path, "global")], [pattern]) + >>> len(results) + 1 + >>> results[0]["source"] + 'global' + + Invert match returns non-matching workspaces: + + >>> pattern_nomatch = SearchPattern( + ... fields=("name",), + ... raw="nonexistent", + ... regex=re.compile("nonexistent"), + ... ) + >>> results = find_search_matches( + ... [(temp_path, "global")], [pattern_nomatch], invert_match=True + ... ) + >>> len(results) + 1 + >>> temp_path.unlink() + """ + results: list[WorkspaceSearchResult] = [] + + for filepath, source in workspaces: + fields = extract_workspace_fields(filepath) + matched, matches = evaluate_match(fields, patterns, match_any=match_any) + + # Apply invert logic + if invert_match: + matched = not matched + + if matched: + results.append( + WorkspaceSearchResult( + filepath=str(filepath), + source=source, + fields=fields, + matches=matches, + ) + ) + + return results + + +def highlight_matches( + text: str, + patterns: list[SearchPattern], + *, + colors: Colors, +) -> str: + """Highlight regex matches in text. + + Parameters + ---------- + text : str + Text to search and highlight. + patterns : list[SearchPattern] + Compiled search patterns (uses their regex attribute). + colors : Colors + Color manager for highlighting. + + Returns + ------- + str + Text with matches highlighted, or original text if no matches. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> highlight_matches("development", [pattern], colors=colors) + 'development' + + With colors enabled (ALWAYS mode): + + >>> colors_on = Colors(ColorMode.ALWAYS) + >>> result = highlight_matches("development", [pattern], colors=colors_on) + >>> "dev" in result + True + >>> chr(27) in result # Contains ANSI escape + True + """ + if not patterns: + return text + + # Collect all match spans + spans: list[tuple[int, int]] = [] + for pattern in patterns: + spans.extend((m.start(), m.end()) for m in pattern.regex.finditer(text)) + + if not spans: + return text + + # Sort and merge overlapping spans + spans.sort() + merged: list[tuple[int, int]] = [] + for start, end in spans: + if merged and start <= merged[-1][1]: + # Overlapping or adjacent, extend previous + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + + # Build result with highlights + result: list[str] = [] + pos = 0 + for start, end in merged: + # Add non-matching text before this match + if pos < start: + result.append(text[pos:start]) + # Add highlighted match + result.append(colors.highlight(text[start:end])) + pos = end + + # Add any remaining text after last match + if pos < len(text): + result.append(text[pos:]) + + return "".join(result) + + +def _output_search_results( + results: list[WorkspaceSearchResult], + patterns: list[SearchPattern], + formatter: OutputFormatter, + colors: Colors, +) -> None: + """Output search results in human-readable or JSON format. + + Parameters + ---------- + results : list[WorkspaceSearchResult] + Search results to output. + patterns : list[SearchPattern] + Patterns used for highlighting. + formatter : OutputFormatter + Output formatter for JSON/NDJSON/human modes. + colors : Colors + Color manager. + """ + if not results: + formatter.emit_text(colors.warning("No matching workspaces found.")) + return + + # Group by source for human output + local_results = [r for r in results if r["source"] == "local"] + global_results = [r for r in results if r["source"] == "global"] + + def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: + """Output a single search result.""" + fields = result["fields"] + + # JSON/NDJSON output: emit structured data + json_data = { + "name": fields["name"], + "path": fields["path"], + "session_name": fields["session_name"], + "source": result["source"], + "matched_fields": list(result["matches"].keys()), + "matches": result["matches"], + } + formatter.emit(json_data) + + # Human output: formatted text with highlighting + name_display = highlight_matches(fields["name"], patterns, colors=colors) + path_info = f" {colors.info(fields['path'])}" if show_path else "" + formatter.emit_text(f" {colors.highlight(name_display)}{path_info}") + + # Show matched session_name if different from name + session_name = fields["session_name"] + if session_name and session_name != fields["name"]: + session_display = highlight_matches(session_name, patterns, colors=colors) + formatter.emit_text(f" session: {session_display}") + + # Show matched windows + if result["matches"].get("window"): + window_names = [ + highlight_matches(w, patterns, colors=colors) for w in fields["windows"] + ] + if window_names: + formatter.emit_text(f" windows: {', '.join(window_names)}") + + # Show matched panes + if result["matches"].get("pane"): + pane_cmds = fields["panes"][:3] # Limit to first 3 + pane_displays = [ + highlight_matches(p, patterns, colors=colors) for p in pane_cmds + ] + if len(fields["panes"]) > 3: + pane_displays.append("...") + if pane_displays: + formatter.emit_text(f" panes: {', '.join(pane_displays)}") + + # Output local results first + if local_results: + formatter.emit_text(colors.heading("Local workspaces:")) + for result in local_results: + output_result(result, show_path=True) + + # Output global results + if global_results: + if local_results: + formatter.emit_text("") # Blank line separator + formatter.emit_text(colors.heading("Global workspaces:")) + for result in global_results: + output_result(result, show_path=False) + + +SEARCH_DESCRIPTION = build_description( + """ + Search workspace files by name, session, path, window, or pane content. + """, + ( + ( + None, + [ + "tmuxp search dev", + 'tmuxp search "my.*project"', + "tmuxp search name:dev", + "tmuxp search s:development", + ], + ), + ( + "Field-scoped search", + [ + "tmuxp search window:editor", + "tmuxp search pane:vim", + "tmuxp search p:~/.tmuxp", + ], + ), + ( + "Matching options", + [ + "tmuxp search -i DEV", + "tmuxp search -S DevProject", + "tmuxp search -F 'my.project'", + "tmuxp search --word-regexp test", + ], + ), + ( + "Multiple patterns", + [ + "tmuxp search dev production", + "tmuxp search --any dev production", + "tmuxp search -v staging", + ], + ), + ( + "Machine-readable output", + [ + "tmuxp search --json dev", + "tmuxp search --ndjson dev | jq '.name'", + ], + ), + ), +) + + +class CLISearchNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp search command. + + Examples + -------- + >>> ns = CLISearchNamespace() + >>> ns.query_terms = ["dev"] + >>> ns.query_terms + ['dev'] + """ + + color: CLIColorModeLiteral + query_terms: list[str] + field: list[str] | None + ignore_case: bool + smart_case: bool + fixed_strings: bool + word_regexp: bool + invert_match: bool + match_any: bool + output_json: bool + output_ndjson: bool + print_help: t.Callable[[], None] + + +def create_search_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``search`` subcommand. + + Parameters + ---------- + parser : argparse.ArgumentParser + The parser to augment. + + Returns + ------- + argparse.ArgumentParser + The augmented parser. + + Examples + -------- + >>> import argparse + >>> parser = argparse.ArgumentParser() + >>> result = create_search_subparser(parser) + >>> result is parser + True + """ + # Positional arguments + parser.add_argument( + "query_terms", + nargs="*", + metavar="PATTERN", + help="search patterns (prefix with field: for field-scoped search)", + ) + + # Field restriction + parser.add_argument( + "-f", + "--field", + action="append", + metavar="FIELD", + help="restrict search to field(s): name, session/s, path/p, window/w, pane", + ) + + # Matching options + parser.add_argument( + "-i", + "--ignore-case", + action="store_true", + help="case-insensitive matching", + ) + parser.add_argument( + "-S", + "--smart-case", + action="store_true", + help="case-insensitive unless pattern has uppercase", + ) + parser.add_argument( + "-F", + "--fixed-strings", + action="store_true", + help="treat patterns as literal strings, not regex", + ) + parser.add_argument( + "-w", + "--word-regexp", + action="store_true", + help="match whole words only", + ) + parser.add_argument( + "-v", + "--invert-match", + action="store_true", + help="show workspaces that do NOT match", + ) + parser.add_argument( + "--any", + dest="match_any", + action="store_true", + help="match ANY pattern (OR logic); default is ALL (AND logic)", + ) + + # Output format + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) + parser.add_argument( + "--ndjson", + action="store_true", + dest="output_ndjson", + help="output as NDJSON (one JSON per line)", + ) + + # Store print_help for use when no arguments provided + parser.set_defaults(print_help=parser.print_help) + + return parser + + +def command_search( + args: CLISearchNamespace | None = None, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp search`` subcommand. + + Searches workspace files in local (cwd and parents) and global (~/.tmuxp/) + directories. + + Parameters + ---------- + args : CLISearchNamespace | None + Parsed command-line arguments. + parser : argparse.ArgumentParser | None + The argument parser (unused but required by CLI interface). + + Examples + -------- + >>> # command_search() searches workspaces with given patterns + """ + # Get color mode from args or default to AUTO + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Determine output mode + output_json = args.output_json if args else False + output_ndjson = args.output_ndjson if args else False + output_mode = get_output_mode(output_json, output_ndjson) + formatter = OutputFormatter(output_mode) + + # Get query terms + query_terms = args.query_terms if args else [] + + if not query_terms: + if args and hasattr(args, "print_help"): + args.print_help() + return + + # Parse and compile patterns + try: + # Get default fields (possibly restricted by --field) + default_fields = normalize_fields(args.field if args else None) + tokens = parse_query_terms(query_terms, default_fields=default_fields) + + if not tokens: + formatter.emit_text(colors.warning("No valid search patterns.")) + formatter.finalize() + return + + patterns = compile_search_patterns( + tokens, + ignore_case=args.ignore_case if args else False, + smart_case=args.smart_case if args else False, + fixed_strings=args.fixed_strings if args else False, + word_regexp=args.word_regexp if args else False, + ) + except InvalidFieldError as e: + formatter.emit_text(colors.error(str(e))) + formatter.finalize() + return + except re.error as e: + formatter.emit_text(colors.error(f"Invalid regex pattern: {e}")) + formatter.finalize() + return + + # Collect workspaces: local (cwd + parents) + global (~/.tmuxp/) + workspaces: list[tuple[pathlib.Path, str]] = [] + + # Local workspace files + local_files = find_local_workspace_files() + workspaces.extend((f, "local") for f in local_files) + + # Global workspace files + tmuxp_dir = pathlib.Path(get_workspace_dir()) + if tmuxp_dir.exists() and tmuxp_dir.is_dir(): + workspaces.extend( + (f, "global") + for f in sorted(tmuxp_dir.iterdir()) + if not f.is_dir() + and f.suffix.lower() in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ) + + if not workspaces: + formatter.emit_text(colors.warning("No workspaces found.")) + formatter.finalize() + return + + # Find matches + results = find_search_matches( + workspaces, + patterns, + match_any=args.match_any if args else False, + invert_match=args.invert_match if args else False, + ) + + # Output results + _output_search_results(results, patterns, formatter, colors) + formatter.finalize() diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index 459e5350dc..e62f0a0758 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -12,9 +12,29 @@ from tmuxp import util from tmuxp._compat import PY3, PYMINOR +from ._colors import Colors, build_description, get_color_mode + +SHELL_DESCRIPTION = build_description( + """ + Launch interactive Python shell with tmux server, session, window and pane. + """, + ( + ( + None, + [ + "tmuxp shell", + "tmuxp shell -L mysocket", + "tmuxp shell -c 'print(server.sessions)'", + "tmuxp shell --best", + ], + ), + ), +) + if t.TYPE_CHECKING: from typing import TypeAlias + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] CLIColorsLiteral: TypeAlias = t.Literal[56, 88] CLIShellLiteral: TypeAlias = t.Literal[ "best", @@ -30,6 +50,7 @@ class CLIShellNamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp shell command.""" + color: CLIColorModeLiteral session_name: str socket_name: str | None socket_path: str | None @@ -160,6 +181,9 @@ def command_shell( - :attr:`libtmux.Server.attached_sessions`, :attr:`libtmux.Session.active_window`, :attr:`libtmux.Window.active_pane` """ + color_mode = get_color_mode(args.color) + cli_colors = Colors(color_mode) + # If inside a server, detect socket_path env_tmux = os.getenv("TMUX") if env_tmux is not None and isinstance(env_tmux, str): @@ -198,11 +222,25 @@ def command_shell( ): from tmuxp._compat import breakpoint as tmuxp_breakpoint + print( # NOQA: T201 RUF100 + cli_colors.muted("Launching ") + + cli_colors.highlight("pdb", bold=False) + + cli_colors.muted(" shell..."), + ) tmuxp_breakpoint() return else: from tmuxp.shell import launch + shell_name = args.shell or "best" + print( # NOQA: T201 RUF100 + cli_colors.muted("Launching ") + + cli_colors.highlight(shell_name, bold=False) + + cli_colors.muted(" shell for session ") + + cli_colors.info(session.name or "") + + cli_colors.muted("..."), + ) + launch( shell=args.shell, use_pythonrc=args.use_pythonrc, # shell: code diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 68de22f7c0..58896deb0f 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -2,43 +2,44 @@ from __future__ import annotations -import logging -import re import typing as t -from tmuxp import log +from tmuxp._internal.colors import ( + ColorMode, + Colors, + UnknownStyleColor, + strip_ansi, + style, + unstyle, +) +from tmuxp._internal.private_path import PrivatePath +from tmuxp.log import tmuxp_echo if t.TYPE_CHECKING: from collections.abc import Callable, Sequence - from typing import TypeAlias - CLIColour: TypeAlias = int | tuple[int, int, int] | str - - -logger = logging.getLogger(__name__) - - -def tmuxp_echo( - message: str | None = None, - log_level: str = "INFO", - style_log: bool = False, -) -> None: - """Combine logging.log and click.echo.""" - if message is None: - return - - if style_log: - logger.log(log.LOG_LEVELS[log_level], message) - else: - logger.log(log.LOG_LEVELS[log_level], unstyle(message)) - - print(message) # NOQA: T201 RUF100 +# Re-export for backward compatibility +__all__ = [ + "ColorMode", + "Colors", + "UnknownStyleColor", + "prompt", + "prompt_bool", + "prompt_choices", + "prompt_yes_no", + "strip_ansi", + "style", + "tmuxp_echo", + "unstyle", +] def prompt( name: str, default: str | None = None, value_proc: Callable[[str], str] | None = None, + *, + color_mode: ColorMode | None = None, ) -> str: """Return user input from command line. @@ -48,6 +49,8 @@ def prompt( prompt text default : default value if no input provided. + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. Returns ------- @@ -59,21 +62,32 @@ def prompt( `flask-script `_. See the `flask-script license `_. """ - prompt_ = name + ((default and f" [{default}]") or "") + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) + # Use PrivatePath to mask home directory in displayed default + display_default = str(PrivatePath(default)) if default else None + prompt_ = name + ( + (display_default and " " + colors.info(f"[{display_default}]")) or "" + ) prompt_ += (name.endswith("?") and " ") or ": " while True: rv = input(prompt_) or default - try: - if value_proc is not None and callable(value_proc): - assert isinstance(rv, str) + # Validate with value_proc only if we have a string value + if rv is not None and value_proc is not None and callable(value_proc): + try: value_proc(rv) - except ValueError as e: - return prompt(str(e), default=default, value_proc=value_proc) + except ValueError as e: + return prompt( + str(e), + default=default, + value_proc=value_proc, + color_mode=color_mode, + ) if rv: return rv if default is not None: return default + # No input and no default - loop to re-prompt def prompt_bool( @@ -81,6 +95,8 @@ def prompt_bool( default: bool = False, yes_choices: Sequence[t.Any] | None = None, no_choices: Sequence[t.Any] | None = None, + *, + color_mode: ColorMode | None = None, ) -> bool: """Return True / False by prompting user input from command line. @@ -94,11 +110,14 @@ def prompt_bool( default 'y', 'yes', '1', 'on', 'true', 't' no_choices : default 'n', 'no', '0', 'off', 'false', 'f' + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. Returns ------- bool """ + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) yes_choices = yes_choices or ("y", "yes", "1", "on", "true", "t") no_choices = no_choices or ("n", "no", "0", "off", "false", "f") @@ -109,7 +128,7 @@ def prompt_bool( else: prompt_choice = "y/N" - prompt_ = name + f" [{prompt_choice}]" + prompt_ = name + " " + colors.muted(f"[{prompt_choice}]") prompt_ += (name.endswith("?") and " ") or ": " while True: @@ -122,16 +141,33 @@ def prompt_bool( return False -def prompt_yes_no(name: str, default: bool = True) -> bool: - """:meth:`prompt_bool()` returning yes by default.""" - return prompt_bool(name, default=default) +def prompt_yes_no( + name: str, + default: bool = True, + *, + color_mode: ColorMode | None = None, +) -> bool: + """:meth:`prompt_bool()` returning yes by default. + + Parameters + ---------- + name : + prompt text + default : + default value if no input provided. + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. + """ + return prompt_bool(name, default=default, color_mode=color_mode) def prompt_choices( name: str, - choices: list[str] | tuple[str, str], + choices: Sequence[str | tuple[str, str]], default: str | None = None, no_choice: Sequence[str] = ("none",), + *, + color_mode: ColorMode | None = None, ) -> str | None: """Return user input from command line from set of provided choices. @@ -140,17 +176,20 @@ def prompt_choices( name : prompt text choices : - list or tuple of available choices. Choices may be single strings or - (key, value) tuples. + Sequence of available choices. Each choice may be a single string or + a (key, value) tuple where key is used for matching. default : default value if no input provided. no_choice : acceptable list of strings for "null choice" + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. Returns ------- str """ + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) choices_: list[str] = [] options: list[str] = [] @@ -162,8 +201,13 @@ def prompt_choices( choice = choice[0] choices_.append(choice) + choices_str = colors.muted(f"({', '.join(options)})") + default_str = " " + colors.info(f"[{default}]") if default else "" + prompt_text = f"{name} - {choices_str}{default_str}" + while True: - rv = prompt(name + " - ({})".format(", ".join(options)), default=default) + prompt_ = prompt_text + ": " + rv = input(prompt_) or default if not rv or rv == default: return default rv = rv.lower() @@ -171,121 +215,7 @@ def prompt_choices( return None if rv in choices_: return rv - - -_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") - - -def strip_ansi(value: str) -> str: - """Clear ANSI from a string value.""" - return _ansi_re.sub("", value) - - -_ansi_colors = { - "black": 30, - "red": 31, - "green": 32, - "yellow": 33, - "blue": 34, - "magenta": 35, - "cyan": 36, - "white": 37, - "reset": 39, - "bright_black": 90, - "bright_red": 91, - "bright_green": 92, - "bright_yellow": 93, - "bright_blue": 94, - "bright_magenta": 95, - "bright_cyan": 96, - "bright_white": 97, -} -_ansi_reset_all = "\033[0m" - - -def _interpret_color( - color: int | tuple[int, int, int] | str, - offset: int = 0, -) -> str: - if isinstance(color, int): - return f"{38 + offset};5;{color:d}" - - if isinstance(color, (tuple, list)): - r, g, b = color - return f"{38 + offset};2;{r:d};{g:d};{b:d}" - - return str(_ansi_colors[color] + offset) - - -class UnknownStyleColor(Exception): - """Raised when encountering an unknown terminal style color.""" - - def __init__(self, color: CLIColour, *args: object, **kwargs: object) -> None: - return super().__init__(f"Unknown color {color!r}", *args, **kwargs) - - -def style( - text: t.Any, - fg: CLIColour | None = None, - bg: CLIColour | None = None, - bold: bool | None = None, - dim: bool | None = None, - underline: bool | None = None, - overline: bool | None = None, - italic: bool | None = None, - blink: bool | None = None, - reverse: bool | None = None, - strikethrough: bool | None = None, - reset: bool = True, -) -> str: - """Credit: click.""" - if not isinstance(text, str): - text = str(text) - - bits = [] - - if fg: - try: - bits.append(f"\033[{_interpret_color(fg)}m") - except KeyError: - raise UnknownStyleColor(color=fg) from None - - if bg: - try: - bits.append(f"\033[{_interpret_color(bg, 10)}m") - except KeyError: - raise UnknownStyleColor(color=bg) from None - - if bold is not None: - bits.append(f"\033[{1 if bold else 22}m") - if dim is not None: - bits.append(f"\033[{2 if dim else 22}m") - if underline is not None: - bits.append(f"\033[{4 if underline else 24}m") - if overline is not None: - bits.append(f"\033[{53 if overline else 55}m") - if italic is not None: - bits.append(f"\033[{3 if italic else 23}m") - if blink is not None: - bits.append(f"\033[{5 if blink else 25}m") - if reverse is not None: - bits.append(f"\033[{7 if reverse else 27}m") - if strikethrough is not None: - bits.append(f"\033[{9 if strikethrough else 29}m") - bits.append(text) - if reset: - bits.append(_ansi_reset_all) - return "".join(bits) - - -def unstyle(text: str) -> str: - """Remove ANSI styling information from a string. - - Usually it's not necessary to use this function as tmuxp_echo function will - automatically remove styling if necessary. - - Credit: click. - - text : the text to remove style information from. - """ - return strip_ansi(text) + print( + colors.warning(f"Invalid choice '{rv}'. ") + + f"Please choose from: {', '.join(choices_)}" + ) diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index 6e53ab05b5..e4429eda6a 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -9,6 +9,8 @@ from colorama import Fore, Style +from tmuxp._internal.colors import unstyle + LEVEL_COLORS = { "DEBUG": Fore.BLUE, # Blue "INFO": Fore.GREEN, # Green @@ -200,3 +202,44 @@ class DebugLogFormatter(LogFormatter): """Provides greater technical details than standard log Formatter.""" template = debug_log_template + + +# Use tmuxp root logger so messages propagate to CLI handlers +_echo_logger = logging.getLogger("tmuxp") + + +def tmuxp_echo( + message: str | None = None, + log_level: str = "INFO", + style_log: bool = False, +) -> None: + """Combine logging.log and print for 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. + + Examples + -------- + >>> tmuxp_echo("Session loaded") # doctest: +ELLIPSIS + Session loaded + + >>> tmuxp_echo("Warning message", log_level="WARNING") # doctest: +ELLIPSIS + 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) diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index f8229bc26a..da19bcc887 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -4,17 +4,20 @@ import logging import os +import pathlib import typing as t -from colorama import Fore - -from tmuxp.cli.utils import tmuxp_echo +from tmuxp._internal.colors import ColorMode, Colors +from tmuxp._internal.private_path import PrivatePath +from tmuxp.log import tmuxp_echo from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS logger = logging.getLogger(__name__) +#: Local workspace file names (dotfiles in project directories) +LOCAL_WORKSPACE_FILES = [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"] + if t.TYPE_CHECKING: - import pathlib from typing import TypeAlias from tmuxp.types import StrPath @@ -100,6 +103,69 @@ def in_cwd() -> list[str]: ] +def find_local_workspace_files( + start_dir: pathlib.Path | str | None = None, + *, + stop_at_home: bool = True, +) -> list[pathlib.Path]: + """Find .tmuxp.* files by traversing upward from start directory. + + Searches the start directory and all parent directories up to (but not past): + - User home directory (when stop_at_home=True) + - Filesystem root + + Parameters + ---------- + start_dir : pathlib.Path | str | None + Directory to start searching from. Defaults to current working directory. + stop_at_home : bool + If True, stops traversal at user home directory. Default True. + + Returns + ------- + list[pathlib.Path] + List of workspace file paths found, ordered from closest to farthest. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... home = pathlib.Path(tmpdir) + ... project = home / "project" + ... project.mkdir() + ... _ = (project / ".tmuxp.yaml").write_text("session_name: test") + ... # Would find .tmuxp.yaml in project dir + ... len(find_local_workspace_files(project, stop_at_home=False)) >= 0 + True + """ + if start_dir is None: + start_dir = os.getcwd() + + current = pathlib.Path(start_dir).resolve() + home = pathlib.Path.home().resolve() + found: list[pathlib.Path] = [] + + while True: + # Check for local workspace files in current directory + for filename in LOCAL_WORKSPACE_FILES: + candidate = current / filename + if candidate.is_file(): + found.append(candidate) + break # Only one per directory (first match wins: .yaml > .yml > .json) + + # Stop conditions + parent = current.parent + if parent == current: # Reached filesystem root + break + if stop_at_home and current == home: + break + + current = parent + + return found + + def get_workspace_dir() -> str: """ Return tmuxp workspace directory. @@ -131,6 +197,85 @@ def get_workspace_dir() -> str: return path +def get_workspace_dir_candidates() -> list[dict[str, t.Any]]: + """Return all candidate workspace directories with existence status. + + Returns a list of all directories that tmuxp checks for workspaces, + in priority order, with metadata about each. + + The priority order is: + 1. ``TMUXP_CONFIGDIR`` environment variable (if set) + 2. ``XDG_CONFIG_HOME/tmuxp`` (if XDG_CONFIG_HOME set) OR ``~/.config/tmuxp/`` + 3. ``~/.tmuxp`` (legacy default) + + Returns + ------- + list[dict[str, Any]] + List of dicts with: + - path: str (privacy-masked via PrivatePath) + - source: str (e.g., "$TMUXP_CONFIGDIR", "$XDG_CONFIG_HOME/tmuxp", "Legacy") + - exists: bool + - workspace_count: int (0 if not exists) + - active: bool (True if this is the directory get_workspace_dir() returns) + + Examples + -------- + >>> candidates = get_workspace_dir_candidates() + >>> isinstance(candidates, list) + True + >>> all('path' in c and 'exists' in c for c in candidates) + True + """ + # Build list of candidate paths with sources (same logic as get_workspace_dir) + # Each entry is (raw_path, source_label) + path_sources: list[tuple[str, str]] = [] + if "TMUXP_CONFIGDIR" in os.environ: + path_sources.append((os.environ["TMUXP_CONFIGDIR"], "$TMUXP_CONFIGDIR")) + if "XDG_CONFIG_HOME" in os.environ: + path_sources.append( + ( + os.path.join(os.environ["XDG_CONFIG_HOME"], "tmuxp"), + "$XDG_CONFIG_HOME/tmuxp", + ) + ) + else: + path_sources.append(("~/.config/tmuxp/", "XDG default")) + path_sources.append(("~/.tmuxp", "Legacy")) + + # Get the active directory for comparison + active_dir = get_workspace_dir() + + candidates: list[dict[str, t.Any]] = [] + for raw_path, source in path_sources: + expanded = os.path.expanduser(raw_path) + exists = os.path.isdir(expanded) + + # Count workspace files if directory exists + workspace_count = 0 + if exists: + workspace_count = len( + [ + f + for f in os.listdir(expanded) + if not f.startswith(".") + and os.path.splitext(f)[1].lower() + in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ] + ) + + candidates.append( + { + "path": str(PrivatePath(expanded)), + "source": source, + "exists": exists, + "workspace_count": workspace_count, + "active": expanded == active_dir, + } + ) + + return candidates + + def find_workspace_file( workspace_file: StrPath, workspace_dir: StrPath | None = None, @@ -216,11 +361,12 @@ def find_workspace_file( ] if len(candidates) > 1: + colors = Colors(ColorMode.AUTO) tmuxp_echo( - Fore.RED - + "Multiple .tmuxp.{yml,yaml,json} workspace_files in " - + dirname(workspace_file) - + Fore.RESET, + colors.error( + "Multiple .tmuxp.{yml,yaml,json} workspace_files in " + + dirname(workspace_file) + ), ) tmuxp_echo( "This is undefined behavior, use only one. " diff --git a/tests/_internal/__init__.py b/tests/_internal/__init__.py new file mode 100644 index 0000000000..10efabce8c --- /dev/null +++ b/tests/_internal/__init__.py @@ -0,0 +1 @@ +"""Tests for tmuxp internal modules.""" diff --git a/tests/_internal/conftest.py b/tests/_internal/conftest.py new file mode 100644 index 0000000000..c0630520dd --- /dev/null +++ b/tests/_internal/conftest.py @@ -0,0 +1,32 @@ +"""Shared pytest fixtures for _internal tests.""" + +from __future__ import annotations + +import pytest + +from tmuxp._internal.colors import ColorMode, Colors + +# ANSI escape codes for test assertions +# These constants improve test readability by giving semantic names to color codes +ANSI_GREEN = "\033[32m" +ANSI_RED = "\033[31m" +ANSI_YELLOW = "\033[33m" +ANSI_BLUE = "\033[34m" +ANSI_MAGENTA = "\033[35m" +ANSI_CYAN = "\033[36m" +ANSI_BRIGHT_CYAN = "\033[96m" +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" + + +@pytest.fixture +def colors_always(monkeypatch: pytest.MonkeyPatch) -> Colors: + """Colors instance with ALWAYS mode and NO_COLOR cleared.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + +@pytest.fixture +def colors_never() -> Colors: + """Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) diff --git a/tests/_internal/test_colors.py b/tests/_internal/test_colors.py new file mode 100644 index 0000000000..3aef1e8040 --- /dev/null +++ b/tests/_internal/test_colors.py @@ -0,0 +1,314 @@ +"""Tests for _internal color utilities.""" + +from __future__ import annotations + +import sys + +import pytest + +from tests._internal.conftest import ( + ANSI_BLUE, + ANSI_BOLD, + ANSI_BRIGHT_CYAN, + ANSI_CYAN, + ANSI_GREEN, + ANSI_MAGENTA, + ANSI_RED, + ANSI_RESET, + ANSI_YELLOW, +) +from tmuxp._internal.colors import ( + ColorMode, + Colors, + UnknownStyleColor, + get_color_mode, + style, +) + +# ColorMode tests + + +def test_auto_tty_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Colors enabled when stdout is TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_auto_no_tty_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Colors disabled when stdout is not TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is False + + +def test_no_color_env_respected(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR environment variable disables colors even in ALWAYS mode.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_no_color_any_value(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR with any non-empty value disables colors.""" + monkeypatch.setenv("NO_COLOR", "yes") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_force_color_env_respected(monkeypatch: pytest.MonkeyPatch) -> None: + """FORCE_COLOR environment variable enables colors in AUTO mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_no_color_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_never_mode_disables(monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.NEVER always disables colors.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.NEVER) + assert colors._enabled is False + assert colors.success("test") == "test" + assert colors.error("fail") == "fail" + assert colors.warning("warn") == "warn" + assert colors.info("info") == "info" + assert colors.highlight("hl") == "hl" + assert colors.muted("mute") == "mute" + + +def test_always_mode_enables(monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.ALWAYS enables colors (unless NO_COLOR set).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is True + assert "\033[" in colors.success("test") + + +# Semantic color tests + + +def test_success_applies_green(monkeypatch: pytest.MonkeyPatch) -> None: + """success() applies green color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.success("ok") + assert ANSI_GREEN in result + assert "ok" in result + assert result.endswith(ANSI_RESET) + + +def test_error_applies_red(monkeypatch: pytest.MonkeyPatch) -> None: + """error() applies red color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.error("fail") + assert ANSI_RED in result + assert "fail" in result + + +def test_warning_applies_yellow(monkeypatch: pytest.MonkeyPatch) -> None: + """warning() applies yellow color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.warning("caution") + assert ANSI_YELLOW in result + assert "caution" in result + + +def test_info_applies_cyan(monkeypatch: pytest.MonkeyPatch) -> None: + """info() applies cyan color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.info("message") + assert ANSI_CYAN in result + assert "message" in result + + +def test_highlight_applies_magenta_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """highlight() applies magenta color with bold by default.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.highlight("important") + assert ANSI_MAGENTA in result + assert ANSI_BOLD in result + assert "important" in result + + +def test_highlight_no_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """highlight() can be used without bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.highlight("important", bold=False) + assert ANSI_MAGENTA in result + assert ANSI_BOLD not in result + assert "important" in result + + +def test_muted_applies_blue(monkeypatch: pytest.MonkeyPatch) -> None: + """muted() applies blue color without bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.muted("secondary") + assert ANSI_BLUE in result + assert ANSI_BOLD not in result + assert "secondary" in result + + +def test_success_with_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """success() can be used with bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.success("done", bold=True) + assert ANSI_GREEN in result + assert ANSI_BOLD in result + assert "done" in result + + +# get_color_mode tests + + +def test_get_color_mode_none_returns_auto() -> None: + """None argument returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + +def test_get_color_mode_auto_string() -> None: + """'auto' string returns AUTO mode.""" + assert get_color_mode("auto") == ColorMode.AUTO + + +def test_get_color_mode_always_string() -> None: + """'always' string returns ALWAYS mode.""" + assert get_color_mode("always") == ColorMode.ALWAYS + + +def test_get_color_mode_never_string() -> None: + """'never' string returns NEVER mode.""" + assert get_color_mode("never") == ColorMode.NEVER + + +def test_get_color_mode_case_insensitive() -> None: + """Color mode strings are case insensitive.""" + assert get_color_mode("ALWAYS") == ColorMode.ALWAYS + assert get_color_mode("Never") == ColorMode.NEVER + assert get_color_mode("AUTO") == ColorMode.AUTO + + +def test_get_color_mode_invalid_returns_auto() -> None: + """Invalid color mode strings return AUTO as fallback.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO + + +# Colors class attribute tests + + +def test_semantic_color_names() -> None: + """Verify semantic color name attributes exist.""" + assert Colors.SUCCESS == "green" + assert Colors.WARNING == "yellow" + assert Colors.ERROR == "red" + assert Colors.INFO == "cyan" + assert Colors.HIGHLIGHT == "magenta" + assert Colors.MUTED == "blue" + + +# Colors disabled tests + + +def test_disabled_returns_plain_text() -> None: + """When colors are disabled, methods return plain text.""" + colors = Colors(ColorMode.NEVER) + assert colors.success("text") == "text" + assert colors.error("text") == "text" + assert colors.warning("text") == "text" + assert colors.info("text") == "text" + assert colors.highlight("text") == "text" + assert colors.muted("text") == "text" + + +def test_disabled_preserves_text() -> None: + """Disabled colors preserve special characters.""" + colors = Colors(ColorMode.NEVER) + special = "path/to/file.yaml" + assert colors.info(special) == special + + with_spaces = "some message" + assert colors.success(with_spaces) == with_spaces + + +# RGB tuple validation tests + + +def test_style_with_valid_rgb_tuple() -> None: + """style() should accept valid RGB tuple.""" + result = style("test", fg=(255, 128, 0)) + assert "\033[38;2;255;128;0m" in result + assert "test" in result + + +def test_style_with_invalid_2_element_tuple() -> None: + """style() should raise UnknownStyleColor for 2-element tuple.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255, 128)) # type: ignore[arg-type] + + +def test_style_with_invalid_4_element_tuple() -> None: + """style() should raise UnknownStyleColor for 4-element tuple.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255, 128, 0, 64)) # type: ignore[arg-type] + + +def test_style_with_empty_tuple() -> None: + """style() treats empty tuple as 'no color' (falsy value).""" + result = style("test", fg=()) # type: ignore[arg-type] + # Empty tuple is falsy, so no fg color is applied + assert "test" in result + assert "\033[38" not in result # No foreground color escape + + +def test_style_with_rgb_value_too_high() -> None: + """style() should reject RGB values > 255.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(256, 0, 0)) + + +def test_style_with_rgb_value_negative() -> None: + """style() should reject negative RGB values.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(-1, 128, 0)) + + +def test_style_with_rgb_non_integer() -> None: + """style() should reject non-integer RGB values.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255.5, 128, 0)) # type: ignore[arg-type] + + +# heading() method tests + + +def test_heading_applies_bright_cyan_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """heading() applies bright_cyan with bold when colors are enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.heading("Local workspaces:") + assert ANSI_BRIGHT_CYAN in result + assert ANSI_BOLD in result + assert "Local workspaces:" in result + assert ANSI_RESET in result diff --git a/tests/_internal/test_colors_formatters.py b/tests/_internal/test_colors_formatters.py new file mode 100644 index 0000000000..c7f9d80297 --- /dev/null +++ b/tests/_internal/test_colors_formatters.py @@ -0,0 +1,236 @@ +"""Tests for Colors class formatting helper methods.""" + +from __future__ import annotations + +import pytest + +from tests._internal.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.colors import ColorMode, Colors + +# format_label tests + + +def test_format_label_plain_text() -> None: + """format_label returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_label("tmux path") == "tmux path" + + +def test_format_label_applies_highlight(monkeypatch: pytest.MonkeyPatch) -> None: + """format_label applies highlight (bold magenta) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_label("tmux path") + assert ANSI_MAGENTA in result # magenta + assert ANSI_BOLD in result # bold + assert "tmux path" in result + + +# format_path tests + + +def test_format_path_plain_text() -> None: + """format_path returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_path("/usr/bin/tmux") == "/usr/bin/tmux" + + +def test_format_path_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: + """format_path applies info color (cyan) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_path("/usr/bin/tmux") + assert ANSI_CYAN in result # cyan + assert "/usr/bin/tmux" in result + + +# format_version tests + + +def test_format_version_plain_text() -> None: + """format_version returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_version("3.2a") == "3.2a" + + +def test_format_version_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: + """format_version applies info color (cyan) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_version("3.2a") + assert ANSI_CYAN in result # cyan + assert "3.2a" in result + + +# format_separator tests + + +def test_format_separator_default_length() -> None: + """format_separator creates 25-character separator by default.""" + colors = Colors(ColorMode.NEVER) + result = colors.format_separator() + assert result == "-" * 25 + + +def test_format_separator_custom_length() -> None: + """format_separator respects custom length.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_separator(10) == "-" * 10 + assert colors.format_separator(50) == "-" * 50 + + +def test_format_separator_applies_muted(monkeypatch: pytest.MonkeyPatch) -> None: + """format_separator applies muted color (blue) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_separator() + assert ANSI_BLUE in result # blue + assert "-" * 25 in result + + +# format_kv tests + + +def test_format_kv_plain_text() -> None: + """format_kv returns plain key: value when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_kv("tmux version", "3.2a") == "tmux version: 3.2a" + + +def test_format_kv_highlights_key(monkeypatch: pytest.MonkeyPatch) -> None: + """format_kv highlights the key but not the value.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux version", "3.2a") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_BOLD in result # bold for key + assert "tmux version" in result + assert ": 3.2a" in result + + +def test_format_kv_empty_value() -> None: + """format_kv handles empty value.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_kv("environment", "") == "environment: " + + +# format_tmux_option tests + + +def test_format_tmux_option_plain_text_key_value() -> None: + """format_tmux_option returns plain text when colors disabled (key=value).""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("base-index=1") == "base-index=1" + + +def test_format_tmux_option_plain_text_space_sep() -> None: + """format_tmux_option returns plain text when colors disabled (space-sep).""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("status on") == "status on" + + +def test_format_tmux_option_key_value_format(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option highlights key=value format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("base-index=1") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "base-index" in result + assert "=1" in result or "1" in result + + +def test_format_tmux_option_space_separated(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option highlights space-separated format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("status on") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "status" in result + assert "on" in result + + +def test_format_tmux_option_single_word() -> None: + """format_tmux_option returns single words (empty array options) unchanged.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("pane-colours") == "pane-colours" + + +def test_format_tmux_option_single_word_highlighted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option highlights single words (empty array options) as keys.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("pane-colours") + assert ANSI_MAGENTA in result # magenta for key + assert "pane-colours" in result + + +def test_format_tmux_option_empty() -> None: + """format_tmux_option handles empty string.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("") == "" + + +def test_format_tmux_option_array_indexed(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option handles array-indexed keys like status-format[0].""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option('status-format[0] "#[align=left]"') + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "status-format[0]" in result + assert "#[align=left]" in result + + +def test_format_tmux_option_array_indexed_complex_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option handles complex format strings as values.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Real tmux status-format value (truncated for test) + line = 'status-format[0] "#[align=left range=left #{E:status-left-style}]"' + result = colors.format_tmux_option(line) + assert "status-format[0]" in result + assert "#[align=left" in result + + +def test_format_tmux_option_value_with_spaces( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option handles values containing spaces.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # tmux options can have values with spaces like status-left "string here" + result = colors.format_tmux_option('status-left "#S: #W"') + assert "status-left" in result + assert '"#S: #W"' in result + + +def test_format_tmux_option_value_with_equals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option splits only on first equals for key=value format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Only split on first equals (no spaces = key=value format) + result = colors.format_tmux_option("option=a=b=c") + assert "option" in result + assert "a=b=c" in result + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value diff --git a/tests/_internal/test_colors_integration.py b/tests/_internal/test_colors_integration.py new file mode 100644 index 0000000000..5927a3b71b --- /dev/null +++ b/tests/_internal/test_colors_integration.py @@ -0,0 +1,193 @@ +"""Integration tests for color output across all commands.""" + +from __future__ import annotations + +import sys +import typing as t + +import pytest + +from tests._internal.conftest import ANSI_BOLD, ANSI_MAGENTA, ANSI_RESET +from tmuxp._internal.colors import ColorMode, Colors, get_color_mode + +# Color flag integration tests + + +def test_color_flag_auto_with_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto enables colors when stdout is TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is True + + +def test_color_flag_auto_without_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto disables colors when stdout is not TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is False + + +def test_color_flag_always_forces_colors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=always forces colors even without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is True + # Verify output contains ANSI codes + assert "\033[" in colors.success("test") + + +def test_color_flag_never_disables_colors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=never disables colors even with TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("never") + colors = Colors(color_mode) + assert colors._enabled is False + # Verify output is plain text + assert colors.success("test") == "test" + assert colors.error("test") == "test" + assert colors.warning("test") == "test" + + +# Environment variable integration tests + + +def test_no_color_env_overrides_always(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify NO_COLOR environment variable overrides --color=always.""" + monkeypatch.setenv("NO_COLOR", "1") + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is False + assert colors.success("test") == "test" + + +def test_no_color_env_with_empty_value(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify empty NO_COLOR is ignored (per spec).""" + monkeypatch.setenv("NO_COLOR", "") + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + + colors = Colors(ColorMode.ALWAYS) + # Empty NO_COLOR should be ignored, colors should be enabled + assert colors._enabled is True + + +def test_force_color_env_with_auto(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify FORCE_COLOR enables colors in auto mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_no_color_takes_precedence_over_force_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +# Color mode consistency tests + + +def test_all_semantic_methods_respect_enabled_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify all semantic color methods include ANSI codes when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + methods: list[t.Callable[..., str]] = [ + colors.success, + colors.error, + colors.warning, + colors.info, + colors.muted, + ] + for method in methods: + result = method("test") + assert "\033[" in result, f"{method.__name__} should include ANSI codes" + assert result.endswith(ANSI_RESET), f"{method.__name__} should reset color" + + +def test_all_semantic_methods_respect_disabled_state() -> None: + """Verify all semantic color methods return plain text when disabled.""" + colors = Colors(ColorMode.NEVER) + + methods: list[t.Callable[..., str]] = [ + colors.success, + colors.error, + colors.warning, + colors.info, + colors.muted, + ] + for method in methods: + result = method("test") + assert result == "test", f"{method.__name__} should return plain text" + assert "\033[" not in result, f"{method.__name__} should not have ANSI codes" + + +def test_highlight_bold_parameter(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify highlight respects bold parameter.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + with_bold = colors.highlight("test", bold=True) + without_bold = colors.highlight("test", bold=False) + + assert ANSI_BOLD in with_bold + assert ANSI_BOLD not in without_bold + # Both should have magenta + assert ANSI_MAGENTA in with_bold + assert ANSI_MAGENTA in without_bold + + +# get_color_mode function tests + + +def test_get_color_mode_none_defaults_to_auto() -> None: + """Verify None input returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + +def test_get_color_mode_valid_string_values() -> None: + """Verify all valid string values are converted correctly.""" + assert get_color_mode("auto") == ColorMode.AUTO + assert get_color_mode("always") == ColorMode.ALWAYS + assert get_color_mode("never") == ColorMode.NEVER + + +def test_get_color_mode_case_insensitive() -> None: + """Verify string values are case insensitive.""" + assert get_color_mode("AUTO") == ColorMode.AUTO + assert get_color_mode("Always") == ColorMode.ALWAYS + assert get_color_mode("NEVER") == ColorMode.NEVER + assert get_color_mode("aUtO") == ColorMode.AUTO + + +def test_get_color_mode_invalid_values_fallback_to_auto() -> None: + """Verify invalid values fallback to AUTO mode.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("no") == ColorMode.AUTO + assert get_color_mode("true") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO diff --git a/tests/_internal/test_private_path.py b/tests/_internal/test_private_path.py new file mode 100644 index 0000000000..7e9f3f4979 --- /dev/null +++ b/tests/_internal/test_private_path.py @@ -0,0 +1,124 @@ +"""Tests for PrivatePath privacy-masking utilities.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string + +# PrivatePath tests + + +def test_private_path_collapses_home(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath replaces home directory with ~.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/projects/tmuxp") + assert str(path) == "~/projects/tmuxp" + + +def test_private_path_collapses_home_exact(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath handles exact home directory match.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser") + assert str(path) == "~" + + +def test_private_path_preserves_non_home(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath preserves paths outside home directory.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/usr/bin/tmux") + assert str(path) == "/usr/bin/tmux" + + +def test_private_path_preserves_tmp(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath preserves /tmp paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/tmp/example") + assert str(path) == "/tmp/example" + + +def test_private_path_preserves_already_collapsed() -> None: + """PrivatePath preserves paths already starting with ~.""" + path = PrivatePath("~/already/collapsed") + assert str(path) == "~/already/collapsed" + + +def test_private_path_repr(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath repr shows class name and collapsed path.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/config.yaml") + assert repr(path) == "PrivatePath('~/config.yaml')" + + +def test_private_path_in_fstring(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath works in f-strings with collapsed home.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/.tmuxp/session.yaml") + result = f"config: {path}" + assert result == "config: ~/.tmuxp/session.yaml" + + +def test_private_path_similar_prefix_not_collapsed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """PrivatePath does not collapse paths with similar prefix but different user.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + # /home/testuser2 should NOT be collapsed even though it starts with /home/testuser + path = PrivatePath("/home/testuser2/projects") + assert str(path) == "/home/testuser2/projects" + + +# collapse_home_in_string tests + + +def test_collapse_home_in_string_single_path(monkeypatch: pytest.MonkeyPatch) -> None: + """collapse_home_in_string handles a single path.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/home/testuser/.local/bin") + assert result == "~/.local/bin" + + +def test_collapse_home_in_string_multiple_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """collapse_home_in_string handles colon-separated paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string( + "/home/testuser/bin:/home/testuser/.cargo/bin:/usr/bin" + ) + assert result == "~/bin:~/.cargo/bin:/usr/bin" + + +def test_collapse_home_in_string_no_home_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """collapse_home_in_string preserves paths not under home.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/usr/bin:/bin:/usr/local/bin") + assert result == "/usr/bin:/bin:/usr/local/bin" + + +def test_collapse_home_in_string_mixed_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """collapse_home_in_string handles mixed home and non-home paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/usr/bin:/home/testuser/.local/bin:/bin") + assert result == "/usr/bin:~/.local/bin:/bin" + + +def test_collapse_home_in_string_empty() -> None: + """collapse_home_in_string handles empty string.""" + result = collapse_home_in_string("") + assert result == "" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000000..32e5748f21 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,63 @@ +"""Shared pytest fixtures for CLI tests.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp._internal.colors import ColorMode, Colors + +# ANSI escape codes for test assertions +# These constants improve test readability by giving semantic names to color codes +ANSI_GREEN = "\033[32m" +ANSI_RED = "\033[31m" +ANSI_YELLOW = "\033[33m" +ANSI_BLUE = "\033[34m" +ANSI_MAGENTA = "\033[35m" +ANSI_CYAN = "\033[36m" +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" + + +@pytest.fixture +def colors_always(monkeypatch: pytest.MonkeyPatch) -> Colors: + """Colors instance with ALWAYS mode and NO_COLOR cleared.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + +@pytest.fixture +def colors_never() -> Colors: + """Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) + + +@pytest.fixture +def mock_home(monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: + """Mock home directory for privacy tests. + + Sets pathlib.Path.home() to return /home/testuser. + """ + home = pathlib.Path("/home/testuser") + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + return home + + +@pytest.fixture +def isolated_home( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> pathlib.Path: + """Isolate test from user's home directory and environment. + + Sets up tmp_path as HOME with XDG_CONFIG_HOME, clears TMUXP_CONFIGDIR + and NO_COLOR, and changes the working directory to tmp_path. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + return tmp_path diff --git a/tests/cli/test_convert_colors.py b/tests/cli/test_convert_colors.py new file mode 100644 index 0000000000..5d395553a6 --- /dev/null +++ b/tests/cli/test_convert_colors.py @@ -0,0 +1,124 @@ +"""Tests for CLI colors in convert command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ANSI_BOLD, ANSI_CYAN, ANSI_GREEN, ANSI_MAGENTA +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Convert command color output tests + + +def test_convert_success_message(colors_always: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors_always.success("New workspace file saved to ") + assert ANSI_GREEN in result # green foreground + assert "New workspace file saved to" in result + + +def test_convert_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_convert_format_type_highlighted(colors_always: Colors) -> None: + """Verify format type uses highlight color (magenta + bold).""" + for fmt in ["json", "yaml"]: + result = colors_always.highlight(fmt) + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD in result # bold + assert fmt in result + + +def test_convert_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_never.success("success") == "success" + assert colors_never.info("info") == "info" + assert colors_never.highlight("highlight") == "highlight" + + +def test_convert_combined_success_format(colors_always: Colors) -> None: + """Verify combined success + info format for save message.""" + newfile = "/home/user/.tmuxp/session.json" + output = ( + colors_always.success("New workspace file saved to ") + + colors_always.info(f"<{newfile}>") + + "." + ) + # Should contain both green and cyan ANSI codes + assert ANSI_GREEN in output # green for success text + assert ANSI_CYAN in output # cyan for path + assert "New workspace file saved to" in output + assert newfile in output + assert output.endswith(".") + + +def test_convert_prompt_format_with_highlight(colors_always: Colors) -> None: + """Verify prompt uses info for path and highlight for format.""" + workspace_file = "/path/to/config.yaml" + to_filetype = "json" + prompt = ( + f"Convert {colors_always.info(workspace_file)} " + f"to {colors_always.highlight(to_filetype)}?" + ) + assert ANSI_CYAN in prompt # cyan for file path + assert ANSI_MAGENTA in prompt # magenta for format type + assert workspace_file in prompt + assert to_filetype in prompt + + +def test_convert_save_prompt_format(colors_always: Colors) -> None: + """Verify save prompt uses info color for new file path.""" + newfile = "/path/to/config.json" + prompt = f"Save workspace to {colors_always.info(newfile)}?" + assert ANSI_CYAN in prompt # cyan for file path + assert newfile in prompt + assert "Save workspace to" in prompt + + +# Privacy masking tests + + +def test_convert_masks_home_in_convert_prompt( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Convert should mask home directory in convert prompt.""" + workspace_file = mock_home / ".tmuxp/session.yaml" + prompt = f"Convert {colors_always.info(str(PrivatePath(workspace_file)))} to json?" + + assert "~/.tmuxp/session.yaml" in prompt + assert "/home/testuser" not in prompt + + +def test_convert_masks_home_in_save_prompt( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Convert should mask home directory in save prompt.""" + newfile = mock_home / ".tmuxp/session.json" + prompt = f"Save workspace to {colors_always.info(str(PrivatePath(newfile)))}?" + + assert "~/.tmuxp/session.json" in prompt + assert "/home/testuser" not in prompt + + +def test_convert_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Convert should mask home directory in saved message.""" + newfile = mock_home / ".tmuxp/session.json" + output = ( + colors_always.success("New workspace file saved to ") + + colors_always.info(str(PrivatePath(newfile))) + + "." + ) + + assert "~/.tmuxp/session.json" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_debug_info.py b/tests/cli/test_debug_info.py index 1729f1e9cc..bb1c0bb479 100644 --- a/tests/cli/test_debug_info.py +++ b/tests/cli/test_debug_info.py @@ -1,15 +1,68 @@ -"""CLI tests for tmuxp debuginfo.""" +"""CLI tests for tmuxp debug-info.""" from __future__ import annotations +import json import typing as t +import pytest + from tmuxp import cli if t.TYPE_CHECKING: import pathlib - import pytest + +class DebugInfoOutputFixture(t.NamedTuple): + """Test fixture for debug-info output modes.""" + + test_id: str + args: list[str] + expected_keys: list[str] + is_json: bool + + +DEBUG_INFO_OUTPUT_FIXTURES: list[DebugInfoOutputFixture] = [ + DebugInfoOutputFixture( + test_id="human_output_has_labels", + args=["debug-info"], + expected_keys=["environment", "python version", "tmux version"], + is_json=False, + ), + DebugInfoOutputFixture( + test_id="json_output_valid", + args=["debug-info", "--json"], + expected_keys=["environment", "python_version", "tmux_version"], + is_json=True, + ), +] + + +@pytest.mark.parametrize( + DEBUG_INFO_OUTPUT_FIXTURES[0]._fields, + [pytest.param(*f, id=f.test_id) for f in DEBUG_INFO_OUTPUT_FIXTURES], +) +def test_debug_info_output_modes( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + args: list[str], + expected_keys: list[str], + is_json: bool, +) -> None: + """Test debug-info output modes (human and JSON).""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(args) + output = capsys.readouterr().out + + if is_json: + data = json.loads(output) + for key in expected_keys: + assert key in data, f"Expected key '{key}' in JSON output" + else: + for key in expected_keys: + assert key in output, f"Expected '{key}' in human output" def test_debug_info_cli( @@ -17,7 +70,7 @@ def test_debug_info_cli( tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: - """Basic CLI test for tmuxp debug-info.""" + """Basic CLI test for tmuxp debug-info (human output).""" monkeypatch.setenv("SHELL", "/bin/bash") cli.cli(["debug-info"]) @@ -36,3 +89,81 @@ def test_debug_info_cli( assert "tmux panes" in cli_output assert "tmux global options" in cli_output assert "tmux window options" in cli_output + + +def test_debug_info_json_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output is valid JSON with expected structure.""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + + data = json.loads(output) + + # Top-level keys + assert "environment" in data + assert "python_version" in data + assert "system_path" in data + assert "tmux_version" in data + assert "libtmux_version" in data + assert "tmuxp_version" in data + assert "tmux_path" in data + assert "tmuxp_path" in data + assert "shell" in data + assert "tmux" in data + + # Environment structure + env = data["environment"] + assert "dist" in env + assert "arch" in env + assert "uname" in env + assert "version" in env + assert isinstance(env["uname"], list) + + # Tmux structure + tmux = data["tmux"] + assert "sessions" in tmux + assert "windows" in tmux + assert "panes" in tmux + assert "global_options" in tmux + assert "window_options" in tmux + assert isinstance(tmux["sessions"], list) + + +def test_debug_info_json_no_ansi( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should not contain ANSI escape codes.""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + + # ANSI escape codes start with \x1b[ or \033[ + assert "\x1b[" not in output, "JSON output contains ANSI escape codes" + assert "\033[" not in output, "JSON output contains ANSI escape codes" + + +def test_debug_info_json_paths_use_private_path( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should mask home directory with ~.""" + import pathlib + + # Set SHELL to a path under home directory + shell_path = pathlib.Path.home() / ".local" / "bin" / "zsh" + monkeypatch.setenv("SHELL", str(shell_path)) + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + data = json.loads(output) + + # The shell path should be masked with ~ + assert data["shell"] == "~/.local/bin/zsh", ( + f"Expected shell path to be masked with ~, got: {data['shell']}" + ) diff --git a/tests/cli/test_debug_info_colors.py b/tests/cli/test_debug_info_colors.py new file mode 100644 index 0000000000..ec2ac283db --- /dev/null +++ b/tests/cli/test_debug_info_colors.py @@ -0,0 +1,154 @@ +"""Tests for debug-info command color output and privacy masking.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string +from tmuxp.cli._colors import ColorMode, Colors + +# Privacy masking in debug-info context + + +def test_debug_info_masks_home_in_paths(mock_home: pathlib.Path) -> None: + """debug-info should mask home directory in paths.""" + # Simulate what debug-info does with tmuxp_path + tmuxp_path = mock_home / "work/python/tmuxp/src/tmuxp" + private_path = str(PrivatePath(tmuxp_path)) + + assert private_path == "~/work/python/tmuxp/src/tmuxp" + assert "/home/testuser" not in private_path + + +def test_debug_info_masks_home_in_system_path(mock_home: pathlib.Path) -> None: + """debug-info should mask home directory in system PATH.""" + path_env = "/home/testuser/.local/bin:/usr/bin:/home/testuser/.cargo/bin" + masked = collapse_home_in_string(path_env) + + assert masked == "~/.local/bin:/usr/bin:~/.cargo/bin" + assert "/home/testuser" not in masked + + +def test_debug_info_preserves_system_paths(mock_home: pathlib.Path) -> None: + """debug-info should preserve paths outside home directory.""" + tmux_path = "/usr/bin/tmux" + private_path = str(PrivatePath(tmux_path)) + + assert private_path == "/usr/bin/tmux" + + +# Formatting helpers in debug-info context + + +def test_debug_info_format_kv_labels(colors_always: Colors) -> None: + """debug-info should highlight labels in key-value pairs.""" + result = colors_always.format_kv("tmux version", "3.2a") + assert ANSI_MAGENTA in result # magenta for label + assert ANSI_BOLD in result # bold for label + assert "tmux version" in result + assert "3.2a" in result + + +def test_debug_info_format_version(colors_always: Colors) -> None: + """debug-info should highlight version strings.""" + result = colors_always.format_kv( + "tmux version", colors_always.format_version("3.2a") + ) + assert ANSI_CYAN in result # cyan for version + assert "3.2a" in result + + +def test_debug_info_format_path(colors_always: Colors) -> None: + """debug-info should highlight paths.""" + result = colors_always.format_kv( + "tmux path", colors_always.format_path("/usr/bin/tmux") + ) + assert ANSI_CYAN in result # cyan for path + assert "/usr/bin/tmux" in result + + +def test_debug_info_format_separator(colors_always: Colors) -> None: + """debug-info should use muted separators.""" + result = colors_always.format_separator() + assert ANSI_BLUE in result # blue for muted + assert "-" * 25 in result + + +# tmux option formatting + + +def test_debug_info_format_tmux_option_space_sep(colors_always: Colors) -> None: + """debug-info should format space-separated tmux options.""" + result = colors_always.format_tmux_option("status on") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "status" in result + assert "on" in result + + +def test_debug_info_format_tmux_option_equals_sep(colors_always: Colors) -> None: + """debug-info should format equals-separated tmux options.""" + result = colors_always.format_tmux_option("base-index=0") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "base-index" in result + assert "0" in result + + +# Color mode behavior + + +def test_debug_info_respects_never_mode(colors_never: Colors) -> None: + """debug-info should return plain text in NEVER mode.""" + result = colors_never.format_kv("tmux version", colors_never.format_version("3.2a")) + assert "\033[" not in result + assert result == "tmux version: 3.2a" + + +def test_debug_info_respects_no_color_env(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should respect NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux path", "/usr/bin/tmux") + assert "\033[" not in result + assert result == "tmux path: /usr/bin/tmux" + + +# Combined formatting + + +def test_debug_info_combined_path_with_privacy( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """debug-info should combine privacy masking with color formatting.""" + # Simulate what debug-info does + raw_path = mock_home / "work/tmuxp/src/tmuxp" + private_path = str(PrivatePath(raw_path)) + formatted = colors_always.format_kv( + "tmuxp path", colors_always.format_path(private_path) + ) + + assert "~/work/tmuxp/src/tmuxp" in formatted + assert "/home/testuser" not in formatted + assert ANSI_CYAN in formatted # cyan for path + assert ANSI_MAGENTA in formatted # magenta for label + + +def test_debug_info_environment_section_format(colors_always: Colors) -> None: + """debug-info environment section should have proper format.""" + # Simulate environment section format + env_items = [ + f"\t{colors_always.format_kv('dist', 'Linux-6.6.87')}", + f"\t{colors_always.format_kv('arch', 'x86_64')}", + ] + section = f"{colors_always.format_label('environment')}:\n" + "\n".join(env_items) + + assert "environment" in section + assert "\t" in section # indented items + assert "dist" in section + assert "arch" in section diff --git a/tests/cli/test_edit_colors.py b/tests/cli/test_edit_colors.py new file mode 100644 index 0000000000..53c2663d80 --- /dev/null +++ b/tests/cli/test_edit_colors.py @@ -0,0 +1,101 @@ +"""Tests for CLI colors in edit command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Edit command color output tests + + +def test_edit_opening_message_format(colors_always: Colors) -> None: + """Verify opening message format with file path and editor.""" + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_always.muted("Opening ") + + colors_always.info(workspace_file) + + colors_always.muted(" in ") + + colors_always.highlight(editor, bold=False) + + colors_always.muted("...") + ) + # Should contain blue, cyan, and magenta ANSI codes + assert ANSI_BLUE in output # blue for muted + assert ANSI_CYAN in output # cyan for file path + assert ANSI_MAGENTA in output # magenta for editor + assert workspace_file in output + assert editor in output + + +def test_edit_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/workspace.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_edit_editor_highlighted(colors_always: Colors) -> None: + """Verify editor name uses highlight color without bold.""" + for editor in ["vim", "nano", "code", "emacs", "nvim"]: + result = colors_always.highlight(editor, bold=False) + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD not in result # no bold - subtle + assert editor in result + + +def test_edit_muted_for_static_text(colors_always: Colors) -> None: + """Verify static text uses muted color (blue).""" + result = colors_always.muted("Opening ") + assert ANSI_BLUE in result # blue foreground + assert "Opening" in result + + +def test_edit_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_never.muted("Opening ") + + colors_never.info(workspace_file) + + colors_never.muted(" in ") + + colors_never.highlight(editor, bold=False) + + colors_never.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Opening {workspace_file} in {editor}..." + + +def test_edit_various_editors(colors_always: Colors) -> None: + """Verify common editors can be highlighted.""" + editors = ["vim", "nvim", "nano", "code", "emacs", "hx", "micro"] + for editor in editors: + result = colors_always.highlight(editor, bold=False) + assert ANSI_MAGENTA in result + assert editor in result + + +# Privacy masking tests + + +def test_edit_masks_home_in_opening_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Edit should mask home directory in 'Opening' message.""" + workspace_file = mock_home / ".tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_always.muted("Opening ") + + colors_always.info(str(PrivatePath(workspace_file))) + + colors_always.muted(" in ") + + colors_always.highlight(editor, bold=False) + + colors_always.muted("...") + ) + + assert "~/.tmuxp/dev.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_formatter.py b/tests/cli/test_formatter.py new file mode 100644 index 0000000000..9902eb387d --- /dev/null +++ b/tests/cli/test_formatter.py @@ -0,0 +1,218 @@ +"""Tests for TmuxpHelpFormatter and themed formatter factory.""" + +from __future__ import annotations + +import argparse + +import pytest + +from tests.cli.conftest import ANSI_RESET +from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._formatter import ( + HelpTheme, + TmuxpHelpFormatter, + create_themed_formatter, +) + + +def test_create_themed_formatter_returns_subclass() -> None: + """Factory returns a TmuxpHelpFormatter subclass.""" + formatter_cls = create_themed_formatter() + assert issubclass(formatter_cls, TmuxpHelpFormatter) + + +def test_create_themed_formatter_with_colors_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Formatter has theme when colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is not None + assert formatter._theme.prog != "" # Has color codes + + +def test_create_themed_formatter_with_colors_disabled() -> None: + """Formatter has no theme when colors disabled.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is None + + +def test_create_themed_formatter_auto_mode_respects_no_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto mode respects NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is None + + +def test_create_themed_formatter_auto_mode_respects_force_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto mode respects FORCE_COLOR environment variable.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is not None + + +def test_fill_text_with_theme_colorizes_examples( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Examples section is colorized when theme is set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should contain ANSI escape codes + assert "\033[" in result + assert "tmuxp" in result + assert "load" in result + + +def test_fill_text_without_theme_plain_text() -> None: + """Examples section is plain text when no theme.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should NOT contain ANSI escape codes + assert "\033[" not in result + assert "tmuxp load myproject" in result + + +def test_fill_text_category_headings_colorized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Category headings within examples block are colorized.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Test category heading without "examples:" suffix + text = "examples:\n tmuxp ls\n\nMachine-readable output:\n tmuxp ls --json" + result = formatter._fill_text(text, 80, "") + + # Both headings should be colorized + assert "\033[" in result + assert "examples:" in result + assert "Machine-readable output:" in result + # Commands should also be colorized + assert "tmuxp" in result + assert "--json" in result + + +def test_fill_text_category_heading_only_in_examples_block( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Category headings are only recognized within examples block.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Text before examples block should not be colorized as heading + text = "Some heading:\n not a command\n\nexamples:\n tmuxp load" + result = formatter._fill_text(text, 80, "") + + # "Some heading:" should NOT be colorized (it's before examples block) + # "examples:" and the command should be colorized + lines = result.split("\n") + # First line should be plain (no ANSI in "Some heading:") + assert "Some heading:" in lines[0] + + +def test_parser_help_respects_no_color( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Parser --help output is plain when NO_COLOR set.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" not in captured.out + + +def test_parser_help_colorized_with_force_color( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Parser --help output is colorized when FORCE_COLOR set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" in captured.out + + +def test_help_theme_from_colors_with_none_returns_empty() -> None: + """HelpTheme.from_colors(None) returns empty theme.""" + theme = HelpTheme.from_colors(None) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + +def test_help_theme_from_colors_disabled_returns_empty() -> None: + """HelpTheme.from_colors with disabled colors returns empty theme.""" + colors = Colors(ColorMode.NEVER) + theme = HelpTheme.from_colors(colors) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + +def test_help_theme_from_colors_enabled_returns_colored( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HelpTheme.from_colors with enabled colors returns colored theme.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + theme = HelpTheme.from_colors(colors) + + # Should have ANSI codes + assert "\033[" in theme.prog + assert "\033[" in theme.action + assert theme.reset == ANSI_RESET diff --git a/tests/cli/test_freeze_colors.py b/tests/cli/test_freeze_colors.py new file mode 100644 index 0000000000..3d6afaa012 --- /dev/null +++ b/tests/cli/test_freeze_colors.py @@ -0,0 +1,152 @@ +"""Tests for CLI colors in freeze command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ( + ANSI_BLUE, + ANSI_CYAN, + ANSI_GREEN, + ANSI_RED, + ANSI_RESET, + ANSI_YELLOW, +) +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Freeze command color output tests + + +def test_freeze_error_uses_red(colors_always: Colors) -> None: + """Verify error messages use error color (red).""" + msg = "Session not found" + result = colors_always.error(msg) + assert ANSI_RED in result # red foreground + assert msg in result + assert result.endswith(ANSI_RESET) # reset at end + + +def test_freeze_success_message(colors_always: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors_always.success("Saved to ") + assert ANSI_GREEN in result # green foreground + assert "Saved to" in result + + +def test_freeze_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_freeze_warning_file_exists(colors_always: Colors) -> None: + """Verify file exists warning uses warning color (yellow).""" + msg = "/path/to/config.yaml exists." + result = colors_always.warning(msg) + assert ANSI_YELLOW in result # yellow foreground + assert msg in result + + +def test_freeze_muted_for_secondary_text(colors_always: Colors) -> None: + """Verify secondary text uses muted color (blue).""" + msg = "Freeze does its best to snapshot live tmux sessions." + result = colors_always.muted(msg) + assert ANSI_BLUE in result # blue foreground + assert msg in result + + +def test_freeze_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_never.error("error") == "error" + assert colors_never.success("success") == "success" + assert colors_never.warning("warning") == "warning" + assert colors_never.info("info") == "info" + assert colors_never.muted("muted") == "muted" + + +def test_freeze_combined_output_format(colors_always: Colors) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + dest = "/home/user/.tmuxp/session.yaml" + output = colors_always.success("Saved to ") + colors_always.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert ANSI_GREEN in output # green for "Saved to" + assert ANSI_CYAN in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + +def test_freeze_warning_with_instructions(colors_always: Colors) -> None: + """Verify warning + muted format for file exists message.""" + path = "/path/to/config.yaml" + output = ( + colors_always.warning(f"{path} exists.") + + " " + + colors_always.muted("Pick a new filename.") + ) + # Should contain both yellow and blue ANSI codes + assert ANSI_YELLOW in output # yellow for warning + assert ANSI_BLUE in output # blue for muted + assert path in output + assert "Pick a new filename." in output + + +def test_freeze_url_highlighted_in_help(colors_always: Colors) -> None: + """Verify URLs use info color in help text.""" + url = "" + help_text = colors_always.muted("tmuxp has examples at ") + colors_always.info(url) + assert ANSI_BLUE in help_text # blue for muted text + assert ANSI_CYAN in help_text # cyan for URL + assert url in help_text + + +# Privacy masking tests + + +def test_freeze_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Freeze should mask home directory in 'Saved to' message.""" + dest = mock_home / ".tmuxp/session.yaml" + output = ( + colors_always.success("Saved to ") + + colors_always.info(str(PrivatePath(dest))) + + "." + ) + + assert "~/.tmuxp/session.yaml" in output + assert "/home/testuser" not in output + + +def test_freeze_masks_home_in_exists_warning( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Freeze should mask home directory in 'exists' warning.""" + dest_prompt = mock_home / ".tmuxp/session.yaml" + output = colors_always.warning(f"{PrivatePath(dest_prompt)} exists.") + + assert "~/.tmuxp/session.yaml exists." in output + assert "/home/testuser" not in output + + +def test_freeze_masks_home_in_save_to_prompt(mock_home: pathlib.Path) -> None: + """Freeze should mask home directory in 'Save to:' prompt.""" + save_to = mock_home / ".tmuxp/session.yaml" + prompt_text = f"Save to: {PrivatePath(save_to)}" + + assert "~/.tmuxp/session.yaml" in prompt_text + assert "/home/testuser" not in prompt_text + + +def test_freeze_masks_home_in_save_confirmation(mock_home: pathlib.Path) -> None: + """Freeze should mask home directory in 'Save to ...?' confirmation.""" + dest = mock_home / ".tmuxp/session.yaml" + prompt_text = f"Save to {PrivatePath(dest)}?" + + assert "~/.tmuxp/session.yaml" in prompt_text + assert "/home/testuser" not in prompt_text diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py new file mode 100644 index 0000000000..85e8d2e879 --- /dev/null +++ b/tests/cli/test_help_examples.py @@ -0,0 +1,252 @@ +"""Tests to ensure CLI help examples are valid commands.""" + +from __future__ import annotations + +import argparse +import subprocess + +import pytest + +from tmuxp.cli import create_parser + + +def _get_help_text(subcommand: str | None = None) -> str: + """Get CLI help text without spawning subprocess. + + Parameters + ---------- + subcommand : str | None + Subcommand name, or None for main help. + + Returns + ------- + str + The formatted help text. + """ + parser = create_parser() + if subcommand is None: + return parser.format_help() + + # Access subparser via _subparsers._group_actions + subparsers = parser._subparsers + if subparsers is not None: + for action in subparsers._group_actions: + if isinstance(action, argparse._SubParsersAction): + choices = action.choices + if choices is not None and subcommand in choices: + return str(choices[subcommand].format_help()) + + return parser.format_help() + + +def extract_examples_from_help(help_text: str) -> list[str]: + r"""Extract example commands from help text. + + Parameters + ---------- + help_text : str + The help output text to extract examples from. + + Returns + ------- + list[str] + List of extracted example commands. + + Examples + -------- + >>> text = "load:\n tmuxp load myproject\n\npositions:" + >>> extract_examples_from_help(text) + ['tmuxp load myproject'] + + >>> text2 = "examples:\n tmuxp debug-info\n\noptions:" + >>> extract_examples_from_help(text2) + ['tmuxp debug-info'] + + >>> text3 = "Field-scoped search:\n tmuxp search window:editor" + >>> extract_examples_from_help(text3) + ['tmuxp search window:editor'] + """ + examples = [] + in_examples = False + for line in help_text.splitlines(): + # Match example section headings: + # - "examples:" (default examples section) + # - "load examples:" or "load:" (category headings) + # - "Field-scoped search:" (multi-word category headings) + # Exclude argparse sections like "positional arguments:", "options:" + stripped = line.strip() + is_section_heading = ( + stripped.endswith(":") + and stripped not in ("positional arguments:", "options:") + and not stripped.startswith("-") + ) + if is_section_heading: + in_examples = True + elif in_examples and line.startswith(" "): + cmd = line.strip() + if cmd.startswith("tmuxp"): + examples.append(cmd) + elif line and not line[0].isspace(): + in_examples = False + return examples + + +def test_main_help_has_examples() -> None: + """Main --help should have at least one example.""" + help_text = _get_help_text() + examples = extract_examples_from_help(help_text) + assert len(examples) > 0, "Main --help should have at least one example" + + +def test_main_help_examples_are_valid_subcommands() -> None: + """All examples in main --help should reference valid subcommands.""" + help_text = _get_help_text() + examples = extract_examples_from_help(help_text) + + # Extract valid subcommands from help output + valid_subcommands = { + "load", + "shell", + "import", + "convert", + "debug-info", + "ls", + "edit", + "freeze", + "search", + } + + for example in examples: + parts = example.split() + if len(parts) >= 2: + subcommand = parts[1] + assert subcommand in valid_subcommands, ( + f"Example '{example}' uses unknown subcommand '{subcommand}'" + ) + + +@pytest.mark.parametrize( + "subcommand", + [ + "load", + "shell", + "import", + "convert", + "debug-info", + "ls", + "edit", + "freeze", + "search", + ], +) +def test_subcommand_help_has_examples(subcommand: str) -> None: + """Each subcommand --help should have at least one example.""" + help_text = _get_help_text(subcommand) + examples = extract_examples_from_help(help_text) + assert len(examples) > 0, f"{subcommand} --help should have at least one example" + + +def test_load_subcommand_examples_are_valid() -> None: + """Load subcommand examples should have valid flags.""" + help_text = _get_help_text("load") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp load"), f"Bad example format: {example}" + + +def test_freeze_subcommand_examples_are_valid() -> None: + """Freeze subcommand examples should have valid flags.""" + help_text = _get_help_text("freeze") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp freeze"), f"Bad example format: {example}" + + +def test_shell_subcommand_examples_are_valid() -> None: + """Shell subcommand examples should have valid flags.""" + help_text = _get_help_text("shell") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp shell"), f"Bad example format: {example}" + + +def test_convert_subcommand_examples_are_valid() -> None: + """Convert subcommand examples should have valid flags.""" + help_text = _get_help_text("convert") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp convert"), f"Bad example format: {example}" + + +def test_import_subcommand_examples_are_valid() -> None: + """Import subcommand examples should have valid flags.""" + help_text = _get_help_text("import") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp import"), f"Bad example format: {example}" + + +def test_edit_subcommand_examples_are_valid() -> None: + """Edit subcommand examples should have valid flags.""" + help_text = _get_help_text("edit") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp edit"), f"Bad example format: {example}" + + +def test_ls_subcommand_examples_are_valid() -> None: + """Ls subcommand examples should have valid flags.""" + help_text = _get_help_text("ls") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp ls"), f"Bad example format: {example}" + + +def test_debug_info_subcommand_examples_are_valid() -> None: + """Debug-info subcommand examples should have valid flags.""" + help_text = _get_help_text("debug-info") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp debug-info"), f"Bad example format: {example}" + + +def test_search_subcommand_examples_are_valid() -> None: + """Search subcommand examples should have valid flags.""" + help_text = _get_help_text("search") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp search"), f"Bad example format: {example}" + + +def test_search_no_args_shows_help() -> None: + """Running 'tmuxp search' with no args shows help. + + Note: This test uses subprocess to verify actual CLI behavior and exit code. + """ + result = subprocess.run( + ["tmuxp", "search"], + capture_output=True, + text=True, + ) + # Should show help (usage line present) + assert "usage: tmuxp search" in result.stdout + # Should exit successfully (not error) + assert result.returncode == 0 diff --git a/tests/cli/test_import_colors.py b/tests/cli/test_import_colors.py new file mode 100644 index 0000000000..158e69b83f --- /dev/null +++ b/tests/cli/test_import_colors.py @@ -0,0 +1,143 @@ +"""Tests for CLI colors in import command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ( + ANSI_BLUE, + ANSI_CYAN, + ANSI_GREEN, + ANSI_RED, + ANSI_RESET, +) +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Import command color output tests + + +def test_import_error_unknown_format(colors_always: Colors) -> None: + """Verify unknown format error uses error color (red).""" + msg = "Unknown config format." + result = colors_always.error(msg) + assert ANSI_RED in result # red foreground + assert msg in result + assert result.endswith(ANSI_RESET) # reset at end + + +def test_import_success_message(colors_always: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors_always.success("Saved to ") + assert ANSI_GREEN in result # green foreground + assert "Saved to" in result + + +def test_import_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_import_muted_for_banner(colors_always: Colors) -> None: + """Verify banner text uses muted color (blue).""" + msg = "Configuration import does its best to convert files." + result = colors_always.muted(msg) + assert ANSI_BLUE in result # blue foreground + assert msg in result + + +def test_import_muted_for_separator(colors_always: Colors) -> None: + """Verify separator uses muted color (blue).""" + separator = "---------------------------------------------------------------" + result = colors_always.muted(separator) + assert ANSI_BLUE in result # blue foreground + assert separator in result + + +def test_import_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_never.error("error") == "error" + assert colors_never.success("success") == "success" + assert colors_never.muted("muted") == "muted" + assert colors_never.info("info") == "info" + + +def test_import_combined_success_format(colors_always: Colors) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + dest = "/home/user/.tmuxp/session.yaml" + output = colors_always.success("Saved to ") + colors_always.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert ANSI_GREEN in output # green for "Saved to" + assert ANSI_CYAN in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + +def test_import_help_text_with_urls(colors_always: Colors) -> None: + """Verify help text uses muted for text and info for URLs.""" + url = "" + help_text = colors_always.muted( + "tmuxp has examples in JSON and YAML format at " + ) + colors_always.info(url) + assert ANSI_BLUE in help_text # blue for muted text + assert ANSI_CYAN in help_text # cyan for URL + assert url in help_text + + +def test_import_banner_with_separator(colors_always: Colors) -> None: + """Verify banner format with separator and instruction text.""" + config_content = "session_name: test\n" + separator = "---------------------------------------------------------------" + output = ( + config_content + + colors_always.muted(separator) + + "\n" + + colors_always.muted("Configuration import does its best to convert files.") + + "\n" + ) + # Should contain blue ANSI code for muted sections + assert ANSI_BLUE in output + assert separator in output + assert "Configuration import" in output + assert config_content in output + + +# Privacy masking tests + + +def test_import_masks_home_in_save_prompt(mock_home: pathlib.Path) -> None: + """Import should mask home directory in save prompt.""" + cwd = mock_home / "projects" + prompt = f"Save to [{PrivatePath(cwd)}]" + + assert "[~/projects]" in prompt + assert "/home/testuser" not in prompt + + +def test_import_masks_home_in_confirm_prompt(mock_home: pathlib.Path) -> None: + """Import should mask home directory in confirmation prompt.""" + dest_path = mock_home / ".tmuxp/imported.yaml" + prompt = f"Save to {PrivatePath(dest_path)}?" + + assert "~/.tmuxp/imported.yaml" in prompt + assert "/home/testuser" not in prompt + + +def test_import_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Import should mask home directory in 'Saved to' message.""" + dest = mock_home / ".tmuxp/imported.yaml" + output = ( + colors_always.success("Saved to ") + + colors_always.info(str(PrivatePath(dest))) + + "." + ) + + assert "~/.tmuxp/imported.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index e45bbc4f26..2191b7320c 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -16,6 +16,8 @@ from tests.fixtures import utils as test_utils 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, @@ -751,3 +753,23 @@ def test_load_append_windows_to_current_session( assert len(server.sessions) == 1 assert len(server.windows) == 6 + + +# 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.""" + 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))) + ) + + assert "~/work/project/.tmuxp.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 1811e636c4..40e1526839 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -3,24 +3,117 @@ from __future__ import annotations import contextlib +import json import pathlib -import typing as t + +import pytest from tmuxp import cli +from tmuxp.cli.ls import ( + _get_workspace_info, + create_ls_subparser, +) + + +def test_get_workspace_info_yaml(tmp_path: pathlib.Path) -> None: + """Extract metadata from YAML workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: my-session\nwindows: []") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["format"] == "yaml" + assert info["session_name"] == "my-session" + assert info["size"] > 0 + assert "T" in info["mtime"] # ISO format contains T + assert info["source"] == "global" # Default source + + +def test_get_workspace_info_source_local(tmp_path: pathlib.Path) -> None: + """Extract metadata with source=local.""" + workspace = tmp_path / ".tmuxp.yaml" + workspace.write_text("session_name: local-session\nwindows: []") + + info = _get_workspace_info(workspace, source="local") + + assert info["name"] == ".tmuxp" + assert info["source"] == "local" + assert info["session_name"] == "local-session" + + +def test_get_workspace_info_json(tmp_path: pathlib.Path) -> None: + """Extract metadata from JSON workspace file.""" + workspace = tmp_path / "test.json" + workspace.write_text('{"session_name": "json-session", "windows": []}') + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["format"] == "json" + assert info["session_name"] == "json-session" + + +def test_get_workspace_info_no_session_name(tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows: []") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["session_name"] is None -if t.TYPE_CHECKING: - import pytest + +def test_get_workspace_info_invalid_yaml(tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["session_name"] is None # Couldn't parse, so None + + +def test_ls_subparser_adds_tree_flag() -> None: + """Verify --tree argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--tree"]) + + assert args.tree is True + + +def test_ls_subparser_adds_json_flag() -> None: + """Verify --json argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--json"]) + + assert args.output_json is True + + +def test_ls_subparser_adds_ndjson_flag() -> None: + """Verify --ndjson argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--ndjson"]) + + assert args.output_ndjson is True def test_ls_cli( - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """CLI test for tmuxp ls.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - filenames = [ ".git/", ".gitignore/", @@ -37,15 +130,615 @@ def test_ls_cli( stems = [pathlib.Path(f).stem for f in filenames if f not in ignored_filenames] for filename in filenames: - location = tmp_path / f".tmuxp/{filename}" + location = isolated_home / f".tmuxp/{filename}" if filename.endswith("/"): location.mkdir(parents=True) else: location.touch() with contextlib.suppress(SystemExit): - cli.cli(["ls"]) + cli.cli(["--color=never", "ls"]) cli_output = capsys.readouterr().out - assert cli_output == "\n".join(stems) + "\n" + # Output now has headers with directory path, check for workspace names + assert "Global workspaces (~/.tmuxp):" in cli_output + for stem in stems: + assert stem in cli_output + + +def test_ls_json_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --json.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + (tmuxp_dir / "prod.json").write_text('{"session_name": "production"}') + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data + + workspaces = data["workspaces"] + assert len(workspaces) == 2 + + names = {item["name"] for item in workspaces} + assert names == {"dev", "prod"} + + # Verify all expected fields are present + for item in workspaces: + assert "name" in item + assert "path" in item + assert "format" in item + assert "size" in item + assert "mtime" in item + assert "session_name" in item + assert "source" in item + assert item["source"] == "global" + + +def test_ls_ndjson_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --ndjson.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "ws1.yaml").write_text("session_name: s1\nwindows: []") + (tmuxp_dir / "ws2.yaml").write_text("session_name: s2\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--ndjson"]) + + output = capsys.readouterr().out + lines = [line for line in output.strip().split("\n") if line] + + assert len(lines) == 2 + + # Each line should be valid JSON + for line in lines: + data = json.loads(line) + assert "name" in data + assert "session_name" in data + assert "source" in data + + +def test_ls_tree_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --tree.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + # Tree mode shows directory header + assert "~/.tmuxp" in output + # And indented workspace name + assert "dev" in output + + +def test_ls_empty_directory( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls with no workspaces.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "No workspaces found" in output + + +def test_ls_tree_shows_session_name_if_different( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode shows session_name if it differs from file name.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + # File named "myfile" but session is "actual-session" + (tmuxp_dir / "myfile.yaml").write_text("session_name: actual-session\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + assert "myfile" in output + assert "actual-session" in output + + +def test_ls_finds_local_workspace_in_cwd( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should find .tmuxp.yaml in current directory.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + +def test_ls_finds_local_workspace_in_parent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should find .tmuxp.yaml in parent directory.""" + home = tmp_path / "home" + project = home / "project" + subdir = project / "src" / "module" + subdir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(subdir) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: parent-local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + +def test_ls_shows_local_and_global( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should show both local and global workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Local workspace + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + # Global workspace + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert "Global workspaces (~/.tmuxp):" in output + assert ".tmuxp" in output + assert "global" in output + + +def test_ls_json_includes_source_for_local( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should include source=local for local workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + sources = {item["source"] for item in workspaces} + assert sources == {"local", "global"} + + local_items = [item for item in workspaces if item["source"] == "local"] + global_items = [item for item in workspaces if item["source"] == "global"] + + assert len(local_items) == 1 + assert len(global_items) == 1 + assert local_items[0]["session_name"] == "local" + assert global_items[0]["session_name"] == "global" + + +def test_ls_local_shows_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Local workspaces should show their path in flat mode.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + # Local workspace output shows path (with ~ contraction) + assert "~/project/.tmuxp.yaml" in output + + +def test_ls_full_flag_subparser() -> None: + """Verify --full argument is added to subparser.""" + import argparse + + from tmuxp.cli.ls import create_ls_subparser + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--full"]) + + assert args.full is True + + +def test_get_workspace_info_include_config(tmp_path: pathlib.Path) -> None: + """Test _get_workspace_info with include_config=True.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows:\n - window_name: editor\n") + + info = _get_workspace_info(workspace, include_config=True) + + assert "config" in info + assert info["config"]["session_name"] == "test" + assert len(info["config"]["windows"]) == 1 + + +def test_get_workspace_info_no_config_by_default(tmp_path: pathlib.Path) -> None: + """Test _get_workspace_info without include_config doesn't include config.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows: []\n") + + info = _get_workspace_info(workspace) + + assert "config" not in info + + +def test_ls_json_full_includes_config( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output with --full includes config content.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json", "--full"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + assert len(workspaces) == 1 + assert "config" in workspaces[0] + assert workspaces[0]["config"]["session_name"] == "dev" + assert workspaces[0]["config"]["windows"][0]["window_name"] == "editor" + + +def test_ls_full_tree_shows_windows( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode with --full shows window/pane hierarchy.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " layout: main-horizontal\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree", "--full"]) + + output = capsys.readouterr().out + + assert "dev" in output + assert "editor" in output + assert "main-horizontal" in output + assert "shell" in output + assert "pane 0" in output + + +def test_ls_full_flat_shows_windows( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Flat mode with --full shows window/pane hierarchy.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\nwindows:\n - window_name: code\n panes:\n - nvim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + assert "Global workspaces (~/.tmuxp):" in output + assert "dev" in output + assert "code" in output + assert "pane 0" in output + + +def test_ls_full_without_json_no_config_in_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Non-JSON with --full shows tree but not raw config.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\nwindows:\n - window_name: editor\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + # Should show tree structure, not raw config keys + assert "editor" in output + assert "session_name:" not in output # Raw YAML not in output + + +def test_ls_shows_global_workspace_dirs_section( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Human output shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "1 workspace" in output + assert "active" in output + assert "~/.config/tmuxp" in output + assert "not found" in output + + +def test_ls_global_header_shows_active_dir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Global workspaces header shows active directory path.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should include the active directory + assert "Global workspaces (~/.tmuxp):" in output + + +def test_ls_json_includes_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output includes global_workspace_dirs array.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON should be an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data + + # Check global_workspace_dirs structure + dirs = data["global_workspace_dirs"] + assert isinstance(dirs, list) + assert len(dirs) >= 1 + + for d in dirs: + assert "path" in d + assert "source" in d + assert "exists" in d + assert "workspace_count" in d + assert "active" in d + + # Find the active one + active_dirs = [d for d in dirs if d["active"]] + assert len(active_dirs) == 1 + assert active_dirs[0]["path"] == "~/.tmuxp" + assert active_dirs[0]["exists"] is True + assert active_dirs[0]["workspace_count"] == 1 + + +def test_ls_json_empty_still_has_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output with no workspaces still includes global_workspace_dirs.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) # Empty directory + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + assert "workspaces" in data + assert "global_workspace_dirs" in data + assert len(data["workspaces"]) == 0 + assert len(data["global_workspace_dirs"]) >= 1 + + +def test_ls_xdg_takes_precedence_in_header( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """When XDG dir exists, it shows in header instead of ~/.tmuxp.""" + home = tmp_path / "home" + xdg_tmuxp = home / ".config" / "tmuxp" + xdg_tmuxp.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (xdg_tmuxp / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should show XDG path when it's active + assert "Global workspaces (~/.config/tmuxp):" in output + + +def test_ls_tree_shows_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode also shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "active" in output diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py new file mode 100644 index 0000000000..3112f60d02 --- /dev/null +++ b/tests/cli/test_output.py @@ -0,0 +1,242 @@ +"""Tests for output formatting utilities.""" + +from __future__ import annotations + +import io +import json +import sys + +import pytest + +from tmuxp.cli._output import OutputFormatter, OutputMode, get_output_mode + + +def test_output_mode_values() -> None: + """Verify OutputMode enum values.""" + assert OutputMode.HUMAN.value == "human" + assert OutputMode.JSON.value == "json" + assert OutputMode.NDJSON.value == "ndjson" + + +def test_output_mode_members() -> None: + """Verify all expected members exist.""" + members = list(OutputMode) + assert len(members) == 3 + assert OutputMode.HUMAN in members + assert OutputMode.JSON in members + assert OutputMode.NDJSON in members + + +def test_get_output_mode_default_is_human() -> None: + """Default mode should be HUMAN when no flags.""" + assert get_output_mode(json_flag=False, ndjson_flag=False) == OutputMode.HUMAN + + +def test_get_output_mode_json_flag() -> None: + """JSON flag should return JSON mode.""" + assert get_output_mode(json_flag=True, ndjson_flag=False) == OutputMode.JSON + + +def test_get_output_mode_ndjson_flag() -> None: + """NDJSON flag should return NDJSON mode.""" + assert get_output_mode(json_flag=False, ndjson_flag=True) == OutputMode.NDJSON + + +def test_get_output_mode_ndjson_takes_precedence() -> None: + """NDJSON should take precedence when both flags set.""" + assert get_output_mode(json_flag=True, ndjson_flag=True) == OutputMode.NDJSON + + +def test_output_formatter_default_mode_is_human() -> None: + """Default mode should be HUMAN.""" + formatter = OutputFormatter() + assert formatter.mode == OutputMode.HUMAN + + +def test_output_formatter_explicit_mode() -> None: + """Mode can be set explicitly.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter.mode == OutputMode.JSON + + +def test_output_formatter_json_buffer_initially_empty() -> None: + """JSON buffer should start empty.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter._json_buffer == [] + + +def test_emit_json_buffers_data() -> None: + """JSON mode should buffer data.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + assert len(formatter._json_buffer) == 2 + assert formatter._json_buffer[0] == {"name": "test1"} + assert formatter._json_buffer[1] == {"name": "test2"} + + +def test_emit_human_does_nothing() -> None: + """HUMAN mode emit should not buffer or output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit({"name": "test"}) + assert formatter._json_buffer == [] + + +def test_emit_ndjson_writes_immediately(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode should write one JSON object per line immediately.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit({"name": "test1", "value": 42}) + formatter.emit({"name": "test2", "value": 43}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0]) == {"name": "test1", "value": 42} + assert json.loads(lines[1]) == {"name": "test2", "value": 43} + + +def test_emit_text_human_outputs(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode should output text.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "Hello, world!\n" + + +def test_emit_text_json_silent(capsys: pytest.CaptureFixture[str]) -> None: + """JSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_text_ndjson_silent(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_json_outputs_array(capsys: pytest.CaptureFixture[str]) -> None: + """JSON mode finalize should output formatted array.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + formatter.finalize() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert isinstance(data, list) + assert len(data) == 2 + assert data[0] == {"name": "test1"} + assert data[1] == {"name": "test2"} + + +def test_finalize_json_clears_buffer() -> None: + """JSON mode finalize should clear the buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test"}) + assert len(formatter._json_buffer) == 1 + + # Capture output to prevent test pollution + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.finalize() + finally: + sys.stdout = old_stdout + + assert formatter._json_buffer == [] + + +def test_finalize_json_empty_buffer_no_output( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode finalize with empty buffer should not output.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_human_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode finalize should do nothing.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_ndjson_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode finalize should do nothing (already streamed).""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_json_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete JSON output workflow.""" + formatter = OutputFormatter(OutputMode.JSON) + + # Emit several records + formatter.emit({"name": "workspace1", "path": "/path/1"}) + formatter.emit({"name": "workspace2", "path": "/path/2"}) + + # Nothing output yet + captured = capsys.readouterr() + assert captured.out == "" + + # Finalize outputs everything + formatter.finalize() + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 2 + + +def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete NDJSON output workflow.""" + formatter = OutputFormatter(OutputMode.NDJSON) + + # Each emit outputs immediately + formatter.emit({"name": "workspace1"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace1"} + + formatter.emit({"name": "workspace2"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace2"} + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" + + +def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete HUMAN output workflow.""" + formatter = OutputFormatter(OutputMode.HUMAN) + + # emit does nothing in human mode + formatter.emit({"name": "ignored"}) + + # emit_text outputs text + formatter.emit_text("Workspace: test") + formatter.emit_text(" Path: /path/to/test") + + captured = capsys.readouterr() + assert "Workspace: test" in captured.out + assert "Path: /path/to/test" in captured.out + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" diff --git a/tests/cli/test_prompt_colors.py b/tests/cli/test_prompt_colors.py new file mode 100644 index 0000000000..65a7093f65 --- /dev/null +++ b/tests/cli/test_prompt_colors.py @@ -0,0 +1,156 @@ +"""Tests for colored prompt utilities.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tests.cli.conftest import ANSI_BLUE, ANSI_CYAN, ANSI_RESET +from tmuxp.cli._colors import ColorMode, Colors + + +def test_prompt_bool_choice_indicator_muted(colors_always: Colors) -> None: + """Verify [Y/n] uses muted color (blue).""" + # Test the muted color is applied to choice indicators + result = colors_always.muted("[Y/n]") + assert ANSI_BLUE in result # blue foreground + assert "[Y/n]" in result + assert result.endswith(ANSI_RESET) + + +def test_prompt_bool_choice_indicator_variants(colors_always: Colors) -> None: + """Verify all choice indicator variants are colored.""" + for indicator in ["[Y/n]", "[y/N]", "[y/n]"]: + result = colors_always.muted(indicator) + assert ANSI_BLUE in result + assert indicator in result + + +def test_prompt_default_value_uses_info(colors_always: Colors) -> None: + """Verify default path uses info color (cyan).""" + path = "/home/user/.tmuxp/session.yaml" + result = colors_always.info(f"[{path}]") + assert ANSI_CYAN in result # cyan foreground + assert path in result + assert result.endswith(ANSI_RESET) + + +def test_prompt_choices_list_muted(colors_always: Colors) -> None: + """Verify (yaml, json) uses muted color (blue).""" + choices = "(yaml, json)" + result = colors_always.muted(choices) + assert ANSI_BLUE in result # blue foreground + assert choices in result + + +def test_prompts_respect_no_color_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify NO_COLOR disables prompt colors.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.AUTO) + + assert colors.muted("[Y/n]") == "[Y/n]" + assert colors.info("[default]") == "[default]" + + +def test_prompt_combined_format(colors_always: Colors) -> None: + """Verify combined prompt format with choices and default.""" + name = "Convert to" + choices_str = colors_always.muted("(yaml, json)") + default_str = colors_always.info("[yaml]") + prompt = f"{name} - {choices_str} {default_str}" + + # Should contain both blue (muted) and cyan (info) ANSI codes + assert ANSI_BLUE in prompt # blue for choices + assert ANSI_CYAN in prompt # cyan for default + assert "Convert to" in prompt + assert "yaml, json" in prompt + + +def test_prompt_colors_disabled_returns_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text without ANSI codes.""" + assert colors_never.muted("[Y/n]") == "[Y/n]" + assert colors_never.info("[/path/to/file]") == "[/path/to/file]" + assert "\033[" not in colors_never.muted("test") + assert "\033[" not in colors_never.info("test") + + +def test_prompt_empty_input_no_default_reprompts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify prompt() re-prompts when user enters empty input with no default. + + This is a regression test for the bug where pressing Enter with no default + would cause an AssertionError instead of re-prompting. + """ + from tmuxp.cli.utils import prompt + + # Simulate: first input is empty (user presses Enter), second input is valid + inputs = iter(["", "valid_input"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = prompt("Enter value") + assert result == "valid_input" + + +def test_prompt_empty_input_with_value_proc_no_crash( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify prompt() with value_proc doesn't crash on empty input. + + This is a regression test for the AssertionError that occurred when + value_proc was provided but input was empty and no default was set. + """ + from tmuxp.cli.utils import prompt + + def validate_path(val: str) -> str: + """Validate that path is absolute.""" + if not val.startswith("/"): + msg = "Must be absolute path" + raise ValueError(msg) + return val + + # Simulate: first input is empty, second input is valid + inputs = iter(["", "/valid/path"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = prompt("Enter path", value_proc=validate_path) + assert result == "/valid/path" + + +def test_prompt_default_uses_private_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """Verify prompt() masks home directory in default value display. + + The displayed default should use PrivatePath to show ~ instead of + the full home directory path. + """ + import pathlib + + from tmuxp.cli.utils import prompt + + # Create a path under the user's home directory + home = pathlib.Path.home() + test_path = str(home / ".tmuxp" / "session.yaml") + + # Capture what prompt displays + displayed_prompt = None + + def capture_input(prompt_text: str) -> str: + nonlocal displayed_prompt + displayed_prompt = prompt_text + return "" # User presses Enter, accepting default + + monkeypatch.setattr("builtins.input", capture_input) + + result = prompt("Save to", default=test_path) + + # The result should be the original path (for actual saving) + assert result == test_path + + # The displayed prompt should use ~ instead of full home path + assert displayed_prompt is not None + assert "~/.tmuxp/session.yaml" in displayed_prompt + assert str(home) not in displayed_prompt diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py new file mode 100644 index 0000000000..e4140b2645 --- /dev/null +++ b/tests/cli/test_search.py @@ -0,0 +1,865 @@ +"""CLI tests for tmuxp search command.""" + +from __future__ import annotations + +import json +import pathlib +import re +import typing as t + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._output import OutputFormatter, OutputMode +from tmuxp.cli.search import ( + DEFAULT_FIELDS, + InvalidFieldError, + SearchPattern, + SearchToken, + WorkspaceFields, + WorkspaceSearchResult, + _get_field_values, + _output_search_results, + compile_search_patterns, + create_search_subparser, + evaluate_match, + extract_workspace_fields, + find_search_matches, + highlight_matches, + normalize_fields, + parse_query_terms, +) + + +class NormalizeFieldsFixture(t.NamedTuple): + """Test fixture for normalize_fields.""" + + test_id: str + fields: list[str] | None + expected: tuple[str, ...] + raises: type[Exception] | None + + +NORMALIZE_FIELDS_FIXTURES: list[NormalizeFieldsFixture] = [ + NormalizeFieldsFixture( + test_id="none_returns_defaults", + fields=None, + expected=DEFAULT_FIELDS, + raises=None, + ), + NormalizeFieldsFixture( + test_id="name_alias", + fields=["n"], + expected=("name",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="session_aliases", + fields=["s", "session", "session_name"], + expected=("session_name",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="path_alias", + fields=["p"], + expected=("path",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="window_alias", + fields=["w"], + expected=("window",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="multiple_fields", + fields=["name", "s", "window"], + expected=("name", "session_name", "window"), + raises=None, + ), + NormalizeFieldsFixture( + test_id="invalid_field", + fields=["invalid"], + expected=(), + raises=InvalidFieldError, + ), + NormalizeFieldsFixture( + test_id="case_insensitive", + fields=["NAME", "Session"], + expected=("name", "session_name"), + raises=None, + ), +] + + +@pytest.mark.parametrize( + NormalizeFieldsFixture._fields, + NORMALIZE_FIELDS_FIXTURES, + ids=[test.test_id for test in NORMALIZE_FIELDS_FIXTURES], +) +def test_normalize_fields( + test_id: str, + fields: list[str] | None, + expected: tuple[str, ...], + raises: type[Exception] | None, +) -> None: + """Test normalize_fields function.""" + if raises: + with pytest.raises(raises): + normalize_fields(fields) + else: + result = normalize_fields(fields) + assert result == expected + + +class ParseQueryTermsFixture(t.NamedTuple): + """Test fixture for parse_query_terms.""" + + test_id: str + terms: list[str] + expected_count: int + expected_first_fields: tuple[str, ...] | None + expected_first_pattern: str | None + + +PARSE_QUERY_TERMS_FIXTURES: list[ParseQueryTermsFixture] = [ + ParseQueryTermsFixture( + test_id="simple_term", + terms=["dev"], + expected_count=1, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="name_prefix", + terms=["name:dev"], + expected_count=1, + expected_first_fields=("name",), + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="session_prefix", + terms=["s:production"], + expected_count=1, + expected_first_fields=("session_name",), + expected_first_pattern="production", + ), + ParseQueryTermsFixture( + test_id="multiple_terms", + terms=["dev", "production"], + expected_count=2, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="url_not_field", + terms=["http://example.com"], + expected_count=1, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="http://example.com", + ), + ParseQueryTermsFixture( + test_id="empty_pattern_skipped", + terms=["name:"], + expected_count=0, + expected_first_fields=None, + expected_first_pattern=None, + ), + ParseQueryTermsFixture( + test_id="path_with_colons", + terms=["path:/home/user/project"], + expected_count=1, + expected_first_fields=("path",), + expected_first_pattern="/home/user/project", + ), +] + + +@pytest.mark.parametrize( + ParseQueryTermsFixture._fields, + PARSE_QUERY_TERMS_FIXTURES, + ids=[test.test_id for test in PARSE_QUERY_TERMS_FIXTURES], +) +def test_parse_query_terms( + test_id: str, + terms: list[str], + expected_count: int, + expected_first_fields: tuple[str, ...] | None, + expected_first_pattern: str | None, +) -> None: + """Test parse_query_terms function.""" + result = parse_query_terms(terms) + + assert len(result) == expected_count + + if expected_count > 0: + assert result[0].fields == expected_first_fields + assert result[0].pattern == expected_first_pattern + + +class CompileSearchPatternsFixture(t.NamedTuple): + """Test fixture for compile_search_patterns.""" + + test_id: str + pattern: str + ignore_case: bool + smart_case: bool + fixed_strings: bool + word_regexp: bool + test_string: str + should_match: bool + + +COMPILE_SEARCH_PATTERNS_FIXTURES: list[CompileSearchPatternsFixture] = [ + CompileSearchPatternsFixture( + test_id="basic_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="case_sensitive_no_match", + pattern="DEV", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="ignore_case_match", + pattern="DEV", + ignore_case=True, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="smart_case_lowercase", + pattern="dev", + ignore_case=False, + smart_case=True, + fixed_strings=False, + word_regexp=False, + test_string="DEVELOPMENT", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="smart_case_uppercase_no_match", + pattern="Dev", + ignore_case=False, + smart_case=True, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="fixed_strings_literal", + pattern="dev.*", + ignore_case=False, + smart_case=False, + fixed_strings=True, + word_regexp=False, + test_string="dev.*project", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="fixed_strings_no_regex", + pattern="dev.*", + ignore_case=False, + smart_case=False, + fixed_strings=True, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="word_boundary_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=True, + test_string="my dev project", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="word_boundary_no_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=True, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="regex_pattern", + pattern="dev.*proj", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="dev-project", + should_match=True, + ), +] + + +@pytest.mark.parametrize( + CompileSearchPatternsFixture._fields, + COMPILE_SEARCH_PATTERNS_FIXTURES, + ids=[test.test_id for test in COMPILE_SEARCH_PATTERNS_FIXTURES], +) +def test_compile_search_patterns( + test_id: str, + pattern: str, + ignore_case: bool, + smart_case: bool, + fixed_strings: bool, + word_regexp: bool, + test_string: str, + should_match: bool, +) -> None: + """Test compile_search_patterns function.""" + tokens = [SearchToken(fields=("name",), pattern=pattern)] + + patterns = compile_search_patterns( + tokens, + ignore_case=ignore_case, + smart_case=smart_case, + fixed_strings=fixed_strings, + word_regexp=word_regexp, + ) + + assert len(patterns) == 1 + match = patterns[0].regex.search(test_string) + assert bool(match) == should_match + + +def test_compile_search_patterns_invalid_regex_raises() -> None: + """Invalid regex pattern raises re.error.""" + tokens = [SearchToken(fields=("name",), pattern="[invalid(")] + with pytest.raises(re.error): + compile_search_patterns(tokens) + + +def test_extract_workspace_fields_basic(tmp_path: pathlib.Path) -> None: + """Extract fields from basic workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: my-session\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) + + fields = extract_workspace_fields(workspace) + + assert fields["name"] == "test" + assert fields["session_name"] == "my-session" + assert "editor" in fields["windows"] + assert "shell" in fields["windows"] + assert "vim" in fields["panes"] + + +def test_extract_workspace_fields_pane_shell_command_dict( + tmp_path: pathlib.Path, +) -> None: + """Extract pane commands from dict format.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: test\n" + "windows:\n" + " - window_name: main\n" + " panes:\n" + " - shell_command: git status\n" + " - shell_command:\n" + " - npm install\n" + " - npm start\n" + ) + + fields = extract_workspace_fields(workspace) + + assert "git status" in fields["panes"] + assert "npm install" in fields["panes"] + assert "npm start" in fields["panes"] + + +def test_extract_workspace_fields_missing_session_name(tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows:\n - window_name: main\n") + + fields = extract_workspace_fields(workspace) + + assert fields["session_name"] == "" + assert fields["name"] == "test" + + +def test_extract_workspace_fields_invalid_yaml(tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") + + fields = extract_workspace_fields(workspace) + + assert fields["name"] == "test" + assert fields["session_name"] == "" + assert fields["windows"] == [] + + +def test_extract_workspace_fields_path_uses_privacy( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Path should use PrivatePath for home contraction.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\n") + + fields = extract_workspace_fields(workspace) + + assert fields["path"] == "~/test.yaml" + + +@pytest.fixture() +def sample_fields() -> WorkspaceFields: + """Sample workspace fields for testing.""" + return WorkspaceFields( + name="dev-project", + path="~/.tmuxp/dev-project.yaml", + session_name="development", + windows=["editor", "shell", "logs"], + panes=["vim", "git status", "tail -f"], + ) + + +def test_evaluate_match_single_pattern(sample_fields: WorkspaceFields) -> None: + """Single pattern should match.""" + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "name" in matches + + +def test_evaluate_match_single_pattern_no_match(sample_fields: WorkspaceFields) -> None: + """Single pattern should not match.""" + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is False + assert matches == {} + + +def test_evaluate_match_and_logic_all_match(sample_fields: WorkspaceFields) -> None: + """AND logic - all patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="project", regex=re.compile("project")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) + + assert matched is True + + +def test_evaluate_match_and_logic_partial_no_match( + sample_fields: WorkspaceFields, +) -> None: + """AND logic - only some patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) + + assert matched is False + + +def test_evaluate_match_or_logic_any_match(sample_fields: WorkspaceFields) -> None: + """OR logic - any pattern matches.""" + p1 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + p2 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=True) + + assert matched is True + + +def test_evaluate_match_window_field(sample_fields: WorkspaceFields) -> None: + """Search in window field.""" + pattern = SearchPattern( + fields=("window",), + raw="editor", + regex=re.compile("editor"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "window" in matches + + +def test_evaluate_match_pane_field(sample_fields: WorkspaceFields) -> None: + """Search in pane field.""" + pattern = SearchPattern( + fields=("pane",), + raw="vim", + regex=re.compile("vim"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "pane" in matches + + +def test_evaluate_match_multiple_fields(sample_fields: WorkspaceFields) -> None: + """Pattern searches multiple fields.""" + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + # Should find matches in both name and session_name + assert "name" in matches or "session_name" in matches + + +def test_find_search_matches_basic(tmp_path: pathlib.Path) -> None: + """Basic search finds matching workspace.""" + workspace = tmp_path / "dev.yaml" + workspace.write_text("session_name: development\n") + + pattern = SearchPattern( + fields=("session_name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["source"] == "global" + + +def test_find_search_matches_no_match(tmp_path: pathlib.Path) -> None: + """Search returns empty when no match.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 0 + + +def test_find_search_matches_invert(tmp_path: pathlib.Path) -> None: + """Invert match returns non-matching workspaces.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern], invert_match=True) + + assert len(results) == 1 + + +def test_find_search_matches_multiple_workspaces(tmp_path: pathlib.Path) -> None: + """Search across multiple workspaces.""" + ws1 = tmp_path / "dev.yaml" + ws1.write_text("session_name: development\n") + + ws2 = tmp_path / "prod.yaml" + ws2.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(ws1, "global"), (ws2, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["fields"]["name"] == "dev" + + +def test_highlight_matches_no_colors() -> None: + """Colors disabled returns original text.""" + colors = Colors(ColorMode.NEVER) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert result == "development" + + +def test_highlight_matches_with_colors() -> None: + """Colors enabled adds ANSI codes.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert "\033[" in result # Contains ANSI escape + assert "dev" in result + + +def test_highlight_matches_no_match() -> None: + """No match returns original text.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert result == "development" + + +def test_highlight_matches_multiple() -> None: + """Multiple matches in same string.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="e", + regex=re.compile("e"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + # Should contain multiple highlights + assert result.count("\033[") > 1 + + +def test_highlight_matches_empty_patterns() -> None: + """Empty patterns returns original text.""" + colors = Colors(ColorMode.ALWAYS) + + result = highlight_matches("development", [], colors=colors) + + assert result == "development" + + +@pytest.fixture() +def sample_fields_for_get_field_values() -> WorkspaceFields: + """Sample workspace fields.""" + return WorkspaceFields( + name="test", + path="~/.tmuxp/test.yaml", + session_name="test-session", + windows=["editor", "shell"], + panes=["vim", "bash"], + ) + + +def test_get_field_values_scalar( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Scalar field returns list with one item.""" + result = _get_field_values(sample_fields_for_get_field_values, "name") + assert result == ["test"] + + +def test_get_field_values_list( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """List field returns the list.""" + result = _get_field_values(sample_fields_for_get_field_values, "windows") + assert result == ["editor", "shell"] + + +def test_get_field_values_window_alias( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Window alias maps to windows.""" + result = _get_field_values(sample_fields_for_get_field_values, "window") + assert result == ["editor", "shell"] + + +def test_get_field_values_pane_alias( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Pane alias maps to panes.""" + result = _get_field_values(sample_fields_for_get_field_values, "pane") + assert result == ["vim", "bash"] + + +def test_get_field_values_empty() -> None: + """Empty value returns empty list.""" + fields = WorkspaceFields( + name="", + path="", + session_name="", + windows=[], + panes=[], + ) + result = _get_field_values(fields, "name") + assert result == [] + + +def test_search_subparser_creation() -> None: + """Subparser can be created successfully.""" + import argparse + + parser = argparse.ArgumentParser() + result = create_search_subparser(parser) + + assert result is parser + + +def test_search_subparser_options() -> None: + """Parser has expected options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + # Parse with various options + args = parser.parse_args(["-i", "-S", "-F", "-w", "-v", "--any", "pattern"]) + + assert args.ignore_case is True + assert args.smart_case is True + assert args.fixed_strings is True + assert args.word_regexp is True + assert args.invert_match is True + assert args.match_any is True + assert args.query_terms == ["pattern"] + + +def test_search_subparser_output_format_options() -> None: + """Parser supports output format options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args_json = parser.parse_args(["--json", "test"]) + assert args_json.output_json is True + + args_ndjson = parser.parse_args(["--ndjson", "test"]) + assert args_ndjson.output_ndjson is True + + +def test_search_subparser_field_option() -> None: + """Parser supports field option.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args = parser.parse_args(["-f", "name", "-f", "session", "test"]) + + assert args.field == ["name", "session"] + + +def test_output_search_results_no_results(capsys: pytest.CaptureFixture[str]) -> None: + """No results outputs warning message.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.HUMAN) + + _output_search_results([], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + assert "No matching" in captured.out + + +def test_output_search_results_json(capsys: pytest.CaptureFixture[str]) -> None: + """JSON output mode produces valid JSON.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.JSON) + + result: WorkspaceSearchResult = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=["editor"], + panes=["vim"], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 1 + assert data[0]["name"] == "dev" + + +def test_output_search_results_ndjson(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON output mode produces one JSON per line.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.NDJSON) + + result: WorkspaceSearchResult = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=[], + panes=[], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + # Filter out human-readable lines + json_lines = [line for line in lines if line.startswith("{")] + assert len(json_lines) >= 1 + data = json.loads(json_lines[0]) + assert data["name"] == "dev" diff --git a/tests/cli/test_shell.py b/tests/cli/test_shell.py index 79e130e493..da97afb237 100644 --- a/tests/cli/test_shell.py +++ b/tests/cli/test_shell.py @@ -208,6 +208,9 @@ def test_shell( ) -> None: """CLI tests for tmuxp shell.""" monkeypatch.setenv("HOME", str(tmp_path)) + # Clear outer tmux environment to prevent pane ID conflicts + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) window_name = "my_window" window = session.new_window(window_name=window_name) window.split() diff --git a/tests/cli/test_shell_colors.py b/tests/cli/test_shell_colors.py new file mode 100644 index 0000000000..de92e748ae --- /dev/null +++ b/tests/cli/test_shell_colors.py @@ -0,0 +1,95 @@ +"""Tests for CLI colors in shell command.""" + +from __future__ import annotations + +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp.cli._colors import Colors + +# Shell command color output tests + + +def test_shell_launch_message_format(colors_always: Colors) -> None: + """Verify launch message format with shell type and session.""" + shell_name = "ipython" + session_name = "my-session" + output = ( + colors_always.muted("Launching ") + + colors_always.highlight(shell_name, bold=False) + + colors_always.muted(" shell for session ") + + colors_always.info(session_name) + + colors_always.muted("...") + ) + # Should contain blue, magenta, and cyan ANSI codes + assert ANSI_BLUE in output # blue for muted + assert ANSI_MAGENTA in output # magenta for highlight + assert ANSI_CYAN in output # cyan for session name + assert shell_name in output + assert session_name in output + + +def test_shell_pdb_launch_message(colors_always: Colors) -> None: + """Verify pdb launch message format.""" + output = ( + colors_always.muted("Launching ") + + colors_always.highlight("pdb", bold=False) + + colors_always.muted(" shell...") + ) + assert ANSI_BLUE in output # blue for muted + assert ANSI_MAGENTA in output # magenta for pdb + assert "pdb" in output + + +def test_shell_highlight_not_bold(colors_always: Colors) -> None: + """Verify shell name uses highlight without bold for subtlety.""" + result = colors_always.highlight("best", bold=False) + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD not in result # no bold - subtle emphasis + assert "best" in result + + +def test_shell_session_name_uses_info(colors_always: Colors) -> None: + """Verify session name uses info color (cyan).""" + session_name = "dev-session" + result = colors_always.info(session_name) + assert ANSI_CYAN in result # cyan foreground + assert session_name in result + + +def test_shell_muted_for_static_text(colors_always: Colors) -> None: + """Verify static text uses muted color (blue).""" + result = colors_always.muted("Launching ") + assert ANSI_BLUE in result # blue foreground + assert "Launching" in result + + +def test_shell_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + shell_name = "ipython" + session_name = "my-session" + output = ( + colors_never.muted("Launching ") + + colors_never.highlight(shell_name, bold=False) + + colors_never.muted(" shell for session ") + + colors_never.info(session_name) + + colors_never.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Launching {shell_name} shell for session {session_name}..." + + +def test_shell_various_shell_names(colors_always: Colors) -> None: + """Verify all shell types can be highlighted.""" + shell_types = [ + "best", + "pdb", + "code", + "ptipython", + "ptpython", + "ipython", + "bpython", + ] + for shell_name in shell_types: + result = colors_always.highlight(shell_name, bold=False) + assert ANSI_MAGENTA in result + assert shell_name in result diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 0000000000..5a82d0c1f7 --- /dev/null +++ b/tests/docs/__init__.py @@ -0,0 +1 @@ +"""Tests for documentation extensions.""" diff --git a/tests/docs/_ext/__init__.py b/tests/docs/_ext/__init__.py new file mode 100644 index 0000000000..deec786b4f --- /dev/null +++ b/tests/docs/_ext/__init__.py @@ -0,0 +1 @@ +"""Tests for docs/_ext/ Sphinx extensions.""" diff --git a/tests/docs/_ext/conftest.py b/tests/docs/_ext/conftest.py new file mode 100644 index 0000000000..bb2cf99b57 --- /dev/null +++ b/tests/docs/_ext/conftest.py @@ -0,0 +1,11 @@ +"""Fixtures and configuration for docs extension tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Add docs/_ext to path so we can import the extension module +docs_ext_path = 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/test_cli_usage_lexer.py b/tests/docs/_ext/test_cli_usage_lexer.py new file mode 100644 index 0000000000..88ff40fb82 --- /dev/null +++ b/tests/docs/_ext/test_cli_usage_lexer.py @@ -0,0 +1,357 @@ +"""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="session-name", + expected_token_type="Token.Name.Label", + expected_value="session-name", + ), + TokenTypeFixture( + test_id="command_name", + input_text="tmuxp", + expected_token_type="Token.Name.Label", + expected_value="tmuxp", + ), + 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="-S socket-path", + option="-S", + value="socket-path", + ), + ShortOptionValueFixture( + test_id="uppercase_value", + input_text="-c COMMAND", + option="-c", + value="COMMAND", + ), + ShortOptionValueFixture( + test_id="simple_value", + input_text="-L name", + option="-L", + 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="[--best | --pdb | --code]", + expected_contains=[ + ("Token.Name.Tag", "--best"), + ("Token.Operator", "|"), + ("Token.Name.Tag", "--pdb"), + ("Token.Operator", "|"), + ("Token.Name.Tag", "--code"), + ], + ), + UsageStringFixture( + test_id="subcommand", + input_text="usage: tmuxp shell", + expected_contains=[ + ("Token.Generic.Heading", "usage:"), + ("Token.Name.Label", "tmuxp"), + ("Token.Name.Label", "shell"), + ], + ), + UsageStringFixture( + test_id="positional_args", + input_text="[session-name] [window-name]", + expected_contains=[ + ("Token.Punctuation", "["), + ("Token.Name.Label", "session-name"), + ("Token.Punctuation", "]"), + ("Token.Punctuation", "["), + ("Token.Name.Label", "window-name"), + ("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 tmuxp usage output test --- + + +def test_tmuxp_shell_usage() -> None: + """Test real tmuxp shell usage output tokenization.""" + usage_text = """\ +usage: tmuxp shell [-h] [-S socket-path] [-L socket-name] [-c COMMAND] + [--best | --pdb | --code | --ptipython | --ptpython | + --ipython | --bpython] [--use-pythonrc] [--no-startup] + [--use-vi-mode] [--no-vi-mode] + [session-name] [window-name]""" + + tokens = get_tokens(usage_text) + + # Check key elements are present + # Note: COMMAND after -c is Name.Variable (option value), not Name.Constant + expected = [ + ("Token.Generic.Heading", "usage:"), + ("Token.Name.Label", "tmuxp"), + ("Token.Name.Label", "shell"), + ("Token.Name.Attribute", "-h"), + ("Token.Name.Attribute", "-S"), + ("Token.Name.Variable", "socket-path"), + ("Token.Name.Attribute", "-c"), + ("Token.Name.Variable", "COMMAND"), # Option value, not standalone metavar + ("Token.Name.Tag", "--best"), + ("Token.Name.Tag", "--pdb"), + ("Token.Name.Tag", "--use-pythonrc"), + ("Token.Name.Tag", "--no-vi-mode"), + ("Token.Name.Label", "session-name"), + ("Token.Name.Label", "window-name"), + ] + + 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/docs/_ext/test_pretty_argparse.py b/tests/docs/_ext/test_pretty_argparse.py new file mode 100644 index 0000000000..f74e0af4c8 --- /dev/null +++ b/tests/docs/_ext/test_pretty_argparse.py @@ -0,0 +1,854 @@ +"""Tests for pretty_argparse sphinx extension.""" + +from __future__ import annotations + +import typing as t + +import pytest +from docutils import nodes +from pretty_argparse import ( # type: ignore[import-not-found] + _is_examples_section, + _is_usage_block, + _reorder_nodes, + is_base_examples_term, + is_examples_term, + make_section_id, + make_section_title, + strip_ansi, + transform_definition_list, +) + +# --- 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 + + +# --- 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 + + +# --- 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:", "tmuxp 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:", "tmuxp ls") + dl += _make_dl_item("Machine-readable output examples:", "tmuxp 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:", "tmuxp ls") + dl += _make_dl_item("Field-scoped examples:", "tmuxp ls --field name") + dl += _make_dl_item("Machine-readable output examples:", "tmuxp 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:", "tmuxp 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:", "tmuxp 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: tmuxp load", + 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" diff --git a/tests/test_util.py b/tests/test_util.py index e5a4a51327..baa592e9a8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -155,8 +155,13 @@ def test_get_session_should_default_to_local_attached_session( def test_get_session_should_return_first_session_if_no_active_session( server: Server, + monkeypatch: pytest.MonkeyPatch, ) -> None: """get_session() should return first session if no active session.""" + # Clear outer tmux environment to ensure no active pane interferes + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + first_session = server.new_session(session_name="myfirstsession") server.new_session(session_name="mysecondsession") diff --git a/tests/workspace/test_finder.py b/tests/workspace/test_finder.py index 20aee8703a..dd0b270bb8 100644 --- a/tests/workspace/test_finder.py +++ b/tests/workspace/test_finder.py @@ -13,6 +13,7 @@ from tmuxp.workspace.finders import ( find_workspace_file, get_workspace_dir, + get_workspace_dir_candidates, in_cwd, in_dir, is_pure_name, @@ -357,3 +358,159 @@ def check_cmd(config_arg: str) -> _pytest.capture.CaptureResult[str]: match="workspace-file not found in workspace dir", ): assert "workspace-file not found in workspace dir" in check_cmd("moo").err + + +class GetWorkspaceDirCandidatesFixture(t.NamedTuple): + """Test fixture for get_workspace_dir_candidates().""" + + test_id: str + env_vars: dict[str, str] # Relative to tmp_path + dirs_to_create: list[str] # Relative to tmp_path + workspace_files: dict[str, int] # dir -> count of .yaml files to create + expected_active_suffix: str # Suffix of active dir (e.g., ".tmuxp") + expected_candidates_count: int + + +GET_WORKSPACE_DIR_CANDIDATES_FIXTURES: list[GetWorkspaceDirCandidatesFixture] = [ + GetWorkspaceDirCandidatesFixture( + test_id="default_tmuxp_only", + env_vars={}, + dirs_to_create=["home/.tmuxp"], + workspace_files={"home/.tmuxp": 3}, + expected_active_suffix=".tmuxp", + expected_candidates_count=2, # ~/.config/tmuxp (not found) + ~/.tmuxp + ), + GetWorkspaceDirCandidatesFixture( + test_id="xdg_exists_tmuxp_not", + env_vars={"XDG_CONFIG_HOME": "home/.config"}, + dirs_to_create=["home/.config/tmuxp"], + workspace_files={"home/.config/tmuxp": 2}, + expected_active_suffix="tmuxp", # XDG takes precedence + expected_candidates_count=2, + ), + GetWorkspaceDirCandidatesFixture( + test_id="both_exist_xdg_wins", + env_vars={"XDG_CONFIG_HOME": "home/.config"}, + dirs_to_create=["home/.config/tmuxp", "home/.tmuxp"], + workspace_files={"home/.config/tmuxp": 2, "home/.tmuxp": 5}, + expected_active_suffix="tmuxp", # XDG wins when both exist + expected_candidates_count=2, + ), + GetWorkspaceDirCandidatesFixture( + test_id="custom_configdir", + env_vars={"TMUXP_CONFIGDIR": "custom/workspaces"}, + dirs_to_create=["custom/workspaces", "home/.tmuxp"], + workspace_files={"custom/workspaces": 4}, + expected_active_suffix="workspaces", + expected_candidates_count=3, # custom + ~/.config/tmuxp + ~/.tmuxp + ), + GetWorkspaceDirCandidatesFixture( + test_id="none_exist_fallback", + env_vars={}, + dirs_to_create=[], # No dirs created + workspace_files={}, + expected_active_suffix=".tmuxp", # Falls back to ~/.tmuxp + expected_candidates_count=2, + ), +] + + +@pytest.mark.parametrize( + list(GetWorkspaceDirCandidatesFixture._fields), + GET_WORKSPACE_DIR_CANDIDATES_FIXTURES, + ids=[test.test_id for test in GET_WORKSPACE_DIR_CANDIDATES_FIXTURES], +) +def test_get_workspace_dir_candidates( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + env_vars: dict[str, str], + dirs_to_create: list[str], + workspace_files: dict[str, int], + expected_active_suffix: str, + expected_candidates_count: int, +) -> None: + """Test get_workspace_dir_candidates() returns correct candidates.""" + # Setup home directory + home = tmp_path / "home" + home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(home)) + + # Clear any existing env vars that might interfere + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + # Create directories + for dir_path in dirs_to_create: + (tmp_path / dir_path).mkdir(parents=True, exist_ok=True) + + # Create workspace files + for dir_path, count in workspace_files.items(): + dir_full = tmp_path / dir_path + for i in range(count): + (dir_full / f"workspace{i}.yaml").touch() + + # Set environment variables (resolve relative paths) + for var, path in env_vars.items(): + monkeypatch.setenv(var, str(tmp_path / path)) + + # Get candidates + candidates = get_workspace_dir_candidates() + + # Verify count + assert len(candidates) == expected_candidates_count, ( + f"Expected {expected_candidates_count} candidates, got {len(candidates)}" + ) + + # Verify structure + for candidate in candidates: + assert "path" in candidate + assert "source" in candidate + assert "exists" in candidate + assert "workspace_count" in candidate + assert "active" in candidate + + # Verify exactly one is active + active_candidates = [c for c in candidates if c["active"]] + assert len(active_candidates) == 1, "Expected exactly one active candidate" + + # Verify active suffix + active = active_candidates[0] + assert active["path"].endswith(expected_active_suffix), ( + f"Expected active path to end with '{expected_active_suffix}', " + f"got '{active['path']}'" + ) + + # Verify workspace counts for existing directories + for candidate in candidates: + if candidate["exists"]: + # Find the matching dir in workspace_files by the last path component + candidate_suffix = candidate["path"].split("/")[-1] + for dir_path, expected_count in workspace_files.items(): + if dir_path.endswith(candidate_suffix): + assert candidate["workspace_count"] == expected_count, ( + f"Expected {expected_count} workspaces in {candidate['path']}, " + f"got {candidate['workspace_count']}" + ) + break # Found match, stop checking + + +def test_get_workspace_dir_candidates_uses_private_path( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that get_workspace_dir_candidates() masks home directory with ~.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + candidates = get_workspace_dir_candidates() + + # All paths should use ~ instead of full home path + for candidate in candidates: + path = candidate["path"] + assert str(home) not in path, f"Path should be masked: {path}" + assert path.startswith("~"), f"Path should start with ~: {path}" diff --git a/tests/workspace/test_finders_local.py b/tests/workspace/test_finders_local.py new file mode 100644 index 0000000000..5482df3abc --- /dev/null +++ b/tests/workspace/test_finders_local.py @@ -0,0 +1,285 @@ +"""Tests for local workspace file discovery with upward traversal.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +from tmuxp.workspace.finders import LOCAL_WORKSPACE_FILES, find_local_workspace_files + + +class LocalWorkspaceTestFixture(t.NamedTuple): + """Test fixture for local workspace file discovery.""" + + test_id: str + files: dict[str, str] # {dir_relative_to_home: filename} + start_dir: str # relative to home + expected_count: int + expected_paths: list[str] # relative to home + + +LOCAL_WORKSPACE_TEST_FIXTURES: list[LocalWorkspaceTestFixture] = [ + LocalWorkspaceTestFixture( + test_id="only_in_cwd", + files={"project": ".tmuxp.yaml"}, + start_dir="project", + expected_count=1, + expected_paths=["project/.tmuxp.yaml"], + ), + LocalWorkspaceTestFixture( + test_id="only_in_parent", + files={"project": ".tmuxp.yaml"}, + start_dir="project/subdir", + expected_count=1, + expected_paths=["project/.tmuxp.yaml"], + ), + LocalWorkspaceTestFixture( + test_id="in_cwd_and_parent", + files={ + "project": ".tmuxp.yaml", + "project/subdir": ".tmuxp.yaml", + }, + start_dir="project/subdir", + expected_count=2, + expected_paths=["project/subdir/.tmuxp.yaml", "project/.tmuxp.yaml"], + ), + LocalWorkspaceTestFixture( + test_id="multiple_ancestors", + files={ + "a": ".tmuxp.yaml", + "a/b": ".tmuxp.yaml", + "a/b/c": ".tmuxp.yaml", + }, + start_dir="a/b/c/d", + expected_count=3, + expected_paths=[ + "a/b/c/.tmuxp.yaml", + "a/b/.tmuxp.yaml", + "a/.tmuxp.yaml", + ], + ), + LocalWorkspaceTestFixture( + test_id="no_local_files", + files={}, + start_dir="project", + expected_count=0, + expected_paths=[], + ), + LocalWorkspaceTestFixture( + test_id="json_format", + files={"project": ".tmuxp.json"}, + start_dir="project", + expected_count=1, + expected_paths=["project/.tmuxp.json"], + ), + LocalWorkspaceTestFixture( + test_id="yml_format", + files={"project": ".tmuxp.yml"}, + start_dir="project", + expected_count=1, + expected_paths=["project/.tmuxp.yml"], + ), + LocalWorkspaceTestFixture( + test_id="stops_at_home", + files={ + "": ".tmuxp.yaml", # In home dir itself + "project": ".tmuxp.yaml", + }, + start_dir="project", + expected_count=2, # Includes home but stops there + expected_paths=["project/.tmuxp.yaml", ".tmuxp.yaml"], + ), +] + + +@pytest.mark.parametrize( + LocalWorkspaceTestFixture._fields, + LOCAL_WORKSPACE_TEST_FIXTURES, + ids=[test.test_id for test in LOCAL_WORKSPACE_TEST_FIXTURES], +) +def test_find_local_workspace_files( + test_id: str, + files: dict[str, str], + start_dir: str, + expected_count: int, + expected_paths: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test local workspace file discovery with upward traversal.""" + home = tmp_path / "home" + home.mkdir() + + # Create directory structure and files + for rel_dir, filename in files.items(): + dir_path = home / rel_dir if rel_dir else home + dir_path.mkdir(parents=True, exist_ok=True) + (dir_path / filename).write_text("session_name: test\n") + + # Ensure start directory exists + start_path = home / start_dir + start_path.mkdir(parents=True, exist_ok=True) + + # Mock home directory + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Run the function + result = find_local_workspace_files(start_path, stop_at_home=True) + + assert len(result) == expected_count + + # Verify paths match expected (relative to home) + result_relative = [str(p.relative_to(home)) for p in result] + assert result_relative == expected_paths + + +class TestFindLocalWorkspaceEdgeCases: + """Edge case tests for local workspace discovery.""" + + def test_at_home_directory( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test behavior when starting at home directory.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (home / ".tmuxp.yaml").write_text("session_name: home\n") + + result = find_local_workspace_files(home, stop_at_home=True) + + assert len(result) == 1 + assert result[0] == home / ".tmuxp.yaml" + + def test_at_filesystem_root( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test traversal stops at filesystem root.""" + # This test verifies no infinite loop at root + result = find_local_workspace_files(pathlib.Path("/"), stop_at_home=False) + # Should complete without error; result depends on system state + assert isinstance(result, list) + + def test_yaml_precedence_over_json( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test .yaml is preferred when multiple formats exist.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Create both formats + (project / ".tmuxp.yaml").write_text("session_name: yaml\n") + (project / ".tmuxp.json").write_text('{"session_name": "json"}') + + result = find_local_workspace_files(project, stop_at_home=True) + + assert len(result) == 1 + assert result[0].name == ".tmuxp.yaml" + + def test_yml_precedence_over_json( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test .yml is preferred when .yaml not present but .json exists.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Create yml and json (no yaml) + (project / ".tmuxp.yml").write_text("session_name: yml\n") + (project / ".tmuxp.json").write_text('{"session_name": "json"}') + + result = find_local_workspace_files(project, stop_at_home=True) + + assert len(result) == 1 + assert result[0].name == ".tmuxp.yml" + + def test_stop_at_home_false_continues_past_home( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test stop_at_home=False continues traversal past home.""" + # Create structure: /grandparent/home/project + grandparent = tmp_path / "grandparent" + home = grandparent / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Put config in grandparent (above home) + (grandparent / ".tmuxp.yaml").write_text("session_name: grandparent\n") + (project / ".tmuxp.yaml").write_text("session_name: project\n") + + # With stop_at_home=True, should only find project config + result_stop = find_local_workspace_files(project, stop_at_home=True) + assert len(result_stop) == 1 + assert "project" in str(result_stop[0]) + + # With stop_at_home=False, should find both + result_continue = find_local_workspace_files(project, stop_at_home=False) + assert len(result_continue) >= 2 + + def test_default_start_dir_uses_cwd( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test that None start_dir uses current working directory.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.chdir(project) + + (project / ".tmuxp.yaml").write_text("session_name: cwd\n") + + result = find_local_workspace_files(None, stop_at_home=True) + + assert len(result) == 1 + assert result[0] == project / ".tmuxp.yaml" + + def test_symlinked_directory( + self, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test behavior with symlinked directories.""" + home = tmp_path / "home" + real_project = home / "real_project" + real_project.mkdir(parents=True) + symlink_project = home / "symlink_project" + symlink_project.symlink_to(real_project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (real_project / ".tmuxp.yaml").write_text("session_name: test\n") + + result = find_local_workspace_files(symlink_project, stop_at_home=True) + + assert len(result) == 1 + + +class TestLocalWorkspaceFilesConstant: + """Tests for LOCAL_WORKSPACE_FILES constant.""" + + def test_constant_order(self) -> None: + """Verify LOCAL_WORKSPACE_FILES has correct order (yaml, yml, json).""" + assert LOCAL_WORKSPACE_FILES == [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"] + + def test_constant_is_list(self) -> None: + """Verify LOCAL_WORKSPACE_FILES is a list.""" + assert isinstance(LOCAL_WORKSPACE_FILES, list) + assert len(LOCAL_WORKSPACE_FILES) == 3 diff --git a/uv.lock b/uv.lock index df85976075..44de80ddb2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10, <4.0" resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] @@ -38,16 +39,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.0" +version = "4.12.1" 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/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, + { 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" }, ] [[package]] @@ -74,11 +75,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] [[package]] @@ -206,101 +207,101 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/08/bdd7ccca14096f7eb01412b87ac11e5d16e4cb54b6e328afc9dee8bdaec1/coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070", size = 217979, upload-time = "2025-12-08T13:12:14.505Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/d1302e3416298a28b5663ae1117546a745d9d19fde7e28402b2c5c3e2109/coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98", size = 218496, upload-time = "2025-12-08T13:12:16.237Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/d36c354c8b2a320819afcea6bffe72839efd004b98d1d166b90801d49d57/coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5", size = 245237, upload-time = "2025-12-08T13:12:17.858Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/be5e85631e0eec547873d8b08dd67a5f6b111ecfe89a86e40b89b0c1c61c/coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e", size = 247061, upload-time = "2025-12-08T13:12:19.132Z" }, - { url = "https://files.pythonhosted.org/packages/0f/45/a5e8fa0caf05fbd8fa0402470377bff09cc1f026d21c05c71e01295e55ab/coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33", size = 248928, upload-time = "2025-12-08T13:12:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/f5/42/ffb5069b6fd1b95fae482e02f3fecf380d437dd5a39bae09f16d2e2e7e01/coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791", size = 245931, upload-time = "2025-12-08T13:12:22.243Z" }, - { url = "https://files.pythonhosted.org/packages/95/6e/73e809b882c2858f13e55c0c36e94e09ce07e6165d5644588f9517efe333/coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032", size = 246968, upload-time = "2025-12-08T13:12:23.52Z" }, - { url = "https://files.pythonhosted.org/packages/87/08/64ebd9e64b6adb8b4a4662133d706fbaccecab972e0b3ccc23f64e2678ad/coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9", size = 244972, upload-time = "2025-12-08T13:12:24.781Z" }, - { url = "https://files.pythonhosted.org/packages/12/97/f4d27c6fe0cb375a5eced4aabcaef22de74766fb80a3d5d2015139e54b22/coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f", size = 245241, upload-time = "2025-12-08T13:12:28.041Z" }, - { url = "https://files.pythonhosted.org/packages/0c/94/42f8ae7f633bf4c118bf1038d80472f9dade88961a466f290b81250f7ab7/coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8", size = 245847, upload-time = "2025-12-08T13:12:29.337Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2f/6369ca22b6b6d933f4f4d27765d313d8914cc4cce84f82a16436b1a233db/coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f", size = 220573, upload-time = "2025-12-08T13:12:30.905Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dc/a6a741e519acceaeccc70a7f4cfe5d030efc4b222595f0677e101af6f1f3/coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303", size = 221509, upload-time = "2025-12-08T13:12:32.09Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, - { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" }, - { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" }, - { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" }, - { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" }, - { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" }, - { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" }, - { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, - { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, - { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" }, - { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" }, - { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" }, - { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" }, - { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" }, - { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" }, - { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, - { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, - { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, - { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, - { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, - { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, - { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, - { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, - { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, - { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, - { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, - { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, - { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, - { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, - { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, - { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, - { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, - { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, - { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, - { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, - { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, - { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, - { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, - { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, - { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, - { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, - { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, - { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, - { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, - { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, - { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, - { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, - { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, + { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, + { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, + { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, ] [package.optional-dependencies] @@ -322,7 +323,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -331,7 +332,7 @@ wheels = [ [[package]] name = "furo" -version = "2025.9.25" +version = "2025.12.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "accessible-pygments" }, @@ -341,9 +342,9 @@ dependencies = [ { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-basic-ng" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/29/ff3b83a1ffce74676043ab3e7540d398e0b1ce7660917a00d7c4958b93da/furo-2025.9.25.tar.gz", hash = "sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98", size = 1662007, upload-time = "2025-09-25T21:37:19.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/69/964b55f389c289e16ba2a5dfe587c3c462aac09e24123f09ddf703889584/furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe", size = 340409, upload-time = "2025-09-25T21:37:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] [[package]] @@ -409,75 +410,75 @@ wheels = [ [[package]] name = "librt" -version = "0.7.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/d9/6f3d3fcf5e5543ed8a60cc70fa7d50508ed60b8a10e9af6d2058159ab54e/librt-0.7.3.tar.gz", hash = "sha256:3ec50cf65235ff5c02c5b747748d9222e564ad48597122a361269dd3aa808798", size = 144549, upload-time = "2025-12-06T19:04:45.553Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/66/79a14e672256ef58144a24eb49adb338ec02de67ff4b45320af6504682ab/librt-0.7.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2682162855a708e3270eba4b92026b93f8257c3e65278b456c77631faf0f4f7a", size = 54707, upload-time = "2025-12-06T19:03:10.881Z" }, - { url = "https://files.pythonhosted.org/packages/58/fa/b709c65a9d5eab85f7bcfe0414504d9775aaad6e78727a0327e175474caa/librt-0.7.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:440c788f707c061d237c1e83edf6164ff19f5c0f823a3bf054e88804ebf971ec", size = 56670, upload-time = "2025-12-06T19:03:12.107Z" }, - { url = "https://files.pythonhosted.org/packages/3a/56/0685a0772ec89ddad4c00e6b584603274c3d818f9a68e2c43c4eb7b39ee9/librt-0.7.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399938edbd3d78339f797d685142dd8a623dfaded023cf451033c85955e4838a", size = 161045, upload-time = "2025-12-06T19:03:13.444Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d9/863ada0c5ce48aefb89df1555e392b2209fcb6daee4c153c031339b9a89b/librt-0.7.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1975eda520957c6e0eb52d12968dd3609ffb7eef05d4223d097893d6daf1d8a7", size = 169532, upload-time = "2025-12-06T19:03:14.699Z" }, - { url = "https://files.pythonhosted.org/packages/68/a0/71da6c8724fd16c31749905ef1c9e11de206d9301b5be984bf2682b4efb3/librt-0.7.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9da128d0edf990cf0d2ca011b02cd6f639e79286774bd5b0351245cbb5a6e51", size = 183277, upload-time = "2025-12-06T19:03:16.446Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/9c97bf2f8338ba1914de233ea312bba2bbd7c59f43f807b3e119796bab18/librt-0.7.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e19acfde38cb532a560b98f473adc741c941b7a9bc90f7294bc273d08becb58b", size = 179045, upload-time = "2025-12-06T19:03:17.838Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b1/ceea067f489e904cb4ddcca3c9b06ba20229bc3fa7458711e24a5811f162/librt-0.7.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7b4f57f7a0c65821c5441d98c47ff7c01d359b1e12328219709bdd97fdd37f90", size = 173521, upload-time = "2025-12-06T19:03:19.17Z" }, - { url = "https://files.pythonhosted.org/packages/7a/41/6cb18f5da9c89ed087417abb0127a445a50ad4eaf1282ba5b52588187f47/librt-0.7.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:256793988bff98040de23c57cf36e1f4c2f2dc3dcd17537cdac031d3b681db71", size = 193592, upload-time = "2025-12-06T19:03:20.637Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3c/fcef208746584e7c78584b7aedc617130c4a4742cb8273361bbda8b183b5/librt-0.7.3-cp310-cp310-win32.whl", hash = "sha256:fcb72249ac4ea81a7baefcbff74df7029c3cb1cf01a711113fa052d563639c9c", size = 47201, upload-time = "2025-12-06T19:03:21.764Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bf/d8a6c35d1b2b789a4df9b3ddb1c8f535ea373fde2089698965a8f0d62138/librt-0.7.3-cp310-cp310-win_amd64.whl", hash = "sha256:4887c29cadbdc50640179e3861c276325ff2986791e6044f73136e6e798ff806", size = 54371, upload-time = "2025-12-06T19:03:23.231Z" }, - { url = "https://files.pythonhosted.org/packages/21/e6/f6391f5c6f158d31ed9af6bd1b1bcd3ffafdea1d816bc4219d0d90175a7f/librt-0.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:687403cced6a29590e6be6964463835315905221d797bc5c934a98750fe1a9af", size = 54711, upload-time = "2025-12-06T19:03:24.6Z" }, - { url = "https://files.pythonhosted.org/packages/ab/1b/53c208188c178987c081560a0fcf36f5ca500d5e21769596c845ef2f40d4/librt-0.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24d70810f6e2ea853ff79338001533716b373cc0f63e2a0be5bc96129edb5fb5", size = 56664, upload-time = "2025-12-06T19:03:25.969Z" }, - { url = "https://files.pythonhosted.org/packages/cb/5c/d9da832b9a1e5f8366e8a044ec80217945385b26cb89fd6f94bfdc7d80b0/librt-0.7.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf8c7735fbfc0754111f00edda35cf9e98a8d478de6c47b04eaa9cef4300eaa7", size = 161701, upload-time = "2025-12-06T19:03:27.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/1e0a7aba15e78529dd21f233076b876ee58c8b8711b1793315bdd3b263b0/librt-0.7.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32d43610dff472eab939f4d7fbdd240d1667794192690433672ae22d7af8445", size = 171040, upload-time = "2025-12-06T19:03:28.482Z" }, - { url = "https://files.pythonhosted.org/packages/69/46/3cfa325c1c2bc25775ec6ec1718cfbec9cff4ac767d37d2d3a2d1cc6f02c/librt-0.7.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:adeaa886d607fb02563c1f625cf2ee58778a2567c0c109378da8f17ec3076ad7", size = 184720, upload-time = "2025-12-06T19:03:29.599Z" }, - { url = "https://files.pythonhosted.org/packages/99/bb/e4553433d7ac47f4c75d0a7e59b13aee0e08e88ceadbee356527a9629b0a/librt-0.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:572a24fc5958c61431da456a0ef1eeea6b4989d81eeb18b8e5f1f3077592200b", size = 180731, upload-time = "2025-12-06T19:03:31.201Z" }, - { url = "https://files.pythonhosted.org/packages/35/89/51cd73006232981a3106d4081fbaa584ac4e27b49bc02266468d3919db03/librt-0.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6488e69d408b492e08bfb68f20c4a899a354b4386a446ecd490baff8d0862720", size = 174565, upload-time = "2025-12-06T19:03:32.818Z" }, - { url = "https://files.pythonhosted.org/packages/42/54/0578a78b587e5aa22486af34239a052c6366835b55fc307bc64380229e3f/librt-0.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ed028fc3d41adda916320712838aec289956c89b4f0a361ceadf83a53b4c047a", size = 195247, upload-time = "2025-12-06T19:03:34.434Z" }, - { url = "https://files.pythonhosted.org/packages/b5/0a/ee747cd999753dd9447e50b98fc36ee433b6c841a42dbf6d47b64b32a56e/librt-0.7.3-cp311-cp311-win32.whl", hash = "sha256:2cf9d73499486ce39eebbff5f42452518cc1f88d8b7ea4a711ab32962b176ee2", size = 47514, upload-time = "2025-12-06T19:03:35.959Z" }, - { url = "https://files.pythonhosted.org/packages/ec/af/8b13845178dec488e752878f8e290f8f89e7e34ae1528b70277aa1a6dd1e/librt-0.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:35f1609e3484a649bb80431310ddbec81114cd86648f1d9482bc72a3b86ded2e", size = 54695, upload-time = "2025-12-06T19:03:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/ae59578501b1a25850266778f59279f4f3e726acc5c44255bfcb07b4bc57/librt-0.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:550fdbfbf5bba6a2960b27376ca76d6aaa2bd4b1a06c4255edd8520c306fcfc0", size = 48142, upload-time = "2025-12-06T19:03:38.263Z" }, - { url = "https://files.pythonhosted.org/packages/29/90/ed8595fa4e35b6020317b5ea8d226a782dcbac7a997c19ae89fb07a41c66/librt-0.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fa9ac2e49a6bee56e47573a6786cb635e128a7b12a0dc7851090037c0d397a3", size = 55687, upload-time = "2025-12-06T19:03:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f6/6a20702a07b41006cb001a759440cb6b5362530920978f64a2b2ae2bf729/librt-0.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e980cf1ed1a2420a6424e2ed884629cdead291686f1048810a817de07b5eb18", size = 57127, upload-time = "2025-12-06T19:03:40.3Z" }, - { url = "https://files.pythonhosted.org/packages/79/f3/b0c4703d5ffe9359b67bb2ccb86c42d4e930a363cfc72262ac3ba53cff3e/librt-0.7.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e094e445c37c57e9ec612847812c301840239d34ccc5d153a982fa9814478c60", size = 165336, upload-time = "2025-12-06T19:03:41.369Z" }, - { url = "https://files.pythonhosted.org/packages/02/69/3ba05b73ab29ccbe003856232cea4049769be5942d799e628d1470ed1694/librt-0.7.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aca73d70c3f553552ba9133d4a09e767dcfeee352d8d8d3eb3f77e38a3beb3ed", size = 174237, upload-time = "2025-12-06T19:03:42.44Z" }, - { url = "https://files.pythonhosted.org/packages/22/ad/d7c2671e7bf6c285ef408aa435e9cd3fdc06fd994601e1f2b242df12034f/librt-0.7.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c634a0a6db395fdaba0361aa78395597ee72c3aad651b9a307a3a7eaf5efd67e", size = 189017, upload-time = "2025-12-06T19:03:44.01Z" }, - { url = "https://files.pythonhosted.org/packages/f4/94/d13f57193148004592b618555f296b41d2d79b1dc814ff8b3273a0bf1546/librt-0.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a59a69deeb458c858b8fea6acf9e2acd5d755d76cd81a655256bc65c20dfff5b", size = 183983, upload-time = "2025-12-06T19:03:45.834Z" }, - { url = "https://files.pythonhosted.org/packages/02/10/b612a9944ebd39fa143c7e2e2d33f2cb790205e025ddd903fb509a3a3bb3/librt-0.7.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d91e60ac44bbe3a77a67af4a4c13114cbe9f6d540337ce22f2c9eaf7454ca71f", size = 177602, upload-time = "2025-12-06T19:03:46.944Z" }, - { url = "https://files.pythonhosted.org/packages/1f/48/77bc05c4cc232efae6c5592c0095034390992edbd5bae8d6cf1263bb7157/librt-0.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:703456146dc2bf430f7832fd1341adac5c893ec3c1430194fdcefba00012555c", size = 199282, upload-time = "2025-12-06T19:03:48.069Z" }, - { url = "https://files.pythonhosted.org/packages/12/aa/05916ccd864227db1ffec2a303ae34f385c6b22d4e7ce9f07054dbcf083c/librt-0.7.3-cp312-cp312-win32.whl", hash = "sha256:b7c1239b64b70be7759554ad1a86288220bbb04d68518b527783c4ad3fb4f80b", size = 47879, upload-time = "2025-12-06T19:03:49.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/92/7f41c42d31ea818b3c4b9cc1562e9714bac3c676dd18f6d5dd3d0f2aa179/librt-0.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:ef59c938f72bdbc6ab52dc50f81d0637fde0f194b02d636987cea2ab30f8f55a", size = 54972, upload-time = "2025-12-06T19:03:50.335Z" }, - { url = "https://files.pythonhosted.org/packages/3f/dc/53582bbfb422311afcbc92adb75711f04e989cec052f08ec0152fbc36c9c/librt-0.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:ff21c554304e8226bf80c3a7754be27c6c3549a9fec563a03c06ee8f494da8fc", size = 48338, upload-time = "2025-12-06T19:03:51.431Z" }, - { url = "https://files.pythonhosted.org/packages/93/7d/e0ce1837dfb452427db556e6d4c5301ba3b22fe8de318379fbd0593759b9/librt-0.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56f2a47beda8409061bc1c865bef2d4bd9ff9255219402c0817e68ab5ad89aed", size = 55742, upload-time = "2025-12-06T19:03:52.459Z" }, - { url = "https://files.pythonhosted.org/packages/be/c0/3564262301e507e1d5cf31c7d84cb12addf0d35e05ba53312494a2eba9a4/librt-0.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14569ac5dd38cfccf0a14597a88038fb16811a6fede25c67b79c6d50fc2c8fdc", size = 57163, upload-time = "2025-12-06T19:03:53.516Z" }, - { url = "https://files.pythonhosted.org/packages/be/ac/245e72b7e443d24a562f6047563c7f59833384053073ef9410476f68505b/librt-0.7.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6038ccbd5968325a5d6fd393cf6e00b622a8de545f0994b89dd0f748dcf3e19e", size = 165840, upload-time = "2025-12-06T19:03:54.918Z" }, - { url = "https://files.pythonhosted.org/packages/98/af/587e4491f40adba066ba39a450c66bad794c8d92094f936a201bfc7c2b5f/librt-0.7.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d39079379a9a28e74f4d57dc6357fa310a1977b51ff12239d7271ec7e71d67f5", size = 174827, upload-time = "2025-12-06T19:03:56.082Z" }, - { url = "https://files.pythonhosted.org/packages/78/21/5b8c60ea208bc83dd00421022a3874330685d7e856404128dc3728d5d1af/librt-0.7.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8837d5a52a2d7aa9f4c3220a8484013aed1d8ad75240d9a75ede63709ef89055", size = 189612, upload-time = "2025-12-06T19:03:57.507Z" }, - { url = "https://files.pythonhosted.org/packages/da/2f/8b819169ef696421fb81cd04c6cdf225f6e96f197366001e9d45180d7e9e/librt-0.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:399bbd7bcc1633c3e356ae274a1deb8781c7bf84d9c7962cc1ae0c6e87837292", size = 184584, upload-time = "2025-12-06T19:03:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fc/af9d225a9395b77bd7678362cb055d0b8139c2018c37665de110ca388022/librt-0.7.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8d8cf653e798ee4c4e654062b633db36984a1572f68c3aa25e364a0ddfbbb910", size = 178269, upload-time = "2025-12-06T19:03:59.769Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d8/7b4fa1683b772966749d5683aa3fd605813defffe157833a8fa69cc89207/librt-0.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f03484b54bf4ae80ab2e504a8d99d20d551bfe64a7ec91e218010b467d77093", size = 199852, upload-time = "2025-12-06T19:04:00.901Z" }, - { url = "https://files.pythonhosted.org/packages/77/e8/4598413aece46ca38d9260ef6c51534bd5f34b5c21474fcf210ce3a02123/librt-0.7.3-cp313-cp313-win32.whl", hash = "sha256:44b3689b040df57f492e02cd4f0bacd1b42c5400e4b8048160c9d5e866de8abe", size = 47936, upload-time = "2025-12-06T19:04:02.054Z" }, - { url = "https://files.pythonhosted.org/packages/af/80/ac0e92d5ef8c6791b3e2c62373863827a279265e0935acdf807901353b0e/librt-0.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:6b407c23f16ccc36614c136251d6b32bf30de7a57f8e782378f1107be008ddb0", size = 54965, upload-time = "2025-12-06T19:04:03.224Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fd/042f823fcbff25c1449bb4203a29919891ca74141b68d3a5f6612c4ce283/librt-0.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:abfc57cab3c53c4546aee31859ef06753bfc136c9d208129bad23e2eca39155a", size = 48350, upload-time = "2025-12-06T19:04:04.234Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ae/c6ecc7bb97134a71b5241e8855d39964c0e5f4d96558f0d60593892806d2/librt-0.7.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:120dd21d46ff875e849f1aae19346223cf15656be489242fe884036b23d39e93", size = 55175, upload-time = "2025-12-06T19:04:05.308Z" }, - { url = "https://files.pythonhosted.org/packages/cf/bc/2cc0cb0ab787b39aa5c7645cd792433c875982bdf12dccca558b89624594/librt-0.7.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1617bea5ab31266e152871208502ee943cb349c224846928a1173c864261375e", size = 56881, upload-time = "2025-12-06T19:04:06.674Z" }, - { url = "https://files.pythonhosted.org/packages/8e/87/397417a386190b70f5bf26fcedbaa1515f19dce33366e2684c6b7ee83086/librt-0.7.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93b2a1f325fefa1482516ced160c8c7b4b8d53226763fa6c93d151fa25164207", size = 163710, upload-time = "2025-12-06T19:04:08.437Z" }, - { url = "https://files.pythonhosted.org/packages/c9/37/7338f85b80e8a17525d941211451199845093ca242b32efbf01df8531e72/librt-0.7.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d4801db8354436fd3936531e7f0e4feb411f62433a6b6cb32bb416e20b529f", size = 172471, upload-time = "2025-12-06T19:04:10.124Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e0/741704edabbfae2c852fedc1b40d9ed5a783c70ed3ed8e4fe98f84b25d13/librt-0.7.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11ad45122bbed42cfc8b0597450660126ef28fd2d9ae1a219bc5af8406f95678", size = 186804, upload-time = "2025-12-06T19:04:11.586Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d1/0a82129d6ba242f3be9af34815be089f35051bc79619f5c27d2c449ecef6/librt-0.7.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6b4e7bff1d76dd2b46443078519dc75df1b5e01562345f0bb740cea5266d8218", size = 181817, upload-time = "2025-12-06T19:04:12.802Z" }, - { url = "https://files.pythonhosted.org/packages/4f/32/704f80bcf9979c68d4357c46f2af788fbf9d5edda9e7de5786ed2255e911/librt-0.7.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:d86f94743a11873317094326456b23f8a5788bad9161fd2f0e52088c33564620", size = 175602, upload-time = "2025-12-06T19:04:14.004Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6d/4355cfa0fae0c062ba72f541d13db5bc575770125a7ad3d4f46f4109d305/librt-0.7.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:754a0d09997095ad764ccef050dd5bf26cbf457aab9effcba5890dad081d879e", size = 196497, upload-time = "2025-12-06T19:04:15.487Z" }, - { url = "https://files.pythonhosted.org/packages/2e/eb/ac6d8517d44209e5a712fde46f26d0055e3e8969f24d715f70bd36056230/librt-0.7.3-cp314-cp314-win32.whl", hash = "sha256:fbd7351d43b80d9c64c3cfcb50008f786cc82cba0450e8599fdd64f264320bd3", size = 44678, upload-time = "2025-12-06T19:04:16.688Z" }, - { url = "https://files.pythonhosted.org/packages/e9/93/238f026d141faf9958da588c761a0812a1a21c98cc54a76f3608454e4e59/librt-0.7.3-cp314-cp314-win_amd64.whl", hash = "sha256:d376a35c6561e81d2590506804b428fc1075fcc6298fc5bb49b771534c0ba010", size = 51689, upload-time = "2025-12-06T19:04:17.726Z" }, - { url = "https://files.pythonhosted.org/packages/52/44/43f462ad9dcf9ed7d3172fe2e30d77b980956250bd90e9889a9cca93df2a/librt-0.7.3-cp314-cp314-win_arm64.whl", hash = "sha256:cbdb3f337c88b43c3b49ca377731912c101178be91cb5071aac48faa898e6f8e", size = 44662, upload-time = "2025-12-06T19:04:18.771Z" }, - { url = "https://files.pythonhosted.org/packages/1d/35/fed6348915f96b7323241de97f26e2af481e95183b34991df12fd5ce31b1/librt-0.7.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9f0e0927efe87cd42ad600628e595a1a0aa1c64f6d0b55f7e6059079a428641a", size = 57347, upload-time = "2025-12-06T19:04:19.812Z" }, - { url = "https://files.pythonhosted.org/packages/9a/f2/045383ccc83e3fea4fba1b761796584bc26817b6b2efb6b8a6731431d16f/librt-0.7.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:020c6db391268bcc8ce75105cb572df8cb659a43fd347366aaa407c366e5117a", size = 59223, upload-time = "2025-12-06T19:04:20.862Z" }, - { url = "https://files.pythonhosted.org/packages/77/3f/c081f8455ab1d7f4a10dbe58463ff97119272ff32494f21839c3b9029c2c/librt-0.7.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7af7785f5edd1f418da09a8cdb9ec84b0213e23d597413e06525340bcce1ea4f", size = 183861, upload-time = "2025-12-06T19:04:21.963Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f5/73c5093c22c31fbeaebc25168837f05ebfd8bf26ce00855ef97a5308f36f/librt-0.7.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ccadf260bb46a61b9c7e89e2218f6efea9f3eeaaab4e3d1f58571890e54858e", size = 194594, upload-time = "2025-12-06T19:04:23.14Z" }, - { url = "https://files.pythonhosted.org/packages/78/b8/d5f17d4afe16612a4a94abfded94c16c5a033f183074fb130dfe56fc1a42/librt-0.7.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9883b2d819ce83f87ba82a746c81d14ada78784db431e57cc9719179847376e", size = 206759, upload-time = "2025-12-06T19:04:24.328Z" }, - { url = "https://files.pythonhosted.org/packages/36/2e/021765c1be85ee23ffd5b5b968bb4cba7526a4db2a0fc27dcafbdfc32da7/librt-0.7.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:59cb0470612d21fa1efddfa0dd710756b50d9c7fb6c1236bbf8ef8529331dc70", size = 203210, upload-time = "2025-12-06T19:04:25.544Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/9923656e42da4fd18c594bd08cf6d7e152d4158f8b808e210d967f0dcceb/librt-0.7.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1fe603877e1865b5fd047a5e40379509a4a60204aa7aa0f72b16f7a41c3f0712", size = 196708, upload-time = "2025-12-06T19:04:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/fc/0b/0708b886ac760e64d6fbe7e16024e4be3ad1a3629d19489a97e9cf4c3431/librt-0.7.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5460d99ed30f043595bbdc888f542bad2caeb6226b01c33cda3ae444e8f82d42", size = 217212, upload-time = "2025-12-06T19:04:27.892Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7f/12a73ff17bca4351e73d585dd9ebf46723c4a8622c4af7fe11a2e2d011ff/librt-0.7.3-cp314-cp314t-win32.whl", hash = "sha256:d09f677693328503c9e492e33e9601464297c01f9ebd966ea8fc5308f3069bfd", size = 45586, upload-time = "2025-12-06T19:04:29.116Z" }, - { url = "https://files.pythonhosted.org/packages/e2/df/8decd032ac9b995e4f5606cde783711a71094128d88d97a52e397daf2c89/librt-0.7.3-cp314-cp314t-win_amd64.whl", hash = "sha256:25711f364c64cab2c910a0247e90b51421e45dbc8910ceeb4eac97a9e132fc6f", size = 53002, upload-time = "2025-12-06T19:04:30.173Z" }, - { url = "https://files.pythonhosted.org/packages/de/0c/6605b6199de8178afe7efc77ca1d8e6db00453bc1d3349d27605c0f42104/librt-0.7.3-cp314-cp314t-win_arm64.whl", hash = "sha256:a9f9b661f82693eb56beb0605156c7fca57f535704ab91837405913417d6990b", size = 45647, upload-time = "2025-12-06T19:04:31.302Z" }, +version = "0.7.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/29/47f29026ca17f35cf299290292d5f8331f5077364974b7675a353179afa2/librt-0.7.7.tar.gz", hash = "sha256:81d957b069fed1890953c3b9c3895c7689960f233eea9a1d9607f71ce7f00b2c", size = 145910, upload-time = "2026-01-01T23:52:22.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/84/2cfb1f3b9b60bab52e16a220c931223fc8e963d0d7bb9132bef012aafc3f/librt-0.7.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4836c5645f40fbdc275e5670819bde5ab5f2e882290d304e3c6ddab1576a6d0", size = 54709, upload-time = "2026-01-01T23:50:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/19/a1/3127b277e9d3784a8040a54e8396d9ae5c64d6684dc6db4b4089b0eedcfb/librt-0.7.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae8aec43117a645a31e5f60e9e3a0797492e747823b9bda6972d521b436b4e8", size = 56658, upload-time = "2026-01-01T23:50:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/3a/e9/b91b093a5c42eb218120445f3fef82e0b977fa2225f4d6fc133d25cdf86a/librt-0.7.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:aea05f701ccd2a76b34f0daf47ca5068176ff553510b614770c90d76ac88df06", size = 161026, upload-time = "2026-01-01T23:50:50.853Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cb/1ded77d5976a79d7057af4a010d577ce4f473ff280984e68f4974a3281e5/librt-0.7.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b16ccaeff0ed4355dfb76fe1ea7a5d6d03b5ad27f295f77ee0557bc20a72495", size = 169529, upload-time = "2026-01-01T23:50:52.24Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/6ca5bdaa701e15f05000ac1a4c5d1475c422d3484bd3d1ca9e8c2f5be167/librt-0.7.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48c7e150c095d5e3cea7452347ba26094be905d6099d24f9319a8b475fcd3e0", size = 183271, upload-time = "2026-01-01T23:50:55.287Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2d/55c0e38073997b4bbb5ddff25b6d1bbba8c2f76f50afe5bb9c844b702f34/librt-0.7.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4dcee2f921a8632636d1c37f1bbdb8841d15666d119aa61e5399c5268e7ce02e", size = 179039, upload-time = "2026-01-01T23:50:56.807Z" }, + { url = "https://files.pythonhosted.org/packages/33/4e/3662a41ae8bb81b226f3968426293517b271d34d4e9fd4b59fc511f1ae40/librt-0.7.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14ef0f4ac3728ffd85bfc58e2f2f48fb4ef4fa871876f13a73a7381d10a9f77c", size = 173505, upload-time = "2026-01-01T23:50:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5d/cf768deb8bdcbac5f8c21fcb32dd483d038d88c529fd351bbe50590b945d/librt-0.7.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e4ab69fa37f8090f2d971a5d2bc606c7401170dbdae083c393d6cbf439cb45b8", size = 193570, upload-time = "2026-01-01T23:50:59.546Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/ee70effd13f1d651976d83a2812391f6203971740705e3c0900db75d4bce/librt-0.7.7-cp310-cp310-win32.whl", hash = "sha256:4bf3cc46d553693382d2abf5f5bd493d71bb0f50a7c0beab18aa13a5545c8900", size = 42600, upload-time = "2026-01-01T23:51:00.694Z" }, + { url = "https://files.pythonhosted.org/packages/f0/eb/dc098730f281cba76c279b71783f5de2edcba3b880c1ab84a093ef826062/librt-0.7.7-cp310-cp310-win_amd64.whl", hash = "sha256:f0c8fe5aeadd8a0e5b0598f8a6ee3533135ca50fd3f20f130f9d72baf5c6ac58", size = 48977, upload-time = "2026-01-01T23:51:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/f0/56/30b5c342518005546df78841cb0820ae85a17e7d07d521c10ef367306d0d/librt-0.7.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a487b71fbf8a9edb72a8c7a456dda0184642d99cd007bc819c0b7ab93676a8ee", size = 54709, upload-time = "2026-01-01T23:51:02.774Z" }, + { url = "https://files.pythonhosted.org/packages/72/78/9f120e3920b22504d4f3835e28b55acc2cc47c9586d2e1b6ba04c3c1bf01/librt-0.7.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f4d4efb218264ecf0f8516196c9e2d1a0679d9fb3bb15df1155a35220062eba8", size = 56663, upload-time = "2026-01-01T23:51:03.838Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/7d7a1ee7dfc1151836028eba25629afcf45b56bbc721293e41aa2e9b8934/librt-0.7.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b8bb331aad734b059c4b450cd0a225652f16889e286b2345af5e2c3c625c3d85", size = 161705, upload-time = "2026-01-01T23:51:04.917Z" }, + { url = "https://files.pythonhosted.org/packages/45/a5/952bc840ac8917fbcefd6bc5f51ad02b89721729814f3e2bfcc1337a76d6/librt-0.7.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:467dbd7443bda08338fc8ad701ed38cef48194017554f4c798b0a237904b3f99", size = 171029, upload-time = "2026-01-01T23:51:06.09Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bf/c017ff7da82dc9192cf40d5e802a48a25d00e7639b6465cfdcee5893a22c/librt-0.7.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50d1d1ee813d2d1a3baf2873634ba506b263032418d16287c92ec1cc9c1a00cb", size = 184704, upload-time = "2026-01-01T23:51:07.549Z" }, + { url = "https://files.pythonhosted.org/packages/77/ec/72f3dd39d2cdfd6402ab10836dc9cbf854d145226062a185b419c4f1624a/librt-0.7.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e5070cf3ec92d98f57574da0224f8c73faf1ddd6d8afa0b8c9f6e86997bc74", size = 180719, upload-time = "2026-01-01T23:51:09.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/86/06e7a1a81b246f3313bf515dd9613a1c81583e6fd7843a9f4d625c4e926d/librt-0.7.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bdb9f3d865b2dafe7f9ad7f30ef563c80d0ddd2fdc8cc9b8e4f242f475e34d75", size = 174537, upload-time = "2026-01-01T23:51:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/83/08/f9fb2edc9c7a76e95b2924ce81d545673f5b034e8c5dd92159d1c7dae0c6/librt-0.7.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8185c8497d45164e256376f9da5aed2bb26ff636c798c9dabe313b90e9f25b28", size = 195238, upload-time = "2026-01-01T23:51:11.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/56/ea2d2489d3ea1f47b301120e03a099e22de7b32c93df9a211e6ff4f9bf38/librt-0.7.7-cp311-cp311-win32.whl", hash = "sha256:44d63ce643f34a903f09ff7ca355aae019a3730c7afd6a3c037d569beeb5d151", size = 42939, upload-time = "2026-01-01T23:51:13.192Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/c288f417e42ba2a037f1c0753219e277b33090ed4f72f292fb6fe175db4c/librt-0.7.7-cp311-cp311-win_amd64.whl", hash = "sha256:7d13cc340b3b82134f8038a2bfe7137093693dcad8ba5773da18f95ad6b77a8a", size = 49240, upload-time = "2026-01-01T23:51:14.264Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/738eb33a6c1516fdb2dfd2a35db6e5300f7616679b573585be0409bc6890/librt-0.7.7-cp311-cp311-win_arm64.whl", hash = "sha256:983de36b5a83fe9222f4f7dcd071f9b1ac6f3f17c0af0238dadfb8229588f890", size = 42613, upload-time = "2026-01-01T23:51:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/1cd9d752070011641e8aee046c851912d5f196ecd726fffa7aed2070f3e0/librt-0.7.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a85a1fc4ed11ea0eb0a632459ce004a2d14afc085a50ae3463cd3dfe1ce43fc", size = 55687, upload-time = "2026-01-01T23:51:16.291Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/d5a1d4221c4fe7e76ae1459d24d6037783cb83c7645164c07d7daf1576ec/librt-0.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87654e29a35938baead1c4559858f346f4a2a7588574a14d784f300ffba0efd", size = 57136, upload-time = "2026-01-01T23:51:17.363Z" }, + { url = "https://files.pythonhosted.org/packages/23/6f/0c86b5cb5e7ef63208c8cc22534df10ecc5278efc0d47fb8815577f3ca2f/librt-0.7.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c9faaebb1c6212c20afd8043cd6ed9de0a47d77f91a6b5b48f4e46ed470703fe", size = 165320, upload-time = "2026-01-01T23:51:18.455Z" }, + { url = "https://files.pythonhosted.org/packages/16/37/df4652690c29f645ffe405b58285a4109e9fe855c5bb56e817e3e75840b3/librt-0.7.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1908c3e5a5ef86b23391448b47759298f87f997c3bd153a770828f58c2bb4630", size = 174216, upload-time = "2026-01-01T23:51:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d6/d3afe071910a43133ec9c0f3e4ce99ee6df0d4e44e4bddf4b9e1c6ed41cc/librt-0.7.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbc4900e95a98fc0729523be9d93a8fedebb026f32ed9ffc08acd82e3e181503", size = 189005, upload-time = "2026-01-01T23:51:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/74060a870fe2d9fd9f47824eba6717ce7ce03124a0d1e85498e0e7efc1b2/librt-0.7.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7ea4e1fbd253e5c68ea0fe63d08577f9d288a73f17d82f652ebc61fa48d878d", size = 183961, upload-time = "2026-01-01T23:51:22.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/918a86c66304af66a3c1d46d54df1b2d0b8894babc42a14fb6f25511497f/librt-0.7.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef7699b7a5a244b1119f85c5bbc13f152cd38240cbb2baa19b769433bae98e50", size = 177610, upload-time = "2026-01-01T23:51:23.874Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d7/b5e58dc2d570f162e99201b8c0151acf40a03a39c32ab824dd4febf12736/librt-0.7.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:955c62571de0b181d9e9e0a0303c8bc90d47670a5eff54cf71bf5da61d1899cf", size = 199272, upload-time = "2026-01-01T23:51:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" }, + { url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" }, + { url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" }, + { url = "https://files.pythonhosted.org/packages/69/9c/228a5c1224bd23809a635490a162e9cbdc68d99f0eeb4a696f07886b8206/librt-0.7.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:23daa1ab0512bafdd677eb1bfc9611d8ffbe2e328895671e64cb34166bc1b8c8", size = 55188, upload-time = "2026-01-01T23:51:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c2/0e7c6067e2b32a156308205e5728f4ed6478c501947e9142f525afbc6bd2/librt-0.7.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:558a9e5a6f3cc1e20b3168fb1dc802d0d8fa40731f6e9932dcc52bbcfbd37111", size = 56895, upload-time = "2026-01-01T23:51:44.534Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/de50ff70c80855eb79d1d74035ef06f664dd073fb7fb9d9fb4429651b8eb/librt-0.7.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2567cb48dc03e5b246927ab35cbb343376e24501260a9b5e30b8e255dca0d1d2", size = 163724, upload-time = "2026-01-01T23:51:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/6e/19/f8e4bf537899bdef9e0bb9f0e4b18912c2d0f858ad02091b6019864c9a6d/librt-0.7.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6066c638cdf85ff92fc6f932d2d73c93a0e03492cdfa8778e6d58c489a3d7259", size = 172470, upload-time = "2026-01-01T23:51:46.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/4c/dcc575b69d99076768e8dd6141d9aecd4234cba7f0e09217937f52edb6ed/librt-0.7.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a609849aca463074c17de9cda173c276eb8fee9e441053529e7b9e249dc8b8ee", size = 186806, upload-time = "2026-01-01T23:51:48.009Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f8/4094a2b7816c88de81239a83ede6e87f1138477d7ee956c30f136009eb29/librt-0.7.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add4e0a000858fe9bb39ed55f31085506a5c38363e6eb4a1e5943a10c2bfc3d1", size = 181809, upload-time = "2026-01-01T23:51:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/821b7c0ab1b5a6cd9aee7ace8309c91545a2607185101827f79122219a7e/librt-0.7.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a3bfe73a32bd0bdb9a87d586b05a23c0a1729205d79df66dee65bb2e40d671ba", size = 175597, upload-time = "2026-01-01T23:51:50.636Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/27f6bfbcc764805864c04211c6ed636fe1d58f57a7b68d1f4ae5ed74e0e0/librt-0.7.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ecce0544d3db91a40f8b57ae26928c02130a997b540f908cefd4d279d6c5848", size = 196506, upload-time = "2026-01-01T23:51:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/46/ba/c9b9c6fc931dd7ea856c573174ccaf48714905b1a7499904db2552e3bbaf/librt-0.7.7-cp314-cp314-win32.whl", hash = "sha256:8f7a74cf3a80f0c3b0ec75b0c650b2f0a894a2cec57ef75f6f72c1e82cdac61d", size = 39747, upload-time = "2026-01-01T23:51:53.683Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/cd1269337c4cde3ee70176ee611ab0058aa42fc8ce5c9dce55f48facfcd8/librt-0.7.7-cp314-cp314-win_amd64.whl", hash = "sha256:3d1fe2e8df3268dd6734dba33ededae72ad5c3a859b9577bc00b715759c5aaab", size = 45971, upload-time = "2026-01-01T23:51:54.697Z" }, + { url = "https://files.pythonhosted.org/packages/79/fd/e0844794423f5583108c5991313c15e2b400995f44f6ec6871f8aaf8243c/librt-0.7.7-cp314-cp314-win_arm64.whl", hash = "sha256:2987cf827011907d3dfd109f1be0d61e173d68b1270107bb0e89f2fca7f2ed6b", size = 39075, upload-time = "2026-01-01T23:51:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/02/211fd8f7c381e7b2a11d0fdfcd410f409e89967be2e705983f7c6342209a/librt-0.7.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e92c8de62b40bfce91d5e12c6e8b15434da268979b1af1a6589463549d491e6", size = 57368, upload-time = "2026-01-01T23:51:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/aca257affae73ece26041ae76032153266d110453173f67d7603058e708c/librt-0.7.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f683dcd49e2494a7535e30f779aa1ad6e3732a019d80abe1309ea91ccd3230e3", size = 59238, upload-time = "2026-01-01T23:51:58.066Z" }, + { url = "https://files.pythonhosted.org/packages/96/47/7383a507d8e0c11c78ca34c9d36eab9000db5989d446a2f05dc40e76c64f/librt-0.7.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b15e5d17812d4d629ff576699954f74e2cc24a02a4fc401882dd94f81daba45", size = 183870, upload-time = "2026-01-01T23:51:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/50f3d8eec8efdaf79443963624175c92cec0ba84827a66b7fcfa78598e51/librt-0.7.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c084841b879c4d9b9fa34e5d5263994f21aea7fd9c6add29194dbb41a6210536", size = 194608, upload-time = "2026-01-01T23:52:00.419Z" }, + { url = "https://files.pythonhosted.org/packages/23/d9/1b6520793aadb59d891e3b98ee057a75de7f737e4a8b4b37fdbecb10d60f/librt-0.7.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c8fb9966f84737115513fecbaf257f9553d067a7dd45a69c2c7e5339e6a8dc", size = 206776, upload-time = "2026-01-01T23:52:01.705Z" }, + { url = "https://files.pythonhosted.org/packages/ff/db/331edc3bba929d2756fa335bfcf736f36eff4efcb4f2600b545a35c2ae58/librt-0.7.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5fb1ecb2c35362eab2dbd354fd1efa5a8440d3e73a68be11921042a0edc0ff", size = 203206, upload-time = "2026-01-01T23:52:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/6af79ec77204e85f6f2294fc171a30a91bb0e35d78493532ed680f5d98be/librt-0.7.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d1454899909d63cc9199a89fcc4f81bdd9004aef577d4ffc022e600c412d57f3", size = 196697, upload-time = "2026-01-01T23:52:04.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/de55ecce4b2796d6d243295c221082ca3a944dc2fb3a52dcc8660ce7727d/librt-0.7.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7ef28f2e7a016b29792fe0a2dd04dec75725b32a1264e390c366103f834a9c3a", size = 217193, upload-time = "2026-01-01T23:52:06.159Z" }, + { url = "https://files.pythonhosted.org/packages/41/61/33063e271949787a2f8dd33c5260357e3d512a114fc82ca7890b65a76e2d/librt-0.7.7-cp314-cp314t-win32.whl", hash = "sha256:5e419e0db70991b6ba037b70c1d5bbe92b20ddf82f31ad01d77a347ed9781398", size = 40277, upload-time = "2026-01-01T23:52:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/1abd972349f83a696ea73159ac964e63e2d14086fdd9bc7ca878c25fced4/librt-0.7.7-cp314-cp314t-win_amd64.whl", hash = "sha256:d6b7d93657332c817b8d674ef6bf1ab7796b4f7ce05e420fd45bd258a72ac804", size = 46765, upload-time = "2026-01-01T23:52:08.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, ] [[package]] @@ -621,48 +622,48 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.0" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "librt" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/b5/b58cdc25fadd424552804bf410855d52324183112aa004f0732c5f6324cf/mypy-1.19.0.tar.gz", hash = "sha256:f6b874ca77f733222641e5c46e4711648c4037ea13646fd0cdc814c2eaec2528", size = 3579025, upload-time = "2025-11-28T15:49:01.26Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/8f/55fb488c2b7dabd76e3f30c10f7ab0f6190c1fcbc3e97b1e588ec625bbe2/mypy-1.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6148ede033982a8c5ca1143de34c71836a09f105068aaa8b7d5edab2b053e6c8", size = 13093239, upload-time = "2025-11-28T15:45:11.342Z" }, - { url = "https://files.pythonhosted.org/packages/72/1b/278beea978456c56b3262266274f335c3ba5ff2c8108b3b31bec1ffa4c1d/mypy-1.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a9ac09e52bb0f7fb912f5d2a783345c72441a08ef56ce3e17c1752af36340a39", size = 12156128, upload-time = "2025-11-28T15:46:02.566Z" }, - { url = "https://files.pythonhosted.org/packages/21/f8/e06f951902e136ff74fd7a4dc4ef9d884faeb2f8eb9c49461235714f079f/mypy-1.19.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f7254c15ab3f8ed68f8e8f5cbe88757848df793e31c36aaa4d4f9783fd08ab", size = 12753508, upload-time = "2025-11-28T15:44:47.538Z" }, - { url = "https://files.pythonhosted.org/packages/67/5a/d035c534ad86e09cee274d53cf0fd769c0b29ca6ed5b32e205be3c06878c/mypy-1.19.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318ba74f75899b0e78b847d8c50821e4c9637c79d9a59680fc1259f29338cb3e", size = 13507553, upload-time = "2025-11-28T15:44:39.26Z" }, - { url = "https://files.pythonhosted.org/packages/6a/17/c4a5498e00071ef29e483a01558b285d086825b61cf1fb2629fbdd019d94/mypy-1.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf7d84f497f78b682edd407f14a7b6e1a2212b433eedb054e2081380b7395aa3", size = 13792898, upload-time = "2025-11-28T15:44:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/67/f6/bb542422b3ee4399ae1cdc463300d2d91515ab834c6233f2fd1d52fa21e0/mypy-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:c3385246593ac2b97f155a0e9639be906e73534630f663747c71908dfbf26134", size = 10048835, upload-time = "2025-11-28T15:48:15.744Z" }, - { url = "https://files.pythonhosted.org/packages/0f/d2/010fb171ae5ac4a01cc34fbacd7544531e5ace95c35ca166dd8fd1b901d0/mypy-1.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a31e4c28e8ddb042c84c5e977e28a21195d086aaffaf08b016b78e19c9ef8106", size = 13010563, upload-time = "2025-11-28T15:48:23.975Z" }, - { url = "https://files.pythonhosted.org/packages/41/6b/63f095c9f1ce584fdeb595d663d49e0980c735a1d2004720ccec252c5d47/mypy-1.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34ec1ac66d31644f194b7c163d7f8b8434f1b49719d403a5d26c87fff7e913f7", size = 12077037, upload-time = "2025-11-28T15:47:51.582Z" }, - { url = "https://files.pythonhosted.org/packages/d7/83/6cb93d289038d809023ec20eb0b48bbb1d80af40511fa077da78af6ff7c7/mypy-1.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb64b0ba5980466a0f3f9990d1c582bcab8db12e29815ecb57f1408d99b4bff7", size = 12680255, upload-time = "2025-11-28T15:46:57.628Z" }, - { url = "https://files.pythonhosted.org/packages/99/db/d217815705987d2cbace2edd9100926196d6f85bcb9b5af05058d6e3c8ad/mypy-1.19.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:120cffe120cca5c23c03c77f84abc0c14c5d2e03736f6c312480020082f1994b", size = 13421472, upload-time = "2025-11-28T15:47:59.655Z" }, - { url = "https://files.pythonhosted.org/packages/4e/51/d2beaca7c497944b07594f3f8aad8d2f0e8fc53677059848ae5d6f4d193e/mypy-1.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7a500ab5c444268a70565e374fc803972bfd1f09545b13418a5174e29883dab7", size = 13651823, upload-time = "2025-11-28T15:45:29.318Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d1/7883dcf7644db3b69490f37b51029e0870aac4a7ad34d09ceae709a3df44/mypy-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:c14a98bc63fd867530e8ec82f217dae29d0550c86e70debc9667fff1ec83284e", size = 10049077, upload-time = "2025-11-28T15:45:39.818Z" }, - { url = "https://files.pythonhosted.org/packages/11/7e/1afa8fb188b876abeaa14460dc4983f909aaacaa4bf5718c00b2c7e0b3d5/mypy-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fb3115cb8fa7c5f887c8a8d81ccdcb94cff334684980d847e5a62e926910e1d", size = 13207728, upload-time = "2025-11-28T15:46:26.463Z" }, - { url = "https://files.pythonhosted.org/packages/b2/13/f103d04962bcbefb1644f5ccb235998b32c337d6c13145ea390b9da47f3e/mypy-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3e19e3b897562276bb331074d64c076dbdd3e79213f36eed4e592272dabd760", size = 12202945, upload-time = "2025-11-28T15:48:49.143Z" }, - { url = "https://files.pythonhosted.org/packages/e4/93/a86a5608f74a22284a8ccea8592f6e270b61f95b8588951110ad797c2ddd/mypy-1.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9d491295825182fba01b6ffe2c6fe4e5a49dbf4e2bb4d1217b6ced3b4797bc6", size = 12718673, upload-time = "2025-11-28T15:47:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/3d/58/cf08fff9ced0423b858f2a7495001fda28dc058136818ee9dffc31534ea9/mypy-1.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6016c52ab209919b46169651b362068f632efcd5eb8ef9d1735f6f86da7853b2", size = 13608336, upload-time = "2025-11-28T15:48:32.625Z" }, - { url = "https://files.pythonhosted.org/packages/64/ed/9c509105c5a6d4b73bb08733102a3ea62c25bc02c51bca85e3134bf912d3/mypy-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f188dcf16483b3e59f9278c4ed939ec0254aa8a60e8fc100648d9ab5ee95a431", size = 13833174, upload-time = "2025-11-28T15:45:48.091Z" }, - { url = "https://files.pythonhosted.org/packages/cd/71/01939b66e35c6f8cb3e6fdf0b657f0fd24de2f8ba5e523625c8e72328208/mypy-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:0e3c3d1e1d62e678c339e7ade72746a9e0325de42cd2cccc51616c7b2ed1a018", size = 10112208, upload-time = "2025-11-28T15:46:41.702Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0d/a1357e6bb49e37ce26fcf7e3cc55679ce9f4ebee0cd8b6ee3a0e301a9210/mypy-1.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7686ed65dbabd24d20066f3115018d2dce030d8fa9db01aa9f0a59b6813e9f9e", size = 13191993, upload-time = "2025-11-28T15:47:22.336Z" }, - { url = "https://files.pythonhosted.org/packages/5d/75/8e5d492a879ec4490e6ba664b5154e48c46c85b5ac9785792a5ec6a4d58f/mypy-1.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4a985b2e32f23bead72e2fb4bbe5d6aceee176be471243bd831d5b2644672d", size = 12174411, upload-time = "2025-11-28T15:44:55.492Z" }, - { url = "https://files.pythonhosted.org/packages/71/31/ad5dcee9bfe226e8eaba777e9d9d251c292650130f0450a280aec3485370/mypy-1.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc51a5b864f73a3a182584b1ac75c404396a17eced54341629d8bdcb644a5bba", size = 12727751, upload-time = "2025-11-28T15:44:14.169Z" }, - { url = "https://files.pythonhosted.org/packages/77/06/b6b8994ce07405f6039701f4b66e9d23f499d0b41c6dd46ec28f96d57ec3/mypy-1.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37af5166f9475872034b56c5efdcf65ee25394e9e1d172907b84577120714364", size = 13593323, upload-time = "2025-11-28T15:46:34.699Z" }, - { url = "https://files.pythonhosted.org/packages/68/b1/126e274484cccdf099a8e328d4fda1c7bdb98a5e888fa6010b00e1bbf330/mypy-1.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:510c014b722308c9bd377993bcbf9a07d7e0692e5fa8fc70e639c1eb19fc6bee", size = 13818032, upload-time = "2025-11-28T15:46:18.286Z" }, - { url = "https://files.pythonhosted.org/packages/f8/56/53a8f70f562dfc466c766469133a8a4909f6c0012d83993143f2a9d48d2d/mypy-1.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:cabbee74f29aa9cd3b444ec2f1e4fa5a9d0d746ce7567a6a609e224429781f53", size = 10120644, upload-time = "2025-11-28T15:47:43.99Z" }, - { url = "https://files.pythonhosted.org/packages/b0/f4/7751f32f56916f7f8c229fe902cbdba3e4dd3f3ea9e8b872be97e7fc546d/mypy-1.19.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f2e36bed3c6d9b5f35d28b63ca4b727cb0228e480826ffc8953d1892ddc8999d", size = 13185236, upload-time = "2025-11-28T15:45:20.696Z" }, - { url = "https://files.pythonhosted.org/packages/35/31/871a9531f09e78e8d145032355890384f8a5b38c95a2c7732d226b93242e/mypy-1.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a18d8abdda14035c5718acb748faec09571432811af129bf0d9e7b2d6699bf18", size = 12213902, upload-time = "2025-11-28T15:46:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/58/b8/af221910dd40eeefa2077a59107e611550167b9994693fc5926a0b0f87c0/mypy-1.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75e60aca3723a23511948539b0d7ed514dda194bc3755eae0bfc7a6b4887aa7", size = 12738600, upload-time = "2025-11-28T15:44:22.521Z" }, - { url = "https://files.pythonhosted.org/packages/11/9f/c39e89a3e319c1d9c734dedec1183b2cc3aefbab066ec611619002abb932/mypy-1.19.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f44f2ae3c58421ee05fe609160343c25f70e3967f6e32792b5a78006a9d850f", size = 13592639, upload-time = "2025-11-28T15:48:08.55Z" }, - { url = "https://files.pythonhosted.org/packages/97/6d/ffaf5f01f5e284d9033de1267e6c1b8f3783f2cf784465378a86122e884b/mypy-1.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63ea6a00e4bd6822adbfc75b02ab3653a17c02c4347f5bb0cf1d5b9df3a05835", size = 13799132, upload-time = "2025-11-28T15:47:06.032Z" }, - { url = "https://files.pythonhosted.org/packages/fe/b0/c33921e73aaa0106224e5a34822411bea38046188eb781637f5a5b07e269/mypy-1.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ad925b14a0bb99821ff6f734553294aa6a3440a8cb082fe1f5b84dfb662afb1", size = 10269832, upload-time = "2025-11-28T15:47:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/09/0e/fe228ed5aeab470c6f4eb82481837fadb642a5aa95cc8215fd2214822c10/mypy-1.19.0-py3-none-any.whl", hash = "sha256:0c01c99d626380752e527d5ce8e69ffbba2046eb8a060db0329690849cf9b6f9", size = 2469714, upload-time = "2025-11-28T15:45:33.22Z" }, +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" }, ] [[package]] @@ -703,109 +704,109 @@ wheels = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, ] [[package]] name = "pillow" -version = "12.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" }, - { url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" }, - { url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" }, - { url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, - { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, - { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, - { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, - { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, - { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, - { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, - { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, - { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +version = "12.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, + { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, + { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, + { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, + { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, + { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, + { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, + { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, + { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, + { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, + { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, + { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, + { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, + { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, + { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, + { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, + { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, + { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, + { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, + { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, + { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, + { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, + { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, ] [[package]] @@ -885,15 +886,15 @@ wheels = [ [[package]] name = "pytest-watcher" -version = "0.4.3" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/72/a2a1e81f1b272ddd9a1848af4959c87c39aa95c0bbfb3007cacb86c47fa9/pytest_watcher-0.4.3.tar.gz", hash = "sha256:0cb0e4661648c8c0ff2b2d25efa5a8e421784b9e4c60fcecbf9b7c30b2d731b3", size = 10386, upload-time = "2024-08-28T17:37:46.662Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/d2/80606077b7fa8784417687f494ff801d7ab817d9a17fc94305811d5919bb/pytest_watcher-0.6.3.tar.gz", hash = "sha256:842dc904264df0ad2d5264153a66bb452fccfa46598cd6e0a5ef1d19afed9b13", size = 601878, upload-time = "2026-01-10T23:28:18.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/3a/c44a76c6bb5e9e896d9707fb1c704a31a0136950dec9514373ced0684d56/pytest_watcher-0.4.3-py3-none-any.whl", hash = "sha256:d59b1e1396f33a65ea4949b713d6884637755d641646960056a90b267c3460f9", size = 11852, upload-time = "2024-08-28T17:37:45.731Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" }, ] [[package]] @@ -975,39 +976,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + [[package]] name = "roman-numerals-py" -version = "3.1.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +dependencies = [ + { name = "roman-numerals", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, ] [[package]] name = "ruff" -version = "0.14.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz", hash = "sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size = 5809165, upload-time = "2025-12-11T21:39:47.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl", hash = "sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size = 13576541, upload-time = "2025-12-11T21:39:14.806Z" }, - { url = "https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size = 13779363, upload-time = "2025-12-11T21:39:20.29Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size = 12925292, upload-time = "2025-12-11T21:39:38.757Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size = 13362894, upload-time = "2025-12-11T21:39:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size = 13311482, upload-time = "2025-12-11T21:39:17.51Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size = 14016100, upload-time = "2025-12-11T21:39:41.948Z" }, - { url = "https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size = 15477729, upload-time = "2025-12-11T21:39:23.279Z" }, - { url = "https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size = 15122386, upload-time = "2025-12-11T21:39:33.125Z" }, - { url = "https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size = 14497124, upload-time = "2025-12-11T21:38:59.33Z" }, - { url = "https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size = 14195343, upload-time = "2025-12-11T21:39:44.866Z" }, - { url = "https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size = 14346425, upload-time = "2025-12-11T21:39:05.927Z" }, - { url = "https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size = 13258768, upload-time = "2025-12-11T21:39:08.691Z" }, - { url = "https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size = 13326939, upload-time = "2025-12-11T21:39:53.842Z" }, - { url = "https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size = 13578888, upload-time = "2025-12-11T21:39:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size = 14314473, upload-time = "2025-12-11T21:39:50.78Z" }, - { url = "https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl", hash = "sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size = 13202651, upload-time = "2025-12-11T21:39:26.628Z" }, - { url = "https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl", hash = "sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size = 14702079, upload-time = "2025-12-11T21:39:11.954Z" }, - { url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" }, +version = "0.14.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" }, + { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, ] [[package]] @@ -1021,11 +1034,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8" +version = "2.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, + { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" }, ] [[package]] @@ -1064,7 +1077,8 @@ name = "sphinx" version = "8.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", ] dependencies = [ { name = "alabaster", marker = "python_full_version >= '3.11'" }, @@ -1129,7 +1143,8 @@ name = "sphinx-autobuild" version = "2025.8.25" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.11'" }, @@ -1164,7 +1179,8 @@ name = "sphinx-autodoc-typehints" version = "3.5.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "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'" }, @@ -1202,15 +1218,15 @@ wheels = [ [[package]] name = "sphinx-inline-tabs" -version = "2023.4.21" +version = "2025.12.21.14" 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/48/f5/f8a2be63ed7be9f91a4c2bea0e25bcb56aa4c5cc37ec4d8ead8065f926b1/sphinx_inline_tabs-2023.4.21.tar.gz", hash = "sha256:5df2f13f602c158f3f5f6c509e008aeada199a8c76d97ba3aa2822206683bebc", size = 42664, upload-time = "2023-04-21T20:25:30.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/6a/f39bde46a79b80a9983233d99b773bd24b468bdd9c1e87acb46ff69af441/sphinx_inline_tabs-2025.12.21.14.tar.gz", hash = "sha256:c71a75800326e613fb4e410eed92a0934214741326aca9897c18018b9f968cb6", size = 45572, upload-time = "2025-12-21T13:30:51.071Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/60/1e4c9017d722b9c7731abc11f39ac8b083b479fbcefe12015b57e457a296/sphinx_inline_tabs-2023.4.21-py3-none-any.whl", hash = "sha256:06809ac613f7c48ddd6e2fa588413e3fe92cff2397b56e2ccf0b0218f9ef6a78", size = 6850, upload-time = "2023-04-21T20:25:28.778Z" }, + { url = "https://files.pythonhosted.org/packages/02/2b/e64e7de34663cff1df029ba4f05a86124315bd9eba3d3b78e64904bea7e0/sphinx_inline_tabs-2025.12.21.14-py3-none-any.whl", hash = "sha256:e685c782b58d4e01490bcc4e2367cf7135ec28e7283a05e89095394e4ca6e81a", size = 7082, upload-time = "2025-12-21T13:30:50.142Z" }, ] [[package]] @@ -1295,20 +1311,20 @@ wheels = [ [[package]] name = "starlette" -version = "0.50.0" +version = "0.51.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/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/65/5a1fadcc40c5fdc7df421a7506b79633af8f5d5e3a95c3e72acacec644b9/starlette-0.51.0.tar.gz", hash = "sha256:4c4fda9b1bc67f84037d3d14a5112e523509c369d9d47b111b2f984b0cc5ba6c", size = 2647658, upload-time = "2026-01-10T20:23:15.043Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/18/c4/09985a03dba389d4fe16a9014147a7b02fa76ef3519bf5846462a485876d/starlette-0.51.0-py3-none-any.whl", hash = "sha256:fb460a3d6fd3c958d729fdd96aee297f89a51b0181f16401fe8fd4cb6129165d", size = 74133, upload-time = "2026-01-10T20:23:13.445Z" }, ] [[package]] name = "tmuxp" -version = "1.62.0" +version = "1.63.0" source = { editable = "." } dependencies = [ { name = "colorama" }, @@ -1351,6 +1367,7 @@ dev = [ { name = "sphinxext-rediraffe" }, { name = "types-colorama" }, { name = "types-docutils" }, + { name = "types-pygments" }, { name = "types-pyyaml" }, ] docs = [ @@ -1377,6 +1394,7 @@ lint = [ { name = "ruff" }, { name = "types-colorama" }, { name = "types-docutils" }, + { name = "types-pygments" }, { name = "types-pyyaml" }, ] testing = [ @@ -1426,6 +1444,7 @@ dev = [ { name = "sphinxext-rediraffe" }, { name = "types-colorama" }, { name = "types-docutils" }, + { name = "types-pygments" }, { name = "types-pyyaml" }, ] docs = [ @@ -1449,6 +1468,7 @@ lint = [ { name = "ruff" }, { name = "types-colorama" }, { name = "types-docutils" }, + { name = "types-pygments" }, { name = "types-pyyaml" }, ] testing = [ @@ -1526,6 +1546,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/01/61ac9eb38f1f978b47443dc6fd2e0a3b0f647c2da741ddad30771f1b2b6f/types_docutils-0.22.3.20251115-py3-none-any.whl", hash = "sha256:c6e53715b65395d00a75a3a8a74e352c669bc63959e65a207dffaa22f4a2ad6e", size = 91951, upload-time = "2025-11-15T02:59:56.413Z" }, ] +[[package]] +name = "types-pygments" +version = "2.19.0.20251121" +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" } +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" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -1555,25 +1587,25 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.40.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/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] [[package]] @@ -1713,59 +1745,68 @@ wheels = [ [[package]] name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ]