diff --git a/.claude/skills/update-libtmux/SKILL.md b/.claude/skills/update-libtmux/SKILL.md new file mode 100644 index 0000000000..530ae6d9e1 --- /dev/null +++ b/.claude/skills/update-libtmux/SKILL.md @@ -0,0 +1,359 @@ +--- +name: update-libtmux +description: >- + Use when the user asks to "update libtmux", "bump libtmux", + "upgrade libtmux dependency", "check for new libtmux version", + or when investigating whether tmuxp needs a libtmux update. + Guides the full workflow: studying upstream changes, updating + the dependency, migrating code and tests, and producing + separate atomic commits with rich messages. +user-invocable: true +argument-hint: "[target-version] (optional, defaults to latest on PyPI)" +--- + +# Update libtmux Dependency + +Workflow for updating the libtmux dependency in tmuxp with separate, atomic commits. + +## Overview + +This skill produces up to four atomic commits on a dedicated branch, then opens a PR: + +1. **Package commit** — bump `pyproject.toml` + `uv.lock` +2. **Code commit(s)** — API migrations, new feature adoption (if needed) +3. **Test commit(s)** — test updates for changed/new APIs (if needed) +4. **CHANGES commit** — changelog entry documenting the bump + +Each commit stands alone, passes tests independently, and has a rich commit body. + +## Step 0: Preflight + +Gather current state before making any changes. + +### 0a. Current dependency + +Read `pyproject.toml` and find the `libtmux~=X.Y.Z` specifier in `[project] dependencies`. + +### 0b. Latest version on PyPI + +```bash +pip index versions libtmux +``` + +If the user provided a target version, use that. Otherwise use the latest from PyPI. + +### 0c. Short-circuit check + +If the current specifier already covers the target version, inform the user and stop. + +### 0d. Ensure local libtmux clone is current + +The local libtmux clone lives at `~/work/python/libtmux`. Fetch and check: + +```bash +cd ~/work/python/libtmux && git fetch --tags && git log --oneline -5 +``` + +Verify the target version tag exists. If not, the version may not be released yet — warn the user. + +## Step 1: Study upstream changes + +This is the most important step. Read the libtmux CHANGES file to understand what changed between the current pinned version and the target. + +### 1a. Read libtmux CHANGES + +Read `~/work/python/libtmux/CHANGES` from the section for the target version back through all versions since the current pin. + +Categorize changes into: + +| Category | Action needed in tmuxp | +|----------|----------------------| +| **Breaking changes** | Must fix code/tests | +| **Deprecations** | Should migrate away | +| **New APIs** | Optionally adopt | +| **Bug fixes** | Note for commit message | +| **Internal/docs** | Note for commit message only | + +### 1b. Check for API impact in tmuxp + +For each breaking change or deprecation, grep tmuxp source and tests: + +```bash +# Example: if Window.rename_window() changed signature +rg "rename_window" src/ tests/ +``` + +Search patterns to check (adapt based on actual changes): +- Method/function names that changed +- Constructor parameters that changed +- Import paths that moved +- Exception types that changed +- Return type changes + +### 1c. Check libtmux git log for details + +For breaking changes where the CHANGES entry is unclear, read the actual commits: + +```bash +cd ~/work/python/libtmux && git log --oneline v{CURRENT}..v{TARGET} -- src/ +``` + +### 1d. Summarize findings + +Present findings to the user before proceeding: +- Versions being skipped (e.g., "0.53.1, 0.54.0, 0.55.0") +- Breaking changes requiring code updates +- New APIs available for adoption +- Test impact assessment +- Estimated commit count + +Get user confirmation to proceed. + +## Step 2: Create branch + +```bash +git checkout -b deps/libtmux-{TARGET_VERSION} +``` + +Branch naming convention: `deps/libtmux-X.Y.Z` + +## Step 3: Package commit + +Update the dependency specifier and lock file. + +### 3a. Edit pyproject.toml + +Change the `libtmux~=X.Y.Z` line in `[project] dependencies`. + +### 3b. Update lock file + +```bash +uv lock +``` + +### 3c. Verify installation + +```bash +uv sync +``` + +### 3d. Run tests (smoke check) + +```bash +uv run py.test tests/ -x -q 2>&1 | tail -20 +``` + +Note any failures — these indicate code changes needed in Step 4. + +### 3e. Commit + +Commit message format (use heredoc for multiline): + +``` +deps(libtmux[~=X.Y.Z]): Bump from ~=A.B.C + +why: Pick up N libtmux release(s) (list versions) bringing +[brief summary of key changes]. + +what: +- Bump libtmux dependency specifier ~=A.B.C -> ~=X.Y.Z in pyproject.toml +- Update uv.lock + +libtmux X.Y.Z (date): +- [key change 1] +- [key change 2] + +[repeat for each intermediate version] + +Release: https://github.com/tmux-python/libtmux/releases/tag/vX.Y.Z +Changelog: https://libtmux.git-pull.com/history.html#libtmux-X-Y-Z-YYYY-MM-DD +``` + +Stage only `pyproject.toml` and `uv.lock`. + +## Step 4: Code commit(s) — if needed + +Skip this step if no breaking changes or API migrations are needed. + +### 4a. Fix breaking changes + +Address each breaking change identified in Step 1b. Make minimal, targeted fixes. + +### 4b. Adopt new APIs (optional) + +Only if the user requested it or it simplifies existing code significantly. + +### 4c. Run linting and type checking + +```bash +uv run ruff check . --fix --show-fixes +uv run ruff format . +uv run mypy +``` + +### 4d. Run tests + +```bash +uv run py.test tests/ -x -q +``` + +### 4e. Commit + +One commit per logical change. Format: + +``` +Scope(type[detail]): description of the migration + +why: libtmux X.Y.Z changed [what changed]. +what: +- [specific code change 1] +- [specific code change 2] +``` + +Use the project's standard scope conventions: +- `workspace/builder(fix[method])` for builder changes +- `cli/load(fix[feature])` for CLI changes +- `plugin(fix[hook])` for plugin changes + +## Step 5: Test commit(s) — if needed + +Skip if no test changes are required beyond what was fixed in Step 4. + +### 5a. Update tests for API changes + +Fix any tests that broke due to upstream changes. + +### 5b. Add tests for newly adopted APIs + +If Step 4 adopted new libtmux features, add tests. + +### 5c. Run full test suite + +```bash +uv run py.test +``` + +All tests must pass (doctests included — pytest is configured with `--doctest-modules`). + +### 5d. Commit + +``` +tests(scope[detail]): description + +why: Adapt tests for libtmux X.Y.Z [change]. +what: +- [specific test change 1] +- [specific test change 2] +``` + +## Step 6: CHANGES commit + +### 6a. Determine placement + +The CHANGES file has a placeholder section for the next unreleased version at the top. Add the entry below the placeholder comments. + +### 6b. Write the entry + +Add under `### Breaking Changes` if the bump changes minimum version, or `### Development` / `### Dependencies` for non-breaking bumps: + +For a breaking bump: + +```markdown +#### **libtmux** minimum bumped from `~=A.B.C` to `~=X.Y.Z` + + Picks up N releases: [version list with brief descriptions]. +``` + +For a non-breaking bump, use `### Dependencies`: + +```markdown +### Dependencies + +- Bump libtmux `~=A.B.C` -> `~=X.Y.Z` ([key changes summary]) +``` + +### 6c. Commit + +``` +docs(CHANGES): libtmux ~=A.B.C -> ~=X.Y.Z + +why: Document the dependency bump for the upcoming release. +what: +- Add entry for libtmux bump under [section name] +- Summarize key upstream changes +``` + +## Step 7: Push and open PR + +### 7a. Push the branch + +```bash +git push -u origin deps/libtmux-{TARGET_VERSION} +``` + +### 7b. Open PR + +```bash +gh pr create \ + --title "deps(libtmux[~=X.Y.Z]): Bump from ~=A.B.C" \ + --body "$(cat <<'EOF' +## Summary + +- Bump libtmux from `~=A.B.C` to `~=X.Y.Z` +- [N] upstream releases included +- [Breaking changes summary, or "No breaking changes"] + +## Upstream changes + +### libtmux X.Y.Z (date) +- [changes] + +[repeat for intermediate versions] + +## Changes in this PR + +- **Package**: pyproject.toml + uv.lock +- **Code**: [summary or "No code changes needed"] +- **Tests**: [summary or "No test changes needed"] +- **CHANGES**: Documented bump + +## Test plan + +- [ ] `uv run py.test` passes +- [ ] `uv run mypy` passes +- [ ] `uv run ruff check .` passes +EOF +)" +``` + +### 7c. Report to user + +Provide the PR URL and a summary of all commits created. + +## Reference: Past libtmux bumps + +These exemplar commits show the established patterns: + +| Version bump | Deps commit | CHANGES commit | PR | +|---|---|---|---| +| 0.53.0 → 0.55.0 | `ff52d0d2` | `094800f4` | #1019 | +| 0.52.1 → 0.53.0 | `5ff6400f` | `240d85fe` | #1003 | +| 0.51.0 → 0.52.1 | `fabd678f` | (in same commit) | #1001 | +| 0.50.1 → 0.51.0 | (in merge) | (in merge) | #999 | + +The 0.53→0.55 bump (`ff52d0d2`) is the gold standard for commit message richness — per-version changelogs, upstream links, and clear why/what structure. + +## Checklist + +Use this as a progress tracker: + +- [ ] Preflight: identify current and target versions +- [ ] Study upstream CHANGES and identify impact +- [ ] Summarize findings and get user confirmation +- [ ] Create branch `deps/libtmux-X.Y.Z` +- [ ] Package commit: pyproject.toml + uv.lock +- [ ] Code commit(s): API migrations (if needed) +- [ ] Test commit(s): test updates (if needed) +- [ ] CHANGES commit: changelog entry +- [ ] Push and open PR +- [ ] Report PR URL to user diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index dd73cf4003..0000000000 --- a/.coveragerc +++ /dev/null @@ -1,17 +0,0 @@ -[run] -branch = True -source = tmuxp -omit = - tests/* - */_vendor/* - */_* - pkg/* - */log.py - -[report] -exclude_lines = - pragma: no cover - def __repr__ - raise NotImplementedError - if __name__ == .__main__.: - def parse_args diff --git a/.cursor/rules/avoid-debug-loops.mdc b/.cursor/rules/avoid-debug-loops.mdc new file mode 100644 index 0000000000..8a241ec990 --- /dev/null +++ b/.cursor/rules/avoid-debug-loops.mdc @@ -0,0 +1,57 @@ +--- +description: When stuck in debugging loops, break the cycle by minimizing to an MVP, removing debugging cruft, and documenting the issue completely for a fresh approach +globs: *.py +alwaysApply: true +--- +# Avoid Debug Loops + +When debugging becomes circular and unproductive, follow these steps: + +## Detection +- You have made multiple unsuccessful attempts to fix the same issue +- You are adding increasingly complex code to address errors +- Each fix creates new errors in a cascading pattern +- You are uncertain about the root cause after 2-3 iterations + +## Action Plan + +1. **Pause and acknowledge the loop** + - Explicitly state that you are in a potential debug loop + - Review what approaches have been tried and failed + +2. **Minimize to MVP** + - Remove all debugging cruft and experimental code + - Revert to the simplest version that demonstrates the issue + - Focus on isolating the core problem without added complexity + +3. **Comprehensive Documentation** + - Provide a clear summary of the issue + - Include minimal but complete code examples that reproduce the problem + - Document exact error messages and unexpected behaviors + - Explain your current understanding of potential causes + +4. **Format for Portability** + - Present the problem in quadruple backticks for easy copying: + +```` +# Problem Summary +[Concise explanation of the issue] + +## Minimal Reproduction Code +```python +# Minimal code example that reproduces the issue +``` + +## Error/Unexpected Output +``` +[Exact error messages or unexpected output] +``` + +## Failed Approaches +[Brief summary of approaches already tried] + +## Suspected Cause +[Your current hypothesis about what might be causing the issue] +```` + +This format enables the user to easily copy the entire problem statement into a fresh conversation for a clean-slate approach. diff --git a/.cursor/rules/dev-loop.mdc b/.cursor/rules/dev-loop.mdc new file mode 100644 index 0000000000..d60a521095 --- /dev/null +++ b/.cursor/rules/dev-loop.mdc @@ -0,0 +1,187 @@ +--- +description: QA every edit +globs: *.py +alwaysApply: true +--- + +# Development Process + +## Project Stack + +The project uses the following tools and technologies: + +- **uv** - Python package management and virtual environments +- **ruff** - Fast Python linter and formatter +- **py.test** - Testing framework + - **pytest-watcher** - Continuous test runner +- **mypy** - Static type checking +- **doctest** - Testing code examples in documentation + +## 1. Start with Formatting + +Format your code first: + +``` +uv run ruff format . +``` + +## 2. Run Tests + +Verify that your changes pass the tests: + +``` +uv run py.test +``` + +For continuous testing during development, use pytest-watcher: + +``` +# Watch all tests +uv run ptw . + +# Watch and run tests immediately, including doctests +uv run ptw . --now --doctest-modules + +# Watch specific files or directories +uv run ptw . --now --doctest-modules src/libtmux/_internal/ +``` + +## 3. Commit Initial Changes + +Make an atomic commit for your changes using conventional commits. +Use `@git-commits.mdc` for assistance with commit message standards. + +## 4. Run Linting and Type Checking + +Check and fix linting issues: + +``` +uv run ruff check . --fix --show-fixes +``` + +Check typings: + +``` +uv run mypy +``` + +## 5. Verify Tests Again + +Ensure tests still pass after linting and type fixes: + +``` +uv run py.test +``` + +## 6. Final Commit + +Make a final commit with any linting/typing fixes. +Use `@git-commits.mdc` for assistance with commit message standards. + +## Development Loop Guidelines + +If there are any failures at any step due to your edits, fix them before proceeding to the next step. + +## Python Code Standards + +### Docstring Guidelines + +For `src/**/*.py` files, follow these docstring guidelines: + +1. **Use reStructuredText format** for all docstrings. + ```python + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1 + param2 : type + Description of param2 + + Returns + ------- + type + Description of return value + """ + ``` + +2. **Keep the main description on the first line** after the opening `"""`. + +3. **Use NumPy docstyle** for parameter and return value documentation. + +### Doctest Guidelines + +For doctests in `src/**/*.py` files: + +1. **Use narrative descriptions** for test sections rather than inline comments: + ```python + """Example function. + + Examples + -------- + Create an instance: + + >>> obj = ExampleClass() + + Verify a property: + + >>> obj.property + 'expected value' + """ + ``` + +2. **Move complex examples** to dedicated test files at `tests/examples//test_.py` if they require elaborate setup or multiple steps. + +3. **Utilize pytest fixtures** via `doctest_namespace` for more complex test scenarios: + ```python + """Example with fixture. + + Examples + -------- + >>> # doctest_namespace contains all pytest fixtures from conftest.py + >>> example_fixture = getfixture('example_fixture') + >>> example_fixture.method() + 'expected result' + """ + ``` + +4. **Keep doctests simple and focused** on demonstrating usage rather than comprehensive testing. + +5. **Add blank lines between test sections** for improved readability. + +6. **Test your doctests continuously** using pytest-watcher during development: + ``` + # Watch specific modules for doctest changes + uv run ptw . --now --doctest-modules src/path/to/module.py + ``` + +### Pytest Testing Guidelines + +1. **Use existing fixtures over mocks**: + - Use fixtures from conftest.py instead of `monkeypatch` and `MagicMock` when available + - For instance, if using libtmux, use provided fixtures: `server`, `session`, `window`, and `pane` + - Document in test docstrings why standard fixtures weren't used for exceptional cases + +2. **Preferred pytest patterns**: + - Use `tmp_path` (pathlib.Path) fixture over Python's `tempfile` + - Use `monkeypatch` fixture over `unittest.mock` + +### Import Guidelines + +1. **Prefer namespace imports**: + - Import modules and access attributes through the namespace instead of importing specific symbols + - Example: Use `import enum` and access `enum.Enum` instead of `from enum import Enum` + - This applies to standard library modules like `pathlib`, `os`, and similar cases + +2. **Standard aliases**: + - For `typing` module, use `import typing as t` + - Access typing elements via the namespace: `t.NamedTuple`, `t.TypedDict`, etc. + - Note primitive types like unions can be done via `|` pipes and primitive types like list and dict can be done via `list` and `dict` directly. + +3. **Benefits of namespace imports**: + - Improves code readability by making the source of symbols clear + - Reduces potential naming conflicts + - Makes import statements more maintainable diff --git a/.cursor/rules/git-commits.mdc b/.cursor/rules/git-commits.mdc new file mode 100644 index 0000000000..f9c0980db7 --- /dev/null +++ b/.cursor/rules/git-commits.mdc @@ -0,0 +1,95 @@ +--- +description: git-commits: Git commit message standards and AI assistance +globs: git-commits: Git commit message standards and AI assistance | *.git/* .gitignore .github/* CHANGELOG.md CHANGES.md +alwaysApply: true +--- +# Optimized Git Commit Standards + +## Commit Message Format +``` +Component/File(commit-type[Subcomponent/method]): Concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic + +refs: #issue-number, breaking changes, or relevant links +``` + +## Component Patterns +### General Code Changes +``` +Component/File(feat[method]): Add feature +Component/File(fix[method]): Fix bug +Component/File(refactor[method]): Code restructure +``` + +### Packages and Dependencies +| Language | Standard Packages | Dev Packages | Extras / Sub-packages | +|------------|------------------------------------|-------------------------------|-----------------------------------------------| +| General | `lang(deps):` | `lang(deps[dev]):` | | +| Python | `py(deps):` | `py(deps[dev]):` | `py(deps[extra]):` | +| JavaScript | `js(deps):` | `js(deps[dev]):` | `js(deps[subpackage]):`, `js(deps[dev{subpackage}]):` | + +#### Examples +- `py(deps[dev]): Update pytest to v8.1` +- `js(deps[ui-components]): Upgrade Button component package` +- `js(deps[dev{linting}]): Add ESLint plugin` + +### Documentation Changes +Prefix with `docs:` +``` +docs(Component/File[Subcomponent/method]): Update API usage guide +``` + +### Test Changes +Prefix with `tests:` +``` +tests(Component/File[Subcomponent/method]): Add edge case tests +``` + +## Commit Types Summary +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting + +## General Guidelines +- Subject line: Maximum 50 characters +- Body lines: Maximum 72 characters +- Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") +- Limit to one topic per commit +- Separate subject from body with a blank line +- Mark breaking changes clearly: `BREAKING:` +- Use `See also:` to provide external references + +## AI Assistance Workflow in Cursor +- Stage changes with `git add` +- Use `@commit` to generate initial commit message +- Review and refine generated message +- Ensure adherence to these standards + +## Good Commit Example +``` +Pane(feat[capture_pane]): Add screenshot capture support + +why: Provide visual debugging capability +what: +- Implement capturePane method with image export +- Integrate with existing Pane component logic +- Document usage in Pane README + +refs: #485 +See also: https://example.com/docs/pane-capture +``` + +## Bad Commit Example +``` +fixed stuff and improved some functions +``` + +These guidelines ensure clear, consistent commit histories, facilitating easier code review and maintenance. \ No newline at end of file diff --git a/.cursor/rules/notes-llms-txt.mdc b/.cursor/rules/notes-llms-txt.mdc new file mode 100644 index 0000000000..ac17097737 --- /dev/null +++ b/.cursor/rules/notes-llms-txt.mdc @@ -0,0 +1,42 @@ +--- +description: LLM-friendly markdown format for notes directories +globs: notes/**/*.md,**/notes/**/*.md +alwaysApply: true +--- + +# Instructions for Generating LLM-Optimized Markdown Content + +When creating or editing markdown files within the specified directories, adhere to the following guidelines to ensure the content is optimized for LLM understanding and efficient token usage: + +1. **Conciseness and Clarity**: + - **Be Brief**: Present information succinctly, avoiding unnecessary elaboration. + - **Use Clear Language**: Employ straightforward language to convey ideas effectively. + +2. **Structured Formatting**: + - **Headings**: Utilize markdown headings (`#`, `##`, `###`, etc.) to organize content hierarchically. + - **Lists**: Use bullet points (`-`) or numbered lists (`1.`, `2.`, etc.) to enumerate items clearly. + - **Code Blocks**: Enclose code snippets within triple backticks (```) to distinguish them from regular text. + +3. **Semantic Elements**: + - **Emphasis**: Use asterisks (`*`) or underscores (`_`) for italicizing text to denote emphasis. + - **Strong Emphasis**: Use double asterisks (`**`) or double underscores (`__`) for bold text to highlight critical points. + - **Inline Code**: Use single backticks (`) for inline code references. + +4. **Linking and References**: + - **Hyperlinks**: Format links using `[Link Text](mdc:URL)` to provide direct access to external resources. + - **References**: When citing sources, use footnotes or inline citations to maintain readability. + +5. **Avoid Redundancy**: + - **Eliminate Repetition**: Ensure that information is not unnecessarily repeated within the document. + - **Use Summaries**: Provide brief summaries where detailed explanations are not essential. + +6. **Standard Compliance**: + - **llms.txt Conformance**: Structure the document in alignment with the `llms.txt` standard, which includes: + - An H1 heading with the project or site name. + - A blockquote summarizing the project's purpose. + - Additional markdown sections providing detailed information. + - H2-delimited sections containing lists of URLs for further details. + +By following these guidelines, the markdown files will be tailored for optimal LLM processing, ensuring that the content is both accessible and efficiently tokenized for AI applications. + +For more information on the `llms.txt` standard, refer to the official documentation: https://llmstxt.org/ diff --git a/.cursor/rules/tmuxp-pytest.mdc b/.cursor/rules/tmuxp-pytest.mdc new file mode 100644 index 0000000000..579809cc64 --- /dev/null +++ b/.cursor/rules/tmuxp-pytest.mdc @@ -0,0 +1,77 @@ +--- +description: Guidelines for using pytest with tmuxp, including libtmux fixtures +globs: tests/**/test_*.py +alwaysApply: true +--- + +# tmuxp Pytest Guidelines + +## Leveraging libtmux fixtures + +tmuxp tests can utilize libtmux's pytest fixtures for fast, efficient tmux server, session, window, and pane setup/teardown. These fixtures automatically manage the lifecycle of tmux resources during tests. + +### Available fixtures + +The following fixtures are available through libtmux's pytest plugin: + +- `server`: Creates a temporary tmux server with isolated socket +- `session`: Creates a temporary tmux session in the server +- `window`: Creates a temporary tmux window in the session +- `pane`: Creates a temporary tmux pane in the pane +- `TestServer`: Factory for creating multiple independent servers with unique socket names + +### Usage in tests + +```python +def test_something_with_server(server): + # server is already running with proper configuration + my_session = server.new_session("test-session") + assert server.is_alive() + assert my_session in server.sessions + +def test_something_with_session(session): + # session is already created and configured + new_window = session.new_window("test-window") + assert new_window in session.windows + +def test_with_multiple_servers(TestServer): + # Create independent servers for comparison tests + Server1 = TestServer() + Server2 = TestServer() + + server1 = Server1() + server2 = Server2() + + session1 = server1.new_session() + session2 = server2.new_session() + + assert server1.socket_path != server2.socket_path +``` + +### Customizing session parameters + +You can override the `session_params` fixture to customize session creation: + +```python +import pytest + +@pytest.fixture +def session_params(): + return { + 'x': 800, + 'y': 600, + 'window_name': 'custom-window' + } +``` + +### Benefits + +- No need to manually set up and tear down tmux infrastructure +- Tests run in isolated tmux environments +- Faster test execution +- Reliable test environment with predictable configuration +- Multiple tests can run in parallel without tmux session conflicts + +For more details, see: +- [libtmux pytest plugin documentation](https://libtmux.git-pull.com/pytest-plugin/index.html) +- [libtmux pytest plugin code](https://github.com/tmux-python/libtmux/blob/master/src/libtmux/pytest_plugin.py) diff --git a/.github/contributing.md b/.github/contributing.md index c13ceb8927..c0eddac2c2 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -1,27 +1,19 @@ # Contributing When contributing to this repository, please first discuss the change you wish to make via issue, -email, or any other method with the maintainers of this repository before making a change. +email, or any other method with the maintainers of this repository before making a change. -Please note we have a code of conduct, please follow it in all your interactions with the project. +See [developing](../docs/developing.md) for environment setup and [AGENTS.md](../AGENTS.md) for +detailed coding standards. ## Pull Request Process -1. Ensure any install or build dependencies are removed before the end of the layer when doing a - build. -2. This project uses flake8 to conform with common Python standards. Make sure - to run your code through linter using latest version of flake8, before pull request. -3. Bad documnentation is a Bug. If your change demands documentation update, please do so. If you - find an issue with documentation, take the time to improve or fix it. -4. pytest is used for automated testing. Please make sure to update tests that are needed, and to run - `make test` before submitting your pull request. This should prevent issues with TravisCI and - make the review and merging process easier and faster. -5. Update the README.md with details of changes to the interface, this includes new environment - variables, exposed ports, useful file locations and container parameters. -6. Increase the version numbers in any examples files and the README.md to the new version that this - Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). -7. You may merge the Pull Request in once you have the sign-off of one other developer. If you - do not have permission to do that, you may request reviewer to merge it for you. +1. **Format and lint**: `uv run ruff format .` then `uv run ruff check . --fix --show-fixes` +2. **Type check**: `uv run mypy` +3. **Test**: `uv run pytest` — all tests must pass before submitting +4. **Document**: Update docs if your change affects the public interface +5. You may merge the Pull Request once you have the sign-off of one other developer. If you + do not have permission to do that, you may request a reviewer to merge it for you. ## Decorum diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..d202a332d2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" diff --git a/.github/issue_template.md b/.github/issue_template.md index ef99c9b8c0..980833e876 100644 --- a/.github/issue_template.md +++ b/.github/issue_template.md @@ -1,22 +1,34 @@ +Are you using the latest version of tmuxp? Check `tmuxp -V` against +https://pypi.org/project/tmuxp/. If it's not the latest, consider updating, e.g. +`pip install --user tmuxp`. + ### Step 1: Provide a summary of your problem - * For general technical questions, problems or feature requests related to the code **in this repository** file an issue. + +- For general technical questions, problems or feature requests related to the code **in this repository** file an issue. ### Step 2: Provide tmuxp details - * Include output of `tmuxp debug-info` - * Include any other pertinant system info not captured + +- Include output of `tmuxp debug-info` +- Include any other pertinant system info not captured ### Step 3: Describe the problem: + #### Steps to reproduce: - 1. _____ - 2. _____ - 3. _____ + +1. ... +2. ... +3. ... #### Observed Results: - * What happened? This could be a description, log output, etc. + +- What happened? This could be a description, log output, etc. #### Expected Results: - * What did you expect to happen? + +- What did you expect to happen? #### Relevant Code: - ``` - // TODO(you): paste here tmuxp configuration (YAML / JSON) you are having issues with. + +``` +// TODO(you): paste here tmuxp configuration (YAML / JSON) you are having issues with. +``` diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 91190c8546..0000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,70 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '28 21 * * 0' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b6132772f3..376d1567b1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,127 +5,132 @@ on: branches: - master +permissions: + contents: read + id-token: write + jobs: build: - runs-on: ubuntu-latest + environment: docs strategy: matrix: - python-version: [ '3.10' ] + python-version: ['3.14'] steps: - - uses: actions/checkout@v1 - - name: Filter changed file paths to outputs - uses: dorny/paths-filter@v2.7.0 - id: changes - with: - filters: | - root_docs: - - CHANGES - - README.* - docs: - - 'docs/**' - - 'examples/**' - python_files: - - 'tmuxp/**' - - pyproject.toml - - poetry.lock - - - name: Should publish - if: steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true' - run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - - name: Get full Python version - id: full-python-version - if: env.PUBLISH == 'true' - shell: bash - run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") - - - name: Install poetry - if: env.PUBLISH == 'true' - run: | - curl -O -sSL https://install.python-poetry.org/install-poetry.py - python install-poetry.py -y --version 1.1.12 - echo "PATH=${HOME}/.poetry/bin:${PATH}" >> $GITHUB_ENV - rm install-poetry.py - - - name: Add ~/.local/bin to PATH - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Get poetry cache paths from config - if: env.PUBLISH == 'true' - run: | - echo "poetry_cache_dir=$(poetry config --list | sed -n 's/.*cache-dir = //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV - echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV - - - name: Configure poetry - if: env.PUBLISH == 'true' - shell: bash - run: poetry config virtualenvs.in-project true - - - name: Set up cache - uses: actions/cache@v2 - id: cache - with: - path: | - .venv - {{ env.poetry_cache_dir }} - {{ env.poetry_virtualenvs_path }} - key: venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' && env.PUBLISH == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv - - - name: Install dependencies [w/ docs] - if: env.PUBLISH == 'true' - run: poetry install --extras "docs lint" - - - name: Build documentation - if: env.PUBLISH == 'true' - run: | - pushd docs; make SPHINXBUILD='poetry run sphinx-build' html; popd - - - name: Push documentation to S3 - if: env.PUBLISH == 'true' - uses: jakejarvis/s3-sync-action@master - 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 - - - name: Generate list of changed files for CloudFront to invalidate - if: env.PUBLISH == 'true' - run: | - FILES=$(find docs/_build/html -exec realpath --relative-to docs/_build/html {} \; | awk '{print "/"$0}' | grep "html\|searchindex.js\|custom.css\|.svg"); - - for file in $FILES; do - echo $file - # add bare directory to list of updated paths when we see index.html - [[ "$file" == *"/index.html" ]] && echo $file | sed -e 's/\/index.html$/\//' - done | sort | uniq | tr '\n' ' ' > .updated_files - - - name: Invalidate on CloudFront - uses: chetan/invalidate-cloudfront-action@v2.3 - if: env.PUBLISH == 'true' - env: - DISTRIBUTION: ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION }} - AWS_REGION: 'us-east-1' - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - PATHS_FROM: .updated_files - - - name: Purge cache on Cloudflare - uses: jakejarvis/cloudflare-purge-action@v0.3.0 - env: - CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} - CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }} + - uses: actions/checkout@v6 + - name: Filter changed file paths to outputs + uses: dorny/paths-filter@v4.0.1 + id: changes + with: + filters: | + root_docs: + - CHANGES + - README.* + docs: + - 'docs/**' + - 'examples/**' + python_files: + - 'src/tmuxp/**' + - pyproject.toml + - uv.lock + + - name: Should publish + if: steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true' + run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV + + - name: Install uv + uses: astral-sh/setup-uv@v7 + if: env.PUBLISH == 'true' + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + if: env.PUBLISH == 'true' + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + if: env.PUBLISH == 'true' + run: uv sync --all-extras --dev + + - name: Install just + if: env.PUBLISH == 'true' + uses: extractions/setup-just@v4 + + - name: Set up Node.js + if: env.PUBLISH == 'true' + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Set up pnpm + if: env.PUBLISH == 'true' + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Cache Puppeteer browser + if: env.PUBLISH == 'true' + uses: actions/cache@v5 + with: + path: ~/.cache/puppeteer + key: puppeteer-${{ runner.os }}-${{ hashFiles('docs/pnpm-lock.yaml') }} + restore-keys: | + puppeteer-${{ runner.os }}- + + # Diagrams (sphinx-gp-mermaid) are rendered at build time by + # mmdc, which drives a headless Chrome. Without it the build still + # succeeds but degrades diagrams to a text fallback, so provision both. + - name: Install diagram toolchain (mermaid-cli + Chrome) + if: env.PUBLISH == 'true' + run: | + pnpm -C docs install --frozen-lockfile + pnpm -C docs rebuild puppeteer + pnpm -C docs exec mmdc --version + + - name: Print python versions + if: env.PUBLISH == 'true' + run: | + python -V + uv run python -V + + - name: Cache sphinx fonts + if: env.PUBLISH == 'true' + uses: actions/cache@v5 + with: + path: ~/.cache/sphinx-fonts + key: sphinx-fonts-${{ hashFiles('docs/conf.py') }} + restore-keys: | + sphinx-fonts- + + - name: Build documentation + if: env.PUBLISH == 'true' + run: | + cd docs && just html + + - name: Configure AWS Credentials + if: env.PUBLISH == 'true' + uses: aws-actions/configure-aws-credentials@v6 + with: + 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" "/history.html" "/objects.inv" "/searchindex.js" + + - name: Purge cache on Cloudflare + if: env.PUBLISH == 'true' + uses: jakejarvis/cloudflare-purge-action@v0.3.0 + env: + CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} + CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fcf8bd1607..0e937801f9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,96 +10,114 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.10' ] - tmux-version: [ '2.6', '2.7', '2.8', '3.0a', '3.1b', '3.2a', 'master' ] - # balance ci coverage across supported python/tmux versions with CI speed - include: - - python-version: '3.7' - tmux-version: '2.6' - - python-version: '3.7' - tmux-version: 'master' + python-version: ['3.14'] + tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', '3.7', 'master'] steps: - - uses: actions/checkout@v1 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - - name: Get full Python version - id: full-python-version - shell: bash - run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") - - - name: Install poetry - run: | - curl -O -sSL https://install.python-poetry.org/install-poetry.py - python install-poetry.py -y --version 1.1.12 - echo "PATH=${HOME}/.poetry/bin:${PATH}" >> $GITHUB_ENV - rm install-poetry.py - - - name: Add ~/.local/bin to PATH - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Get poetry cache paths from config - run: | - echo "poetry_cache_dir=$(poetry config --list | sed -n 's/.*cache-dir = //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV - echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV - - - name: Configure poetry - shell: bash - run: poetry config virtualenvs.in-project true - - - name: Set up poetry cache - uses: actions/cache@v2 - id: cache - with: - path: | - .venv - ${{ env.poetry_cache_dir }} - ${{ env.poetry_virtualenvs_path }} - key: venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv - - - name: Setup tmux build cache for tmux ${{ matrix.tmux-version }} - id: tmux-build-cache - uses: actions/cache@v1 - with: - path: ~/tmux-builds/tmux-${{ matrix.tmux-version }} - key: tmux-${{ matrix.tmux-version }} - - - name: Build tmux ${{ matrix.tmux-version }} - if: steps.tmux-build-cache.outputs.cache-hit != 'true' - run: | - sudo apt install libevent-dev libncurses5-dev libtinfo-dev libutempter-dev bison - mkdir ~/tmux-builds - mkdir ~/tmux-src - git clone https://github.com/tmux/tmux.git ~/tmux-src/tmux-${{ matrix.tmux-version }} - cd ~/tmux-src/tmux-${{ matrix.tmux-version }} - git checkout ${{ matrix.tmux-version }} - sh autogen.sh - ./configure --prefix=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }} && make && make install - export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH - cd ~ - tmux -V - - - name: Install python dependencies - run: | - poetry install -E "test coverage lint" - - name: Lint with flake8 - run: | - poetry run flake8 - - name: Test with pytest - continue-on-error: ${{ matrix.tmux-version == 'master' }} - run: | - export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH - ls $HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin - tmux -V - poetry run py.test --cov=./ --cov-report=xml - - uses: codecov/codecov-action@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Test runtime dependencies + run: | + uv run --no-dev -p python${{ matrix.python-version }} -- python -c ' + from tmuxp import _internal, cli, workspace, exc, log, plugin, shell, types, util, __version__ + from tmuxp._internal import config_reader, types + from tmuxp.cli import convert, debug_info, edit, freeze, import_config, load, ls, shell, utils + from tmuxp.workspace import builder, constants, finders, freezer, importers, loader, validation + from libtmux import __version__ as __libtmux_version__ + print("tmuxp version:", __version__) + print("libtmux version:", __libtmux_version__) + ' + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Setup tmux build cache for tmux ${{ matrix.tmux-version }} + id: tmux-build-cache + uses: actions/cache@v5 + with: + path: ~/tmux-builds/tmux-${{ matrix.tmux-version }} + key: tmux-${{ matrix.tmux-version }} + + - name: Build tmux ${{ matrix.tmux-version }} + if: steps.tmux-build-cache.outputs.cache-hit != 'true' + run: | + sudo apt install libevent-dev libncurses5-dev libtinfo-dev libutempter-dev bison + mkdir ~/tmux-builds + mkdir ~/tmux-src + git clone https://github.com/tmux/tmux.git ~/tmux-src/tmux-${{ matrix.tmux-version }} + cd ~/tmux-src/tmux-${{ matrix.tmux-version }} + git checkout ${{ matrix.tmux-version }} + sh autogen.sh + ./configure --prefix=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }} && make && make install + export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH + cd ~ + tmux -V + + - name: Lint with ruff check . + run: uv run ruff check . + + - name: Format with ruff + run: uv run ruff format . --check + + - name: Lint with mypy + run: uv run mypy . + + - name: Print python versions + run: | + python -V + uv run python -V + + - name: Test with pytest + continue-on-error: ${{ matrix.tmux-version == 'master' }} + run: | + sudo apt install libevent-2.1-7 + export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH + ls $HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin + tmux -V + uv run py.test --cov=./ --cov-report=xml --verbose + + - uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + release: + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + permissions: + id-token: write + attestations: write + + strategy: + matrix: + python-version: ['3.14'] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Build package + run: uv build + + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true + skip-existing: true diff --git a/.gitignore b/.gitignore index cfc23d45dd..7e36db2f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -59,12 +59,19 @@ coverage.xml # Sphinx documentation docs/_build/ +# Mermaid diagram tooling (sphinx-gp-mermaid) +docs/node_modules/ +docs/_mermaid_cache/ + # PyBuilder target/ -#Ipython Notebook +# Ipython Notebook .ipynb_checkpoints +# mypy +.mypy_cache/ + # editors .idea .ropeproject @@ -73,3 +80,15 @@ target/ # docs doc/_build/ + +# MonkeyType +monkeytype.sqlite3 + +# Generated by sphinx_fonts extension (downloaded at build time) +docs/_static/fonts/ +docs/_static/css/fonts.css + +# Claude code +**/CLAUDE.local.md +**/CLAUDE.*.md +**/.claude/settings.local.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index db0347ee15..0000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -repos: -- repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black - language_version: python3.10 -- repo: https://github.com/pycqa/isort - rev: 5.10.1 - hooks: - - id: isort - name: isort (python) -- repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 - hooks: - - id: flake8 diff --git a/.python-version b/.python-version index bb57aeee83..6324d401a0 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10.1 3.9.9 3.8.11 3.7.12 +3.14 diff --git a/.tmuxp-before-script.sh b/.tmuxp-before-script.sh deleted file mode 100755 index 0721faabdd..0000000000 --- a/.tmuxp-before-script.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -poetry shell --no-ansi --no-interaction &2> /dev/null -poetry install --no-ansi --no-interaction &2> /dev/null diff --git a/.tmuxp.json b/.tmuxp.json index ff97a9be40..c2d1640c02 100644 --- a/.tmuxp.json +++ b/.tmuxp.json @@ -1,9 +1,8 @@ { "session_name": "tmuxp", "start_directory": "./", - "before_script": "./.tmuxp-before-script.sh", "shell_command_before": [ - "[ -f .venv/bin/activate ] && source .venv/bin/activate && reset" + "uv virtualenv --quiet > /dev/null 2>&1 && clear" ], "windows": [ { @@ -11,21 +10,22 @@ "focus": true, "layout": "main-horizontal", "options": { - "main-pane-height": 35 + "main-pane-height": "67%" }, "panes": [ { "focus": true }, "pane", - "make watch_test" + "just watch-mypy", + "just watch-test" ] }, { "window_name": "docs", "layout": "main-horizontal", "options": { - "main-pane-height": 35 + "main-pane-height": "67%" }, "start_directory": "docs/", "panes": [ @@ -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 c44e45b940..b004edf622 100644 --- a/.tmuxp.yaml +++ b/.tmuxp.yaml @@ -1,25 +1,25 @@ session_name: tmuxp start_directory: ./ # load session relative to config location (project root). -before_script: ./.tmuxp-before-script.sh shell_command_before: -- '[ -f .venv/bin/activate ] && source .venv/bin/activate && reset' +- uv virtualenv --quiet > /dev/null 2>&1 && clear windows: - window_name: tmuxp focus: True layout: main-horizontal options: - main-pane-height: 35 + main-pane-height: 67% panes: - focus: true - pane - - make watch_test + - just watch-mypy + - just watch-test - window_name: docs layout: main-horizontal options: - main-pane-height: 35 + main-pane-height: 67% start_directory: docs/ panes: - focus: true - pane - pane - - make start + - just start diff --git a/.tool-versions b/.tool-versions index 9e962809a8..cc11d913c4 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ -poetry 1.1.12 -python 3.10.1 3.9.9 3.8.11 3.7.12 +just 1.57.0 +uv 0.11.29 +python 3.14 3.13 3.12 3.11 3.10 diff --git a/.vim/coc-settings.json b/.vim/coc-settings.json new file mode 100644 index 0000000000..d542a72629 --- /dev/null +++ b/.vim/coc-settings.json @@ -0,0 +1,19 @@ +{ + "[markdown][python]": { + "coc.preferences.formatOnSave": true + }, + "python.analysis.autoSearchPaths": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.useLibraryCodeForTypes": true, + "python.formatting.provider": "ruff", + "python.linting.ruffEnabled": true, + "python.linting.mypyEnabled": true, + "python.linting.flake8Enabled": false, + "python.linting.pyflakesEnabled": false, + "python.linting.pycodestyleEnabled": false, + "python.linting.banditEnabled": false, + "python.linting.pylamaEnabled": false, + "python.linting.pylintEnabled": false, + "pyright.organizeimports.provider": "ruff", + "pyright.testing.provider": "pytest", +} diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 0000000000..80360fcb75 --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,170 @@ +# libtmux Python Project Rules + + +- uv - Python package management and virtual environments +- ruff - Fast Python linter and formatter +- py.test - Testing framework + - pytest-watcher - Continuous test runner +- mypy - Static type checking +- doctest - Testing code examples in documentation + + + +- Use a consistent coding style throughout the project +- Format code with ruff before committing +- Run linting and type checking before finalizing changes +- Verify tests pass after each significant change + + + +- Use reStructuredText format for all docstrings in src/**/*.py files +- Keep the main description on the first line after the opening `"""` +- Use NumPy docstyle for parameter and return value documentation +- Format docstrings as follows: + ```python + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1 + param2 : type + Description of param2 + + Returns + ------- + type + Description of return value + """ + ``` + + + +- Use narrative descriptions for test sections rather than inline comments +- Format doctests as follows: + ```python + """ + Examples + -------- + Create an instance: + + >>> obj = ExampleClass() + + Verify a property: + + >>> obj.property + 'expected value' + """ + ``` +- Add blank lines between test sections for improved readability +- Keep doctests simple and focused on demonstrating usage +- Move complex examples to dedicated test files at tests/examples//test_.py +- Utilize pytest fixtures via doctest_namespace for complex scenarios + + + +- Run tests with `uv run py.test` before committing changes +- Use pytest-watcher for continuous testing: `uv run ptw . --now --doctest-modules` +- Fix any test failures before proceeding with additional changes + + + +- Make atomic commits with conventional commit messages +- Start with an initial commit of functional changes +- Follow with separate commits for formatting, linting, and type checking fixes + + + +- Use the following commit message format: + ``` + Component/File(commit-type[Subcomponent/method]): Concise description + + why: Explanation of necessity or impact. + what: + - Specific technical changes made + - Focused on a single topic + + refs: #issue-number, breaking changes, or relevant links + ``` + +- Common commit types: + - **feat**: New features or enhancements + - **fix**: Bug fixes + - **refactor**: Code restructuring without functional change + - **docs**: Documentation updates + - **chore**: Maintenance (dependencies, tooling, config) + - **test**: Test-related updates + - **style**: Code style and formatting + +- Prefix Python package changes with: + - `py(deps):` for standard packages + - `py(deps[dev]):` for development packages + - `py(deps[extra]):` for extras/sub-packages + +- General guidelines: + - Subject line: Maximum 50 characters + - Body lines: Maximum 72 characters + - Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") + - Limit to one topic per commit + - Separate subject from body with a blank line + - Mark breaking changes clearly: `BREAKING:` + + + +- Use fixtures from conftest.py instead of monkeypatch and MagicMock when available +- For libtmux tests, use these provided fixtures for fast, efficient tmux resource management: + - `server`: Creates a temporary tmux server with isolated socket + - `session`: Creates a temporary tmux session in the server + - `window`: Creates a temporary tmux window in the session + - `pane`: Creates a temporary tmux pane in the pane + - `TestServer`: Factory for creating multiple independent servers with unique socket names +- Example usage with server fixture: + ```python + def test_something_with_server(server): + # server is already running with proper configuration + my_session = server.new_session("test-session") + assert server.is_alive() + ``` +- Example usage with session fixture: + ```python + def test_something_with_session(session): + # session is already created and configured + new_window = session.new_window("test-window") + assert new_window in session.windows + ``` +- Customize session parameters by overriding the session_params fixture: + ```python + @pytest.fixture + def session_params(): + return { + 'x': 800, + 'y': 600, + 'window_name': 'custom-window' + } + ``` +- Benefits of using libtmux fixtures: + - No need to manually set up and tear down tmux infrastructure + - Tests run in isolated tmux environments + - Faster test execution + - Reliable test environment with predictable configuration +- Document in test docstrings why standard fixtures weren't used for exceptional cases +- Use tmp_path (pathlib.Path) fixture over Python's tempfile +- Use monkeypatch fixture over unittest.mock + + + +- Prefer namespace imports over importing specific symbols +- Import modules and access attributes through the namespace: + - Use `import enum` and access `enum.Enum` instead of `from enum import Enum` + - This applies to standard library modules like pathlib, os, and similar cases +- For typing, use `import typing as t` and access via the namespace: + - Access typing elements as `t.NamedTuple`, `t.TypedDict`, etc. + - Note primitive types like unions can be done via `|` pipes + - Primitive types like list and dict can be done via `list` and `dict` directly +- Benefits of namespace imports: + - Improves code readability by making the source of symbols clear + - Reduces potential naming conflicts + - Makes import statements more maintainable + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..3e0a275948 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,645 @@ +# AGENTS.md + +This file provides guidance to AI agents (e.g., Claude Code, Cursor, and other LLM-powered tools) when working with code in this repository. + +## Project Overview + +tmuxp is a session manager for tmux that allows users to save and load tmux sessions through YAML/JSON configuration files. It's powered by libtmux and provides a declarative way to manage tmux sessions. + +## Development Commands + +### Testing +- `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 +- `just start` or `just watch-test` - Watch and run tests on file changes + +### Code Quality +- `just ruff` or `uv run ruff check .` - Run linter +- `uv run ruff check . --fix --show-fixes` - Fix linting issues automatically +- `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 +- `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 +- `tmuxp load -d ` - Load session in detached state +- `tmuxp freeze ` - Export running session to config +- `tmuxp convert ` - Convert between YAML and JSON +- `tmuxp shell` - Interactive Python shell with tmux context +- `tmuxp debug-info` - Collect system info for debugging + +## Architecture + +### Core Components + +1. **CLI Module** (`src/tmuxp/cli/`): Entry points for all tmuxp commands + - `load.py`: Load tmux sessions from config files + - `freeze.py`: Export live sessions to config files + - `convert.py`: Convert between YAML/JSON formats + - `shell.py`: Interactive Python shell with tmux context + +2. **Workspace Module** (`src/tmuxp/workspace/`): Core session management + - `builder.py`: Builds tmux sessions from configuration + - `loader.py`: Loads and validates config files + - `finders.py`: Locates workspace config files + - `freezer.py`: Exports running sessions to config + +3. **Plugin System** (`src/tmuxp/plugin.py`): Extensibility framework + - Plugins extend `TmuxpPlugin` base class + - Hooks: `before_workspace_builder`, `on_window_create`, `after_window_finished`, `before_script`, `reattach` + - Version constraint checking for compatibility + +### Configuration Flow + +1. Load YAML/JSON config via `ConfigReader` (handles includes, environment variables) +2. Expand inline shorthand syntax +3. Trickle down default values (session → window → pane) +4. Validate configuration structure +5. Build tmux session via `WorkspaceBuilder` + +### Key Patterns + +- **Type Safety**: All code uses type hints with mypy strict mode +- **Error Handling**: Custom exception hierarchy based on `TmuxpException` +- **Testing**: Pytest with fixtures for tmux server/session/window/pane isolation +- **Future Imports**: All files use `from __future__ import annotations` + +## Configuration Format + +```yaml +session_name: my-session +start_directory: ~/project +windows: + - window_name: editor + layout: main-vertical + panes: + - shell_command: + - vim + - shell_command: + - git status +``` + +## Environment Variables + +- `TMUXP_CONFIGDIR`: Custom directory for workspace configs +- `TMUX_CONF`: Path to tmux configuration file +- `TMUXP_DEFAULT_COLUMNS/ROWS`: Default session dimensions + +## 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::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` +- **Use `monkeypatch`** fixture instead of `unittest.mock` + +## Code Style + +- Follow NumPy-style docstrings (pydocstyle convention) +- Use ruff for formatting and linting +- Maintain strict mypy type checking +- Keep imports organized with future annotations at top +- **Prefer namespace imports for stdlib**: Use `import enum` and `enum.Enum` instead of `from enum import Enum`; third-party packages may use `from X import Y` +- **Type imports**: Use `import typing as t` and access via namespace (e.g., `t.Optional`) +- **Development workflow**: Format → Test → Commit → Lint/Type Check → Test → Final Commit + +## Git Commit Standards + +Format commit messages as: +``` +Scope(type[detail]): concise description + +why: Explanation of necessity or impact. + +what: +- Specific technical changes made +- Focused on a single topic +``` + +Keep the subject ≤50 chars (excluding any trailing `(#NN)` PR ref); wrap +body lines at ≤72 chars. Separate the `why:` and `what:` blocks with a +blank line. + +Common commit types: +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting +- **py(deps)**: Dependencies +- **py(deps[dev])**: Dev Dependencies +- **ai(rules[AGENTS])**: AI rule updates +- **ai(claude[rules])**: Claude Code rules (CLAUDE.md) +- **ai(claude[command])**: Claude Code command changes + +Example: +``` +Pane(feat[send_keys]): Add support for literal flag + +why: Enable sending literal characters without tmux interpretation + +what: +- Add literal parameter to send_keys method +- Update send_keys to pass -l flag when literal=True +- Add tests for literal key sending +``` +#### Release commits + +Never create tags. Never push tags. The user handles tagging and tag +pushes (tags trigger the CI publish workflow). + +Release commit subjects are plain and short: `Tag v`. Put +the detailed why/what in the commit body. Don't use the +`Scope(type[detail]):` format for releases — don't bury the lede. + +For multi-line commits, use heredoc to preserve formatting: +```bash +git commit -m "$(cat <<'EOF' +feat(Component[method]) add feature description + +why: Explanation of the change. + +what: +- First change +- Second change +EOF +)" +``` + +## Logging Standards + +These rules guide future logging changes; existing code may not yet conform. + +### Logger setup + +- Use `logging.getLogger(__name__)` in every module +- Add `NullHandler` in library `__init__.py` files +- Never configure handlers, levels, or formatters in library code — that's the application's job + +### Structured context via `extra` + +Pass structured data on every log call where useful for filtering, searching, or test assertions. + +**Core keys** (stable, scalar, safe at any log level): + +| Key | Type | Context | +|-----|------|---------| +| `tmux_cmd` | `str` | tmux command line | +| `tmux_subcommand` | `str` | tmux subcommand (e.g. `new-session`) | +| `tmux_target` | `str` | tmux target specifier (e.g. `mysession:1.2`) | +| `tmux_exit_code` | `int` | tmux process exit code | +| `tmux_session` | `str` | session name | +| `tmux_window` | `str` | window name or index | +| `tmux_pane` | `str` | pane identifier | +| `tmux_config_path` | `str` | workspace config file path | +| `tmux_layout` | `str` | window layout string | + +**Heavy/optional keys** (DEBUG only, potentially large): + +| Key | Type | Context | +|-----|------|---------| +| `tmux_stdout` | `list[str]` | tmux stdout lines (truncate or cap; `%(tmux_stdout)s` produces repr) | +| `tmux_stderr` | `list[str]` | tmux stderr lines (same caveats) | + +Treat established keys as compatibility-sensitive — downstream users may build dashboards and alerts on them. Change deliberately. + +### Key naming rules + +- `snake_case`, not dotted; `tmux_` prefix +- Prefer stable scalars; avoid ad-hoc objects +- Heavy keys (`tmux_stdout`, `tmux_stderr`) are DEBUG-only; consider companion `tmux_stdout_len` fields or hard truncation (e.g. `stdout[:100]`) + +### Lazy formatting + +`logger.debug("msg %s", val)` not f-strings. Two rationales: +- Deferred string interpolation: skipped entirely when level is filtered +- Aggregator message template grouping: `"Running %s"` is one signature grouped ×10,000; f-strings make each line unique + +When computing `val` itself is expensive, guard with `if logger.isEnabledFor(logging.DEBUG)`. + +### stacklevel for wrappers + +Increment for each wrapper layer so `%(filename)s:%(lineno)d` and OTel `code.filepath` point to the real caller. Verify whenever call depth changes. + +### LoggerAdapter for persistent context + +For objects with stable identity (Session, Window, Pane), use `LoggerAdapter` to avoid repeating the same `extra` on every call. Lead with the portable pattern (override `process()` to merge); `merge_extra=True` simplifies this on Python 3.13+. + +### Log levels + +| Level | Use for | Examples | +|-------|---------|----------| +| `DEBUG` | Internal mechanics, tmux I/O, config expansion | tmux command + stdout, trickle-down steps | +| `INFO` | Session lifecycle, user-visible operations | Session created, window added, workspace loaded | +| `WARNING` | Recoverable issues, deprecation, user-actionable config | Deprecated key, missing optional program | +| `ERROR` | Failures that stop an operation | tmux command failed, config validation error | + +Config discovery noise belongs in `DEBUG`; only surprising/user-actionable config issues → `WARNING`. + +### Message style + +- Lowercase, past tense for events: `"session created"`, `"tmux command failed"` +- No trailing punctuation +- Keep messages short; put details in `extra`, not the message string + +### Exception logging + +- Use `logger.exception()` only inside `except` blocks when you are **not** re-raising +- Use `logger.error(..., exc_info=True)` when you need the traceback outside an `except` block +- Avoid `logger.exception()` followed by `raise` — this duplicates the traceback. Either add context via `extra` that would otherwise be lost, or let the exception propagate + +### Testing logs + +Assert on `caplog.records` attributes, not string matching on `caplog.text`: +- Scope capture: `caplog.at_level(logging.DEBUG, logger="libtmux.common")` +- Filter records rather than index by position: `[r for r in caplog.records if hasattr(r, "tmux_cmd")]` +- Assert on schema: `record.tmux_exit_code == 0` not `"exit code 0" in caplog.text` +- `caplog.record_tuples` cannot access extra fields — always use `caplog.records` + +### Output channels + +Two output channels serve different audiences: + +1. **Diagnostics** (`logger.*()` with `extra`): System events for log files, `caplog`, and aggregators. Never styled. +2. **User-facing output**: What the human sees. Styled via `Colors` class. + - Commands with output modes (`--json`/`--ndjson`): prefer `OutputFormatter.emit_text()` from `tmuxp.cli._output` — silenced in non-human modes. + - Human-only commands: use `tmuxp_echo()` from `tmuxp.log` (re-exported via `tmuxp.cli.utils`) for user-facing messages. + - **Undefined contracts:** Machine-output behavior for error and empty-result paths (e.g., `search` with no matches) is not yet defined. These paths currently emit styled text through `formatter.emit_text()`, which is a no-op in machine modes. + +Raw `print()` is forbidden in command/business logic. The `print()` call lives only inside the presenter layer (`_output.py`) or `tmuxp_echo`. + +### Avoid + +- f-strings/`.format()` in log calls +- Unguarded logging in hot loops (guard with `isEnabledFor()`) +- Catch-log-reraise without adding new context +- `print()` for debugging or internal diagnostics — use `logger.debug()` with structured `extra` instead +- Logging secret env var values (log key names only) +- Non-scalar ad-hoc objects in `extra` +- Requiring custom `extra` fields in format strings without safe defaults (missing keys raise `KeyError`) + +## 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 + +**Doctest exceptions** (patterns where doctests are not required): + +1. **Sphinx/docutils `visit_*`/`depart_*` methods** - tested via integration tests; 0 examples across docutils (851 methods), Sphinx (800+), and CPython's `ast.NodeVisitor` +2. **Sphinx `setup()` functions** - entry points not testable in isolation +3. **Complex recursive traversal functions** - extract helper predicates instead + +**Best practice for node processing**: Extract testable helper functions (like `_is_usage_block()`) and doctest those. Keep complex visitor logic in integration tests. + +## 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. For sequential commands, either use separate code blocks or chain them with `&&` or `;` and `\` continuations (keeping it one logical command). + +**Put explanations outside the code block**, not as comments inside. + +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 +``` + +### Shell Command Formatting + +These rules apply to shell commands in documentation (README, CHANGES, docs/), **not** to Python doctests. + +**Use `console` language tag with `$ ` prefix.** This distinguishes interactive commands from scripts and enables prompt-aware copy in many terminals. + +Good: + +```console +$ uv run pytest +``` + +Bad: + +```bash +uv run pytest +``` + +**Split long commands with `\` for readability.** Each flag or flag+value pair gets its own continuation line, indented. Positional parameters go on the final line. + +Good: + +```console +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' +``` + +Bad: + +```console +$ pipx install --suffix=@next --pip-args '\--pre' --force 'tmuxp' +``` + +### Changelog Conventions + +These rules apply when authoring entries in `CHANGES`, which is rendered as the Sphinx changelog page. Modeled on Django's release-notes shape — deliverables get titles and prose, not bullets. + +**Release entry boilerplate.** Every release header is `## tmuxp X.Y.Z (YYYY-MM-DD)`. The file opens with a `## tmuxp X.Y.Z (Yet to be released)` placeholder block fenced by `` and `` HTML comments — new release entries land immediately below the END marker, never above it. + +**Open with a multi-sentence lead paragraph.** Plain prose, no italic. Open with the version as sentence subject (*"tmuxp X.Y.Z ships …"*) so the lead is self-contained when excerpted. Two to four sentences telling the reader what shipped and who cares — user-visible takeaways, not internal mechanism. Cross-reference detail docs with `{ref}` to keep the lead compact. + +**Each deliverable is a section, not a bullet.** Inside `### What's new`, every distinct deliverable gets a `#### Deliverable title (#NN)` heading naming it in user vocabulary, followed by 1-3 prose paragraphs explaining what shipped. Don't wrap a paragraph in `- ` — bullets are for enumerable lists, not paragraph containers. Cross-link detail docs (`See {ref}\`foo\` for details.`) so prose stays focused. + +**The deliverable test.** Before writing an entry, ask: "What's the deliverable, in user vocabulary?" If you can't answer in one sentence, the entry isn't ready. Mechanism (helper internals, byte counters, schema-validation locations) belongs in PR descriptions and code comments, not the changelog. + +**Fixed subheadings**, in this order when present: `### Breaking changes`, `### Dependencies`, `### What's new`, `### Fixes`, `### Documentation`, `### Development`. Dev tooling (helper scripts, internal automation) lives under `### Development`. For breaking changes, show the migration path with concrete inline code (e.g. a `# Before` / `# After` fenced code block). Dependency floor bumps use the form ``Minimum `pkg>=X.Y.Z` (was `>=X.Y.W`)``. + +**PR refs `(#NN)`** sit in each deliverable's `####` heading. + +**When bullets are appropriate.** Catch-all sections (`### Fixes`, occasionally `### Documentation`) with 3+ genuinely small items use bullets — one line each, never paragraphs. If a bullet swells past two lines, promote it to a `#### Title (#NN)` heading with prose body. + +**Anti-patterns.** + +- Fragile metrics: token ceilings, third-party version pins, percent benchmarks, exact byte counts. Describe the *capability*, not the math. +- Internal jargon: private symbols (leading-underscore identifiers), algorithm names exposed for the first time, backend scaffolding. +- Walls of text dressed up as bullets. +- Buried breaking changes — they get their own subheading at the top of the entry. + +**Always link autodoc'd APIs.** Any class, method, function, exception, or attribute that has its own rendered page must be cited via the appropriate role (`{class}`, `{meth}`, `{func}`, `{exc}`, `{attr}`) — never with plain backticks. Doc pages without explicit ref labels use `{doc}`. Plain backticks are correct for code syntax, env vars, parameter names, and file paths that aren't doc pages — anything without an autodoc destination. + +**MyST roles.** Class references use `{class}` (e.g. `{class}\`~tmuxp.workspace.builder.WorkspaceBuilder\``), methods use `{meth}`, functions use `{func}`, exceptions use `{exc}`, attributes use `{attr}`, internal anchors use `{ref}`, doc-path links use `{doc}`. + +**Summarization style.** When a user asks "what changed in the latest version?" or similar, lead with the entry's lead paragraph (paraphrased if needed), followed by each `####` deliverable heading under `### What's new` with a one-sentence summary. Cite `(#NN)` only if the user asks for source links. Don't invent versions, dates, or numbers not present in `CHANGES`. Don't quote line numbers or file offsets — those shift as the file evolves. + +## Important Notes + +- **QA every edit**: Run formatting and tests before committing +- **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. + +## AI Slop Prevention + +Treat AI slop as **review-hostile noise**, not as proof that text or +code is wrong. The goal is to maximize information density by removing +artifacts that make the repository harder to trust or navigate. + +### The Anti-Slop Rubric + +Before committing, audit all AI-assisted changes for these noise +patterns: + +- **AI Signatures:** Remove "Generated by", footers, conversational + filler ("Certainly!", "Here is..."), unexplained emojis (🤖, ✨), and + AI-tool metadata. +- **Brittle References:** Avoid hard-coded line numbers, fragile + file/test counts, dated "as of" claims, bare SHAs, and local + absolute paths unless they are strict evidentiary artifacts (e.g., + benchmark logs). +- **Diff Narration:** Do not restate what moved, was renamed, or was + removed in artifacts the downstream reader holds: code, docstrings, + README, CHANGES, PR descriptions, or release notes. The diff and + commit message already carry this history. +- **Branch-Internal Narrative:** Do not mention intermediate branch + states, abandoned approaches, or "no longer" behavior unless users + of a published release actually experienced the old state (**The + Published-Release Test**). +- **Low-Value Scaffolding:** Remove ownerless TODOs (`TODO: revisit`), + unused future-proofing, debug artifacts, and defensive wrappers that + do not protect a currently reachable failure mode. +- **Prose Inflation:** Replace generic AI "tells" like *comprehensive, + robust, seamless, production-ready, leverage, delve, tapestry,* and + *best practices* with concrete descriptions of behavior, + constraints, or trade-offs. +- **Coded Labels:** Write rules, options, and findings as plain + imperatives. Don't tag them with codes like `[R1]`, `A1`, or + `Option B` in artifacts a human reads — the reader shouldn't have to + decode an index. Internal agent bookkeeping may use ids; shipped text + may not. + +### Durable Source Links + +Link to a pinned revision, never to trunk. A pinned permalink is not a +brittle reference; an unlinked SHA dropped into prose is. `blob/master/…` +links rot silently — the file moves, lines shift, and the anchor lands +on unrelated code while still resolving. + +- Prefer a release tag (`blob/v1.4.0/…`). Most durable, and it tells + the reader which released version the claim held for. +- Otherwise use a 7-char commit ref (`blob/9a29b1a/…`) reachable from + trunk. Use when there is no tag or the claim is about unreleased + code. Never a PR-head SHA — it can be rebased or garbage-collected. +- Reserve `blob/master/…` for living documents meant to always show the + latest state, such as a contributing guide. +- Line anchors (`#L120-L145`) are only safe on a pinned ref. + +### Preservation & Context + +**When unsure, leave the text in place and ask.** Subjective cleanup +must never be a reason to remove load-bearing rationale. + +- **Preserve the "Why":** You MUST NOT delete comments that document + invariants, protocol constraints, platform quirks, security + boundaries, and upstream workarounds. +- **Evidence is Immune:** Preserve exact counts, dates, and SHAs when + they serve as evidence in benchmark results, release notes, stack + traces, or lockfiles. +- **Behavior Over Inventory:** A useful description explains what + changed for the *system or user*; it does not provide an inventory + of files or functions the diff already shows. + +### The Published-Release Test + +Long-running branches accumulate tactical decisions — renames, +refactors, attempts-then-reverts. When deciding what counts as +branch-internal, use trunk or the parent branch as the baseline — not +intermediate states inside the current branch. Ask: + +> Did users of the most recently published release ever experience +> this old name, old behavior, or bug? + +If the answer is **no**, it is branch-internal narrative. Move it to +the commit message and describe only the final state in the artifact. + +**Keep in shipped artifacts:** +- Deprecations and migration guides for symbols that actually shipped. +- `### Fixes` entries for bugs that affected users of a published + release. +- Comments explaining *why the current code looks this way* + (invariants, platform quirks) that make sense to a reader who never + saw the previous version. + +### Cleanup in Hindsight + +When applying these rules retroactively from inside a feature branch, +first establish scope by diffing against the parent branch (or trunk) +to identify which commits this branch actually introduced. Then: + +- **In-branch commits:** Prompt the user with two options: `fixup!` + commits with `git rebase --autosquash` to address each causal commit + at its source, or a single cleanup commit at branch tip. +- **Trunk/Parent commits:** Default to leaving them alone. Act only on + explicit user instruction. If the user opts in, fold the cleanup + into a single commit at branch tip; do not rewrite shared history. +- **Scope guard:** If cleaning prior slop would touch a colleague's + work or expand the branch beyond its stated goal, stay in lane: + protect the current goal and leave prior slop alone. + +### Change Discipline + +- Make the smallest coherent change that solves the verified problem; + keep unrelated cleanup out of it. +- Reuse an existing file, component, helper, API, or test before adding + a new one. Modify in place when the change fits the file's + responsibility. +- Keep new APIs private until a caller outside the module needs them. +- Add a file only for a durable boundary — a distinct responsibility, + independent reuse, or splitting an oversized high-touch module — not + for a single-use helper or a one-line re-export. + +### Keep Instructions Lean + +Treat this file like code and prune it. + +- Delete a line whose removal would not cause a mistake. +- Move multi-step procedures into skills, path-specific rules into + nested AGENTS.md files, and hard limits into hooks or CI. +- Keep only non-obvious, broadly applicable defaults here. Anything a + reader can infer from the code, a manifest, or a linter does not + belong. diff --git a/CHANGES b/CHANGES index 4b74563bdd..0b2d4fe37f 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,6 @@ # Changelog -To install the unreleased tmuxp version, see [developmental releases](https://tmuxp.git-pull.com/quickstart.html#developmental-releases). +To install the unreleased tmuxp version, see {ref}`developmental-releases`. [pip](https://pip.pypa.io/en/stable/): @@ -8,1370 +8,3050 @@ To install the unreleased tmuxp version, see [developmental releases](https://tm $ pip install --user --upgrade --pre tmuxp ``` +[uv](https://docs.astral.sh/uv/getting-started/features/#python-versions): + +```console +$ uv add tmuxp --prerelease allow +``` + +[uvx](https://docs.astral.sh/uv/guides/tools/): + +```console +$ uvx --from 'tmuxp' --prerelease allow tmuxp +``` + [pipx](https://pypa.github.io/pipx/docs/): ```console -$ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force -// Usage: tmuxp@next load yoursession +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' ``` -## 1.11 - unreleased +Run the developmental install with: + +```console +$ tmuxp@next load yoursession +``` + +## tmuxp 1.75.0 (Yet to be released) + + + + +_Notes on the upcoming release will go here._ + + +### Documentation + +#### Diagrams stay readable on narrow screens (#1076) + +Every diagram now declares alt text, a stable link target, and a responsive +policy that scales it to the content column, and the architecture chart flows +top-to-bottom instead of running wide. Phone-width layouts and screen readers +get the same information as the desktop site. + +#### Inline commands and paths are highlighted (#1076) + +Inline mentions of `tmuxp` commands and file paths now carry syntax +highlighting via the shared `sphinx-gp-highlighting` package, and the plugin +module layout renders as a highlighted directory tree. + +## tmuxp 1.74.0 (2026-07-04) + +tmuxp 1.74.0 pairs a libtmux upgrade with a documentation overhaul. It bumps libtmux to 0.61.0 — hardening tmux 3.7 patch-line support — and teaches `tmuxp debug-info` to report the exact tmux patch release (`3.7a`/`3.7b`) instead of the numeric-normalized version. The docs also gain theme-aware inline diagrams and a concept-first rewrite that leads with what each feature is before its configuration. + +### Dependencies + +#### Minimum `libtmux~=0.61.0` (was `~=0.60.0`) (#1074) + +Picks up libtmux 0.61.0, which hardens tmux 3.7 patch-line support: +{meth}`~libtmux.Pane.break_pane` keeps tmux's own default window name on +tmux 3.7a/3.7b instead of forcing `libtmux`, and the new +{func}`~libtmux.common.get_version_str` exposes the raw tmux version +string. tmux 3.2a-3.6 are unaffected. + +### What's new + +#### `tmuxp debug-info` reports the exact tmux patch release (#1074) + +`tmuxp debug-info` now shows tmux's full version, keeping the +point-release letter (for example `3.7a`) that was previously normalized +away to `3.7`. Because tmux patch releases can differ in behavior, the +precise release is what a bug report needs. This reads libtmux 0.61.0's +{func}`~libtmux.common.get_version_str`. + +### Documentation + +#### Themed diagrams across the docs (#1071) -- _Insert changes/features/fixes for next release here_ +The docs now render their diagrams as theme-aware inline graphics that follow the +site's light/dark palette and code styling, replacing the old static ASCII art. +{ref}`workspace-builders` gains a load → build → attach flow diagram and opens +with a concept-first overview, and {ref}`examples` shows each pane example as a +faithful tmux window layout. -### Maintenance +#### Concept-first documentation pass (#1072) -- {issue}`762`: CLI: Break up into modules in _cli.py_ -> _cli/{command}_ -- {issue}`761`: Refactor _tests/_ constants and fixtures +The docs now open with what each feature *is* before its configuration, in a +consistent second-person voice, with advanced and Python-only material marked +opt-in. New diagrams trace the plugin hook lifecycle, the CLI request flow, the +workspace hierarchy, and what `tmuxp load` does inside an existing session, and +more internal API cross-references resolve to their pages. The {ref}`quickstart` +and {ref}`about-tmux` primer also pick up corrected version requirements, +tooling, and commands. ### Development -- Remove tox and tox-poetry-installer +#### Docs adopt the upstream `sphinx-gp-mermaid` renderer (#1073) - This created issues with running poetry while inside the virtualenv. +The build-time mermaid diagram renderer, previously bundled with the docs, now +comes from the reusable `sphinx-gp-mermaid` package (a dev/docs dependency). +Rendered output is unchanged. -## tmuxp 1.10.0 (2022-03-19) +## tmuxp 1.73.0 (2026-06-28) + +tmuxp 1.73.0 makes the workspace build step pluggable and tunable. A workspace can now build through a third-party builder selected by registered entry-point name or Python import path, and a new `workspace_builder_options` catalog controls the pane-readiness wait per workspace. The built-in builder stays the default, so existing workspaces keep working — though the new `pane_readiness: auto` default skips the prompt wait on non-zsh shells. See {ref}`custom-workspace-builders` for the guide. + +### What's new + +#### Pluggable workspace builders (#1066) + +tmuxp can now build a workspace with a builder other than its built-in one. Point +a workspace file's `workspace_builder` key at an installed builder — by registered +name or by Python import path — and tmuxp loads the session through it. Builders +that ship outside tmuxp's environment can be imported from directories listed in +`workspace_builder_paths`, which tmuxp trusts only for that load. The built-in +builder stays the default, so existing workspaces are unaffected. See +{ref}`custom-workspace-builders`. + +#### Configurable pane readiness (#1066) + +Before sending a pane's layout and commands, tmuxp can wait for the shell to draw +its prompt — a guard against a zsh prompt-redraw artifact. That wait is now a +policy you set per workspace: + +```yaml +workspace_builder_options: + pane_readiness: auto # auto | always | never (also true/false) +``` + +The new default, `auto`, waits only when the session's shell is zsh, so zsh users +see no change while bash, sh, and other shells skip a wait they never needed. Set +`always` to keep the previous wait-everywhere behavior, or `never` to skip it +entirely. See {ref}`custom-workspace-builders`. + +#### Workspace builder API (#1066) + +`tmuxp.workspace.builder` is now a package. The default builder is +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`, with +`WorkspaceBuilder` kept as a backwards-compatible alias. Third-party builders +implement {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +(shaped to allow async builders later) and are resolved by +{func}`~tmuxp.workspace.builder.registry.resolve_builder_class` from an entry +point or import path, with trusted import directories handled by +{func}`~tmuxp.workspace.builder.registry.resolve_builder_paths`. Builder behavior +is configured through {class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` +and {class}`~tmuxp.workspace.options.PaneReadiness`, and resolution failures raise +{exc}`~tmuxp.exc.WorkspaceBuilderError` and its subclasses. See +{ref}`custom-workspace-builders` for the guide. + +## tmuxp 1.72.0 (2026-06-28) + +tmuxp 1.72.0 bumps libtmux to 0.60.0, completing tmux 3.7 feature parity at the library layer tmuxp builds on. The new libtmux capabilities — floating panes, typed tmux 3.7 options, new pane format variables, and new command flags — are reachable through tmuxp's Python API and `tmuxp shell`, while the YAML workspace format and tmux 3.2a-3.6 support stay unchanged. + +### Dependencies -### Compatibility +#### Minimum `libtmux~=0.60.0` (was `~=0.59.0`) (#1065) -- Final python 3.7 and 3.8 release +### What's new + +#### libtmux 0.60.0 completes tmux 3.7 feature parity (#1065) + +The libtmux floor bump brings tmux 3.7 feature parity to the library layer tmuxp builds on: floating panes ({meth}`~libtmux.Window.new_pane`), typed tmux 3.7 server, session, window, and pane options, new pane format variables, and new tmux 3.7 command flags. Every 3.7-only surface is version-gated, so tmux 3.2a-3.6 are unaffected. These APIs are reachable through tmuxp's Python API and `tmuxp shell`; the YAML workspace format is unchanged. + +## tmuxp 1.71.0 (2026-06-27) - Bug fixes and security updates will go to - [`v1.10.x`](https://github.com/tmux-python/tmuxp/tree/v1.10.x) +tmuxp 1.71.0 bumps libtmux to 0.59.0, adding support for tmux 3.7. + +### Dependencies + +#### Minimum `libtmux~=0.59.0` (was `~=0.58.1`) (#1057) ### What's new -- {issue}`747`: Skip execution via `enter: false` - - See {ref}`enter`. - - :::{note} +#### tmux 3.7 support (#1057) + +tmuxp now supports tmux 3.7, which joins 3.2a through 3.6 in the tested version range. There are no behavior changes for users on earlier tmux releases. + +## tmuxp 1.70.1 (2026-06-16) + +tmuxp 1.70.1 bumps libtmux to 0.58.1, fixing a pytest 9.1 plugin import failure that broke test collection for projects using tmuxp's or libtmux's fixtures. + +### Dependencies + +#### Minimum `libtmux~=0.58.1` (was `~=0.58.0`) (#1052) + +### Fixes + +#### pytest 9.1 plugin import compatibility (#1052) + +libtmux's pytest plugin aborted at import under pytest 9.1, breaking test collection for projects that use tmuxp's or libtmux's fixtures. Fixed by the libtmux 0.58.1 floor bump. + +## tmuxp 1.70.0 (2026-05-23) + +tmuxp 1.70.0 bumps libtmux to 0.58.0, fixing session and window listing on systems whose locale is not UTF-8. - _Experimental setting_: behavior and api is subject to change until stable. +### Dependencies - ::: +#### Minimum `libtmux~=0.58.0` (was `~=0.57.1`) (#1045) - ```yaml - session_name: Should not execute - windows: - - panes: - - shell_command: - - echo "this sends" - - cmd: echo "___$((1 + 3))___" - enter: false - # pane-wide skip - - shell_command: - - echo "___$((1 + 3))___" - enter: false - ``` +### Fixes -- {issue}`750`: Pause execution via `sleep_before: [int]` and `sleep_after: [int]` +#### Non-UTF-8 locale decoding (#1045) - See {ref}`sleep`. +Session and window listing could silently return empty results when the system locale was not UTF-8. Fixed by the libtmux 0.58.0 floor bump. - :::{note} +## tmuxp 1.69.0 (2026-05-23) - _Experimental setting_: behavior and api is subject to change until stable. +tmuxp 1.69.0 is a libtmux that expands functionality of `tmuxp shell` under the hood. - ::: +### Dependencies - ```yaml - session_name: Pause / skip command execution (command-level) - windows: - - panes: - - shell_command: - # Executes immediately - - echo "___$((11 + 1))___" - # Delays before sending 2 seconds - - cmd: echo "___$((1 + 3))___" - sleep_before: 2 - # Executes immediately - - cmd: echo "___$((1 + 3))___" - # Pauses 2 seconds after - - cmd: echo "Stuff rendering here!" - sleep_after: 2 - # Executes after earlier commands (after 2 sec) - - cmd: echo "2 seconds later" - ``` +#### Minimum `libtmux~=0.57.1` (was `~=0.56.0`) (#1043) -- {issue}`701`: `tmuxp freeze` now accepts `--quiet` and `--yes` along with the - `--config-format` and filename (`--save-to`). This means you can do it all in - one command: +The libtmux bump pulls in the 0.57.x command-coverage and client-awareness releases. Scripts launched through {ref}`tmuxp-shell` can now work with attached-client objects and use tmux-native filtering to query sessions, windows, and panes without manual post-filtering. - `tmuxp freeze -yqo .tmuxp.yaml` +## tmuxp 1.68.0 (2026-05-10) - Or bind it to `.tmux.conf` itself: `bind -T root C-s run-shell "tmuxp freeze -yqo .tmuxp.yaml"` +tmuxp 1.68.0 refreshes the documentation stack and developer workflow for the next release train. The docs now sit on the shared [gp-sphinx](https://gp-sphinx.git-pull.com/) platform, the API pages pick up the newer gp-furo visual language, and the test suite avoids paying for each contributor's interactive shell startup. The release also raises the libtmux floor so {ref}`tmuxp-shell` users can script against the expanded tmux command wrapper surface. - Credit: [@davidatbu](https://github.com/davidatbu) +### Dependencies -- {issue}`672`: Panes now accept `shell` for their initial command. +#### Minimum `libtmux~=0.56.0` (was `~=0.55.0`) (#1038) - Equivalent to `tmux split-windows`'s `[shell-command]` +The libtmux bump pulls in the 0.55.1 test-fixture socket cleanup and the 0.56.0 command-coverage release. tmuxp's runtime imports stay on stable APIs, while scripts launched through {ref}`tmuxp-shell` can now reach more upstream wrappers for interactive commands, buffer I/O, key bindings, and window/pane manipulation without falling back to raw `cmd()` calls. - ```yaml - session_name: Pane shell example - windows: - - window_name: first - window_shell: /usr/bin/python2 - layout: even-vertical - suppress_history: false - options: - remain-on-exit: true - panes: - - shell: /usr/bin/python3 - shell_command: - - print('This is python 3') - - shell: /usr/bin/vim -u none - shell_command: - - iAll panes have the `remain-on-exit` setting on. - - When you exit out of the shell or application, the panes will remain. - - Use tmux command `:kill-pane` to remove the pane. - - Use tmux command `:respawn-pane` to restart the shell in the pane. - - Use and then `:q!` to get out of this vim window. :-) - - shell_command: - - print('Hello World 2') - - shell: /usr/bin/top - ``` +### Documentation + +#### Shared gp-sphinx documentation platform (#1033, #1035, #1036, #1037) + +The docs move from repo-local Sphinx extension copies to the shared gp-sphinx package family. That makes the tmuxp site inherit the common Furo-based theme, IBM Plex typography, packaged argparse documentation helpers, API badge styling, and MyST-aware object references used across the surrounding Python projects. + +The later gp-sphinx bumps bring in the `gp-furo-theme` Tailwind v4 respin and `sphinx-vite-builder` asset pipeline. In practice, this keeps tmuxp's CLI and API docs visually aligned with the newer [libtmux-mcp](https://libtmux-mcp.git-pull.com/) docs without keeping private copies of the same theme and extension code. - Credit: @jerri +#### Documentation command blocks are easier to copy (#1024) -### Improvements +Shell examples now use one command per `console` code block with a `$` prompt. Long commands are split with continuations so rendered docs stay readable while still copying cleanly. -- Improve `tmuxp freeze` UX flow, credit @joseph-flinn ({issue}`657`, in re: {issue}`627`) -- `tmuxp freeze` will now detect the attached session if no session name - is specified. Credit: @will-ockmore. ({issue}`660`) +### Development + +#### Test panes spawn `/bin/sh` instead of the developer's `$SHELL` (#1041) -### Bugs +The pytest suite now pins `$SHELL=/bin/sh` while tests run, so tmux panes created by fixtures skip the developer's interactive shell startup files. That removes a large local performance cost for zsh or heavily customized shell setups and does not change tmuxp runtime behavior. -- Fix loading of `.yml` files with `tmuxp convert`, thank you @kalixi! ({issue}`725`) +## tmuxp 1.67.0 (2026-03-08) -### Internal API +tmuxp 1.67.0 makes {ref}`tmuxp-load` visibly track the workspace build it is performing. Users get progress feedback while windows, panes, and `before_script` hooks are being prepared, and automation can still disable the display entirely. + +### What's new -- {issue}`752`: Command structure (internal API) +#### Animated progress for `tmuxp load` (#1020) - To pave the way for per-command options such as `enter: false` ({issue}`53`), commands are now a different format: +The {ref}`tmuxp-load` command now shows an animated progress display while it builds a session. Built-in formats cover terse, window-focused, pane-focused, and verbose views, while `--progress-format` and `TMUXP_PROGRESS_FORMAT` allow a custom display. - Before, [`str`](str): +`--progress-lines` and `TMUXP_PROGRESS_LINES` control how much `before_script` output appears in the panel, and `--no-progress` or `TMUXP_PROGRESS=0` restores quiet output. - ```python - "echo hello" - ``` +## tmuxp 1.66.0 (2026-03-08) - After, [`dict`](dict): +tmuxp 1.66.0 cleans up diagnostics and user-facing output. Normal CLI use is quieter, build failures stop dumping raw tracebacks by default, and structured logging becomes the common diagnostic path. - ```python - { - "cmd": "echo hello" - } - ``` +### Fixes - This is purely internal. Normal usage should be the same since the - configuration emits the equivalent result. +#### Quieter CLI failures and machine-readable output fixes (#1017) -- {issue}`752`: Configuration parsing refactorings +The default CLI log level moved from INFO to WARNING so routine commands no longer emit internal chatter. Workspace build failures now show a concise user-facing error unless `--log-level debug` is requested, `get_pane()` preserves exception context without printing directly, and JSON output for `ls` and `debug-info` now flows through the shared output formatter. ### Development -- Run through black + isort with string normalization ({issue}`738`). This way we can - use black without any configuration. (One-time, big diff) -- Run codebase through pyupgrade ({issue}`745`) -- Add `codeql-analysis.yml` to try it out +#### Structured logging across tmuxp (#1017) + +Modules now use named loggers with structured `extra` fields such as `tmux_session`, `tmux_window`, `tmux_pane`, and `tmux_config_path`. Library imports install a `NullHandler`, and stable objects can carry persistent context through a logger adapter. + +The same cleanup removes the `colorama` dependency in favor of stdlib ANSI helpers and routes direct user-visible printing through tmuxp's output layer. + +## tmuxp 1.65.0 (2026-03-08) + +tmuxp 1.65.0 is a libtmux compatibility release. It raises the floor to pick up upstream lifecycle logging, error propagation fixes, pane title support, configurable tmux binary support, and safer pre-execution command logging. + +### Dependencies + +#### Minimum `libtmux~=0.55.0` (was `~=0.53.0`) (#1019) + +This pulls in libtmux 0.53.1, 0.54.0, and 0.55.0. The most visible downstream effects are the upstream `new_session()` race fix, structured lifecycle logging, `Pane.set_title()`, `Server(tmux_bin=)`, and DEBUG logging before tmux subprocess execution. + +## tmuxp 1.64.2 (2026-03-08) + +tmuxp 1.64.2 is a packaging-only correction for the previous patch release. + +### Fixes + +#### Published version metadata corrected + +The package version in `__about__.__version__` is updated to match the 1.64.1 release. + +## tmuxp 1.64.1 (2026-03-08) + +tmuxp 1.64.1 fixes a long-standing zsh display artifact during workspace load and adds linkable, richer CLI option documentation. + +### Fixes + +#### Pane readiness before layout and commands (#1018) + +Workspace loading now waits for each pane's shell to become ready before applying layout or sending commands. This fixes the inverse `%` marker that zsh could leave in panes after loading a workspace ([#365](https://github.com/tmux-python/tmuxp/issues/365)). ### Documentation -- Move to `furo` sphinx theme -- Reorganize documentation into sections -- Added examples for `enter: false` and `sleep: [second]` +#### Linkable and richer CLI arguments (#1010, #1011) -## tmuxp 1.9.4 (2022-01-10) +CLI docs now expose permanent anchors for command options and positional arguments. Argument metadata is rendered as readable key/value detail with required badges, default-value styling, and header links for sharing a specific option. -### Packaging +## tmuxp 1.64.0 (2026-01-24) + +tmuxp 1.64.0 replaces the first-generation argparse docs extension with a maintained package and fixes the warnings that surfaced during that migration. + +### Documentation + +#### CLI docs powered by `sphinx_argparse_neo` (#1009) + +The CLI reference moves to a packaged argparse documentation engine with syntax-highlighted usage blocks, transformed examples sections, ANSI stripping, safer RST escaping, and a page structure consistent with sibling projects. -- `poetry build` used to package in place of `python setup.py build` ({issue}`729`) +### Fixes - Package maintainers: If you run into any issues check in at {issue}`625` and file an issue. +#### Docutils and typing cleanup (#1009) - Additionally, `libtmux` has been pinned to a similar release at - [0.10.3](https://pypi.org/project/libtmux/0.10.3/) which has used the new - build process. +The docs extension no longer assigns directly to `node.children`, preserving docutils parent/child tracking, and a missing type annotation was added to satisfy mypy. -- `poetry publish` instead of `twine upload dist/*` ({issue}`729`) +## tmuxp 1.63.1 (2026-01-11) - Similar to the above, reach out to the {issue}`625` issue if you bump into problems. +tmuxp 1.63.1 is a small CLI help fix after the colorized command release. + +### Fixes + +#### Example sections colorize correctly (#1008) + +Example headings in `tmuxp --help` now use the command-specific heading format needed by the formatter, so example sections receive the same styling as other help output. + +## tmuxp 1.63.0 (2026-01-11) + +tmuxp 1.63.0 is the CLI usability release. It adds semantic color, a searchable workspace index, richer `ls` output, and JSON output for debugging and automation. ### What's new -- `tmuxp edit` for configuration changes ({issue}`707`, @GlebPoljakov) +#### Semantic color across CLI output (#1006) + +The root CLI gains `--color=auto|always|never` and respects `NO_COLOR` and `FORCE_COLOR`. Commands use semantic color roles for success, warnings, errors, paths, highlights, muted labels, interactive prompts, and help examples. + +#### Workspace search command (#1006) + +The new {ref}`search-config` command searches local and global workspace files with field prefixes such as `name:`, `session:`, `path:`, `window:`, and `pane:`. It supports smart-case, fixed-string, word, OR, and inverted matching, plus human, JSON, and NDJSON output. - Inside of configuration directory: `tmuxp edit yourconfig` +#### Richer workspace listing (#1006) - Inside a project: `tmuxp edit .` +The {ref}`ls-config` command can display grouped trees, complete parsed config content, local-versus-global source information, and machine-readable JSON/NDJSON output. Global workspace directories also show legacy and XDG locations with status. -### Removed support +#### JSON debug information (#1006) -- Python 3.6 support has been removed ({issue}`726`) +{ref}`tmuxp-debug-info` can now emit structured JSON for automation and issue-reporting workflows. + +### Documentation + +#### Better CLI documentation primitives (#1007) + +The docs gained a pretty-argparse extension for stripping ANSI escape codes, turning help examples into documentation sections, highlighting usage blocks, and ordering generated sections more naturally. ### Development -- We are trying `.pre-commit-config.yaml` in pull requests to automate - black, isort and flake8 for those who forget ({issue}`726`) -- Poetry update 1.1.7 -> 1.1.12 and use new installer URL ({issue}`726`) -- Black updated 21.9b0 -> 21.12b0 ({issue}`726`) +#### Development tasks move to Just (#1005) -## tmuxp 1.9.3 (2021-10-30) +The project development entrypoint moved from `Makefile` targets to `just` recipes, and the docs were updated around the new workflow. -- {issue}`700`: Add `-h` / `--help` option, thanks @GHPS -- {issue}`689`: Update poetry to 1.1 - - CI: Use poetry 1.1.7 and `install-poetry.py` installer - - Relock poetry.lock at 1.1 (w/ 1.1.7's fix) -- {issue}`696`: Typo fix, thanks @inkch +#### Docs deployment uses OIDC (#1000) -## tmuxp 1.9.2 (2021-06-17) +The documentation deployment path moved to AWS OIDC credentials and the AWS CLI, removing long-lived credentials from the normal deploy flow. -- {issue}`686`: Allow click 8.0.x -- Remove `manual/`, move to https://github.com/tmux-python/tmux-manuals +## tmuxp 1.62.0 (2025-12-14) -## tmuxp 1.9.1 (2021-06-16) +tmuxp 1.62.0 pairs a libtmux floor bump with a load-time error fix. -- libtmux: Update to 0.10.1+ to include `Window.select_window()` fix +### Dependencies - https://github.com/tmux-python/libtmux/pull/271 +#### Minimum `libtmux~=0.53.0` (was `~=0.52.1`) (#1001, #1003) -## tmuxp 1.9.0 (2021-06-16) +The libtmux bump keeps tmuxp aligned with the upstream session-management fixes required by the current test and runtime surface. -- libtmux: Update to 0.10.x +### Fixes -## tmuxp 1.8.2 (2021-06-15) +#### Session-killed tracebacks are surfaced correctly (#1002, #1003) -- {issue}`474` Re-release with `python setup.py sdist bdist_wheel` to - fix missing test files +When a session is killed during the attach path after `tmuxp load`, the failure now reports through the expected error path instead of hiding the traceback. -## tmuxp 1.8.1 (2021-06-14) +## tmuxp 1.61.0 (2025-12-07) -- {issue}`681` Bump version to make homebrew release easier +tmuxp 1.61.0 keeps the dependency stack on the newly published trusted-release line. -## tmuxp 1.8.0.post0 (2021-06-14) +### Dependencies -- {issue}`681` tmuxp is now available on homebrew! Thank you @jvcarli! +#### Minimum `libtmux~=0.52.1` (was `~=0.51.0`) (#1001) -## tmuxp 1.8.0 (2021-06-14) +The libtmux update picks up improved pane capture support and the upstream package's Trusted Publisher release. -- {issue}`662` CI: Only update docs when changed -- {issue}`666` CI: Move test plugin packages from pyproject.toml to - pytest fixtures. Fixes {issue}`658` -- {issue}`661` +#### `gp-libs` trusted-publishing refresh (#1001) - - Bump minimum version 3.5 -> 3.6 - - Drop python 2.7 support - - Modernize syntax (remove `__future__` and modesets) - - Update black to 21.6b0 +The gp-libs dependency moves to a package published through PyPI Trusted Publisher. -## tmuxp 1.7.2 (2021-02-03) +## tmuxp 1.60.1 (2025-12-07) -- {issue}`666` CI: Move test plugin packages from pyproject.toml to - pytest fixtures. Fixes {issue}`658` +tmuxp 1.60.1 is a release-infrastructure update. -## tmuxp 1.7.1 (2021-02-03) +### Development -- {issue}`665` Support for passing tmux config file (`-f`), thanks - @jfindlay! Fixes {issue}`654` +#### PyPI Trusted Publisher (#1000) -## tmuxp 1.6.5 (2021-02-03) +tmuxp publishing moved to PyPI Trusted Publisher, reducing the need for long-lived upload credentials. -- {issue}`665` Support for passing tmux config file (`-f`), thanks @jfindlay! Fixes - {issue}`654` +## tmuxp 1.60.0 (2025-12-06) -## tmuxp 1.7.0 (2021-01-09) +tmuxp 1.60.0 raises the libtmux floor to the release where old deprecation warnings became hard API errors. -This will be the last Python 2.7 release of tmuxp. Bug fixes for python -2.7 will live in the [1.7.x branch][1.7.x branch]. +### Dependencies -- {issue}`530` New feature: Plugin system +#### Minimum `libtmux~=0.51.0` (was `~=0.50.1`) (#999) - - Add plugin system for user customization of tmuxp - - Add tests for the plugin system - - Update existing tests for the plugin system - - Add the plugin interface to the tmuxp package - - Add in depth documentation for the plugin system +Users and plugins relying on deprecated libtmux names should migrate before this line, because libtmux 0.51.0 turns some formerly warned APIs into errors. - Thank you @joseph-flinn! +## tmuxp 1.59.1 (2025-12-06) -- {issue}`656` New feature: Ability to append windows to a session +tmuxp 1.59.1 is a docs-health and dependency patch. - `tmuxp load -a configfile` will append a configuration to your current - tmux session. +### Dependencies - Thank you @will-ockmore! +#### Minimum `libtmux~=0.50.1` (was `~=0.50.0`) (#998) -- {issue}`647` Improvement: Logging to file: +The libtmux patch keeps tmuxp on the latest 0.50.x maintenance line. - `tmuxp load --log-level outputfile.log` +### Documentation -- {issue}`643` New command: Debug information +#### Sphinx warnings fixed (#997) - Port `tmuxp debug-info` from via v1.6.2 +The docs build received typehint configuration, docstring typo fixes, missing `Returns` and `Raises` sections, RST parameter formatting fixes, and an orphan footnote cleanup. -[1.7.x branch]: https://github.com/tmux-python/tmuxp/tree/v1.7.x +## tmuxp 1.59.0 (2025-11-30) -## tmuxp 1.7.0a4 (2021-01-06) +tmuxp 1.59.0 adapts to libtmux's unified options API and its Python-native option values. -- Port click package fix from 1.6.4 +### Dependencies -## tmuxp 1.7.0a3 (2020-11-22) +#### Minimum `libtmux~=0.50.0` (was `~=0.49.0`) (#996) -- Port `tmuxp load --log-level outputfile.log` from - 1.6.3 +Internal tmuxp calls moved from the old libtmux option helpers to the newer unified option methods. Historical names such as `Session.attach_session()`, `Window.show_window_option()`, and `Window.set_window_option()` are intentionally left as inline code here because they describe the old migration surface. -## tmuxp 1.7.0a2 (2020-11-08) +## tmuxp 1.58.0 (2025-11-30) -- Port `tmuxp debug-info` from {issue}`643` from via v1.6.2 +tmuxp 1.58.0 completes the move away from old tmux versions. -## tmuxp 1.7.0a1 (2020-11-07) +### Breaking changes -- {issue}`530` Plugin system +#### tmux 3.2+ is now required (#992, #993) - - Add plugin system for user customization of tmuxp - - Add tests for the plugin system - - Update existing tests for the plugin system - - Add the plugin interface to the tmuxp package - - Add in depth documentation for the plugin system +tmux versions below 3.2a are disabled through the libtmux 0.49.0 update. - Thank you @joseph-flinn! +### Dependencies -## tmuxp 1.6.4 (2021-01-06) +#### Minimum `libtmux~=0.49.0` (was `~=0.48.0`) (#992) -- {issue}`651` Fix packaging issue with click, thanks @dougharris! Fixes - {issue}`649` +The libtmux update carries the tmux-version enforcement used by tmuxp. -## tmuxp 1.6.3 (2020-11-22) +## tmuxp 1.57.0 (2025-11-28) -- {issue}`647` Adding option to dump {}`load` output to log file, thank you - @joseph-flinn! +tmuxp 1.57.0 starts the tmux 3.2+ migration and expands test coverage to tmux 3.6. - `tmuxp load file.yaml --log-file yourfile.txt` +### Breaking changes - Adjust log levels: +#### tmux versions below 3.2a are deprecated (#991) - `tmuxp --log-level DEBUG load file.yaml --log-file yourfile.txt` +Older tmux versions now emit a `FutureWarning` on first use through libtmux. Set `LIBTMUX_SUPPRESS_VERSION_WARNING=1` only when you intentionally need to silence the warning during a transition. -## tmuxp 1.6.2 (2020-11-08) +### Dependencies -- {issue}`643` New command `tmuxp debug-info` for creating github - issues, fixes {issue}`352`. Thank you @joseph-flinn! +#### Minimum `libtmux~=0.48.0` (was `~=0.47.0`) (#990) -(v1-6-1)= +The libtmux floor carries the warning path for older tmux versions. -## tmuxp 1.6.1 (2020-11-07) +### Development -- {issue}`641` Improvements to `shell` +#### tmux 3.6 in the test grid (#989) - Thanks [django-extensions][django-extensions] (licensed MIT) for the shell detection abstraction. +CI now covers tmux 3.6. - - Deprecate `shell_plus` - - `tmuxp shell` now detects the best shell available by default - - Python 3.7+ with `PYTHONBREAKPOINT` set in env will drop into `pdb` by - default - - Drop into `code.interact` by default instead of `pdb` if no third - party shells found - - New options, override: +## tmuxp 1.56.0 (2025-11-01) - - `--pdb`: Use plain old `breakpoint()` (python 3.7+) or - `pdb.set_trace` - - `--code`: Drop into `code.interact`, accepts `--use-pythonrc` - - `--bpython`: Drop into [bpython][bpython] - - `--ipython`: Drop into [ipython][ipython] - - `--ptpython`: Drop into [ptpython][ptpython], accepts `--use-vi-mode` - - `--ptipython`: Drop into [ipython][ipython] + [ptpython][ptpython], accepts - `--use-vi-mode` +tmuxp 1.56.0 drops Python 3.9 and starts the Python 3.14 readiness line. -[django-extensions]: https://github.com/django-extensions/django-extensions +### Breaking changes -## tmuxp 1.6.0 (2020-11-06) +#### Python 3.10+ is now required (#987) -- {issue}`636` + {issue}`638` New command: `tmuxp shell` +tmuxp 1.55.0 was the last release supporting Python 3.9. Python 3.9 reached end of life on October 5, 2025. - Automatically preloads session, window, and pane via [libtmux][libtmux] - {ref}`API objects ` and makes them available in a python - console. +#### Minimum `libtmux~=0.47.0` (was `~=0.46.0`) - ```{image} _static/tmuxp-shell.gif - :width: 100% +The libtmux floor moves with the supported Python baseline. - ``` +### Development - As of {ref}`1.6.1 (above) `, `tmuxp shell` will find the most - feature-rich shell available. If you have [ipython][ipython], [ptpython][ptpython], or - [bpython][bpython] available, it will be selected automatically. Pass `--pdb` - to use standard library pdb, or `--code` to use `code.interact`. +#### Python 3.14 added to CI (#986) - In python 3.7+, supports `PYTHONBREAKPOINT`: +The test matrix now includes Python 3.14. - ```{code-block} sh +## tmuxp 1.55.0 (2025-02-26) - $ pip install --user ipdb - $ env PYTHONBREAKPOINT=ipdb.set_trace tmuxp shell +tmuxp 1.55.0 is a maintenance release for libtmux test-helper compatibility. - ``` +### Dependencies - You can execute python directly via `-c`: +#### Minimum `libtmux~=0.46.0` (was `~=0.45.0`) (#969) - ```{code-block} sh +The dependency bump keeps tmuxp current with upstream libtmux test helper changes. - $ tmuxp shell -c 'print(session.name); print(window.name)' - my_server - my_window +## tmuxp 1.54.0 (2025-02-23) - $ tmuxp shell my_server -c 'print(session.name); print(window.name)' - my_server - my_window +tmuxp 1.54.0 is a maintenance release around libtmux and runtime-dependency checks. - $ tmuxp shell my_server my_window -c 'print(session.name); print(window.name)' - my_server - my_window +### Dependencies - $ tmuxp shell my_server my_window -c 'print(window.name.upper())' - MY_WINDOW +#### Minimum `libtmux~=0.45.0` (was `~=0.44.2`) (#968) - ``` +The libtmux update keeps the project aligned with upstream test helper changes. -[bpython]: https://bpython-interpreter.org/ -[ipython]: https://ipython.org/ -[ptpython]: https://github.com/prompt-toolkit/ptpython +### Development -## tmuxp 1.5.8 (2020-10-31) +#### CLI runtime dependency checks (#967) -- {issue}`639` Passes start_directory through to new tmux session - Fixes {issue}`631`, thank you @joseph-flinn! +CI now verifies runtime dependencies for CLI modules, extending the earlier dependency-import checks. -## tmuxp 1.5.7 (2020-10-31) +## tmuxp 1.53.0 (2025-02-19) -- {issue}`637` Support for loading directories with periods in it +tmuxp 1.53.0 fixes a runtime typing import issue and makes the test suite easier to read. - `tmuxp load ~/work/your.project` will now work +### Fixes - Earlier workaround was to do `tmuxp load ~/work/your.project/.tmuxp.yaml` +#### Runtime import fix (#965) - Fixes {issue}`212` and {issue}`201` +Imports that were only available for typing no longer leak into runtime paths. -## tmuxp 1.5.6 (2020-10-12) +### Development -- {issue}`618`: allow passing `--overwrite` to `tmuxp freeze`. Thank you - @betoSolares! -- {issue}`589` added option for the the confirm command to auto-confirm the prompt. - Thank you @aRkedos! -- {issue}`626` Add new session name option to cli. Thank you @joseph-flinn! -- {issue}`626` Add test for new session name option -- {issue}`626` Update docs for new session name option -- {issue}`623` Move docs from RTD to self-serve site -- {issue}`623` Modernize Makefiles -- {issue}`623` New development docs -- {issue}`623` Move doc -> docs -- {issue}`623` Move tests to GitHub Actions -- {issue}`623` Update pyproject.toml to experiment with poetry packaging -- {issue}`619` isort 5 -- {issue}`629` Update black from 19.10b0 to 20.08b1 +#### Dependency and test fixture cleanup (#962, #964, #965) -## tmuxp 1.5.5 (2020-07-26) +The libtmux floor moves to 0.44.2, CI verifies runtime dependencies, and parametrized tests were converted to named fixture records so failures show clearer IDs. -- {issue}`616` (via: {issue}`599`) New command: `tmuxp ls` +## tmuxp 1.52.2 (2025-02-02) - List commands available via config directory. If the config is printed, - it's loadable via `tmuxp load configfilename` without needing to type the - full filepath. Thank you @pythops! +tmuxp 1.52.2 continues the `run_before_script()` output-capture fix line. -- {issue}`480` Fix typo, thanks @jstoja -- {issue}`578` Fix typo, thanks @mauroporras -- {issue}`519` Fix typo, thanks @timgates42 -- {issue}`506` Fix Makefile typo, thanks @wolfgangpfnuer -- {issue}`619` Update isort to 5.x -- Travis: Only run on master and PRs one time -- Travis: Add caching for tmux builds -- Travis: Test 2.9 and 3.0a -- {issue}`613`: Move from Pipenv to Poetry +### Fixes -## tmuxp 1.5.4 (2019-11-06) +#### `run_before_script()` captures output more reliably (#960) -- {issue}`500`: Fix window focus -- Fix travis CI builds for python 3.7 +Additional output-capture edge cases were fixed for pre-load scripts. -## tmuxp 1.5.3 (2019-06-06) +## tmuxp 1.52.1 (2025-02-02) -- {issue}`377`: Include examples in source distribution package +tmuxp 1.52.1 fixes the first output-capture issue in `run_before_script()`. -## tmuxp 1.5.2 (2019-06-02) +### Fixes -- Loosen libtmux version constraint to >0.8 and <0.9 (libtmux 0.8.2 - released today) -- {issue}`484` CHANGES converted to plain reStructuredText -- {issue}`491` `tmuxp freeze` will now infer active session with freezing -- {issue}`490` Fix XDG's `$XDG_CONFIG_HOME` behavior -- {issue}`483`, {issue}`482`, {issue}`480` Doc fixes -- {issue}`487` Simplifying handling of configs with no panes (Fixes - {issue}`470`) +#### `run_before_script()` output fix (#959) -## tmuxp 1.5.1 (2019-02-18) +The pre-load script runner now captures output through the corrected libtmux path. -- Add tests/\*.sh scripts to MANIFEST.in to include them in Pypi package. -- Include twine to dev packages on requirements and Pipfile files. +## tmuxp 1.52.0 (2025-02-02) -## tmuxp 1.5.0 (2018-10-02) +tmuxp 1.52.0 is a maintenance release for the libtmux 0.42.0 line. -- Support Click 7.0 -- Remove unused `__future__` imports -- {issue}`471` Update libtmux 0.8.0 -> 0.8.1 -- {issue}`404` from @anddam, support XDG base directory -- Sort imports -- Add configuration and make command for isort. -- Add sphinxcontrib-napoleon. -- Assure _requirements/dev.txt_ dependencies are in _Pipfile_ -- Update sphinx, releases to latest version -- Sync _requirements/_.txt* dependencies with *Pipfile\*. -- Update docstring style to use numpy-style documentation. - This enhances readability and plays nicely with sphinx documentation. -- Documentation overhaul. - - - Areas like {meth}`tmuxp.cli.load_workspace` are now documented verbosely. - This is so contributors helping on the project can more quickly gain - situational awareness in this tricky area of code. +### Dependencies -## tmuxp 1.4.2 (2018-09-30) +#### Minimum `libtmux~=0.42.0` (was `~=0.40.1`) (#958) -- {issue}`431` Include tests in source distribution +tmuxp drops its reliance on the older `console_to_str()` helper while staying on the current libtmux command-output path. -## tmuxp 1.4.1 (2018-09-26) +## tmuxp 1.51.0 (2025-02-02) -- Loosen click restraint to <7 +tmuxp 1.51.0 modernizes annotations and lint rules. -## tmuxp 1.4.0 (2018-03-11) +### Development -- Bump libtmux to 0.8.0 -- {issue}`264` Update license from BSD to MIT -- {issue}`348` Continuous integration updates and fixes for Travis CI +#### Deferred annotations and modern typing checks (#957) - - Update builds to use trusty - - Remove older python 3 versions (before 3.6) - - Update pypy versions +Source files now use `from __future__ import annotations`, and Ruff checks for PEP 585 and PEP 604 style annotations are enabled. -- {issue}`349` flake8 via continuous integration -- Improve reliability of time-sensitive tests by - using `while True` with a timeout. -- Update sphinx to 1.7.1 -- Update alagitpull (sphinx theme) to 0.0.19. - External websites open in new window. -- Update pytest to 3.4.1 +## tmuxp 1.50.1 (2024-12-24) -## tmuxp 1.3.5 (2017-11-10) +tmuxp 1.50.1 is a libtmux maintenance update. -- {issue}`312` Support for tmux 2.6 layout setting (via hooks) in the - following scenarios: +### Dependencies - - loading outside tmux - - loading inside tmux, via switch-client - - loading inside tmux, with session in background (with -d), and - reattaching/switching to after - - loading session outside tmux in background with -d, and - reattaching/switching after +#### Minimum `libtmux~=0.40.1` (was `~=0.40.0`) (#956) -- {issue}`308` Fix bug where layouts don't correctly set on tmux 2.6 -- Upgrade libtmux to 0.7.7 +The libtmux bump picks up the server environment variable fix from [libtmux#553](https://github.com/tmux-python/libtmux/pull/553). -## tmuxp 1.3.4 (2017-10-12) +## tmuxp 1.50.0 (2024-12-20) -- {}`before_script` now respects {}`start_directory` - in the session root. This makes it easier to run things like {}`pipenv install` as a {}`before_script`. +tmuxp 1.50.0 modernizes syntax for the Python 3.9 baseline. -## tmuxp 1.3.3 (2017-10-07) +### Dependencies -- Update libtmux to 0.7.5 for tmux 2.6 hotfix +#### Minimum `libtmux~=0.40.0` (was `~=0.39.0`) (#954) -## tmuxp 1.3.2 (2017-08-20) +The libtmux update adopts Python 3.9 syntax features. -- {issue}`184` - update libtmux to fix environmental variables in the - session scope -- Update libtmux to 0.7.4 -- Updates to pytest and pytest-rerunfailures +### Development -## tmuxp 1.3.1 (2017-05-29) +#### Ruff modernization pass (#953) -- {issue}`252` Fix bug where loading a session with a name matching a subset - of current session causes undesired behavior. -- Update libtmux to 0.7.3 -- Switch theme to alagitpull (alabaster subtheme) -- Remove unneeded doc dependency packages +The codebase was run through Ruff's automated fixes and formatter for the Python 3.9 baseline. -## tmuxp 1.3.0 (2017-04-27) +## tmuxp 1.49.0 (2024-11-26) -- {issue}`239` Improve support for formatted options when freezing and - using configs with them. -- {issue}`236` Support for symlinked directories, thanks @rafi. -- {issue}`235` Support for `options_after`, for setting options like - `synchronize-panes`. Thanks @sebastianst. -- {issue}`248` Drop python 2.6 support -- {issue}`248` Upgrade libtmux to 0.7.1 -- Upgrade colorama from 0.3.7 to 0.3.9 +tmuxp 1.49.0 drops Python 3.8 and moves the dependency stack to the Python 3.9 line. -## tmuxp 1.2.8 (2017-04-02) +### Breaking changes -- {issue}`229` More helpful error message on systems missing - tmux. -- Update libtmux from 0.6.4 to 0.6.5. +#### Python 3.9+ is now required (#951) -## tmuxp 1.2.7 (2017-03-25) +tmuxp 1.48.0 was the last release supporting Python 3.8. Python 3.8 reached end of life on October 7, 2024. -- Support for OpenBSD. +#### Minimum `libtmux~=0.39.0` (was `~=0.38.1`) -## tmuxp 1.2.6 (2017-02-24) +The libtmux floor moves with the Python baseline. -- {issue}`218` Fix pane ordering by running `select-layout` before - splits. +## tmuxp 1.48.0 (2024-11-26) -## tmuxp 1.2.5 (2017-02-08) +tmuxp 1.48.0 moves project management from Poetry to uv and the build backend from Poetry to Hatchling. -- {issue}`207` add custom tmuxp config directory via - `TMUXP_CONFIGDIR` variable. -- {issue}`199` support for running tmuxp on tmux `master`. -- update libtmux from 0.6.2 to 0.6.3. +### Breaking changes -## tmuxp 1.2.4 (2017-01-13) +#### Project management moved to uv (#949) -- {issue}`198` bump click from 6.6 to 6.7 -- {issue}`195` pin packages for colorama and doc requirements +[uv](https://github.com/astral-sh/uv) replaces Poetry for development workflows and dependency locking. -## tmuxp 1.2.3 (2016-12-21) +#### Build backend moved to Hatchling (#949) -- bump libtmux 0.6.0 to 0.6.1 -- {issue}`193` improve suppress history test, courtesy of @abeyer. -- {issue}`191` documentation typo from @modille -- {issue}`186` documentation typo from @joelwallis +[Hatchling](https://hatch.pypa.io/latest/) replaces Poetry's build backend for packaging. -## tmuxp 1.2.2 (2016-09-16) +#### Minimum `libtmux~=0.38.1` (was `~=0.37.0`) (#950) -- {issue}`181` Support tmux 2.3 +The libtmux dependency is rebuilt and locked through the new uv-based project workflow. -## tmuxp 1.2.1 (2016-09-16) +### Development -- {issue}`132` Handle cases with invalid session names -- update libtmux from 0.5.0 to 0.6.0 +#### Ruff 0.4.2 cleanup (#931) -## tmuxp 1.2.0 (2016-06-16) +Code quality updates include f-string cleanups from the Ruff 0.4.2 rule set. -- {issue}`65` Ability to specify `options` and `global_options` via - configuration. Also you can specify environment variables via that. +## tmuxp 1.47.0 (2024-04-21) - Include tests and add example. +tmuxp 1.47.0 is a libtmux maintenance release. -## tmuxp 1.1.1 (2016-06-02) +### Dependencies -- {issue}`167` fix attaching multiple sessions -- {issue}`165` fix typo in error output, thanks @fpietka -- {issue}`166` add new docs on zsh/bash completion -- Add back `tmuxp -V` for version info +#### Minimum `libtmux~=0.37.0` (was `~=0.36.0`) (#929) -## tmuxp 1.1.0 (2016-06-01) +The libtmux update brings upstream test-suite improvements, including pytest-xdist work and more relaxed `retry_until()` tests. -- {issue}`160` load tmuxp configs by name -- {issue}`134` Use `click` for command-line completion, Rewrite command - line functionality for importing, config finding, conversion and prompts. -- Remove `-l` from `tmuxp import tmuxinator|teamocil` -- {issue}`158` argparse bug overcome by switch to click +## tmuxp 1.46.0 (2024-04-12) -## tmuxp 1.0.2 (2016-05-25) +tmuxp 1.46.0 changes workspace building to respect the actual terminal size. -- {issue}`163` fix issue re-attaching sessions that are already loaded -- {issue}`159` improved support for tmuxinator imports, from @fpietka. -- {issue}`161` readme link fixes from @Omeryl. +### Breaking changes -## tmuxp 1.0.1 (2016-05-25) +#### Workspace builder detects terminal size (#926) -- switch to readthedocs.io for docs -- {issue}`157` bump libtmux to 0.4.1 +The workspace builder now uses `shutil.get_terminal_size()` for dimensions, which makes percentage-based layouts such as `main-pane-height: 67%` render proportionally. Set `TMUXP_DETECT_TERMINAL_SIZE=0` to use the old fixed-size behavior during migration. -## tmuxp 1.0.0-rc1 (2016-05-25) +### Documentation -- version jump 0.11.1 to 1.0 -- tests moved to py.test framework -- [libtmux][libtmux] core split into its own project -- {issue}`145` Add new-window command functionality, @ikirudennis -- {issue}`146` Optionally disable shell history suppression, @kmactavish -- {issue}`147` Patching unittest timing for shell history suppression -- move doc building, tests and watcher to Makefile -- update .tmuxp.yaml and .tmuxp.json for Makefile change -- overhaul README +#### Plain links are linkified -## tmuxp 0.11.0 (2016-02-29) +Documentation links that were previously plain text are now automatically linkified. -- {issue}`137` Support for environment settings in configs, thanks - {}`@tasdomas` -- Spelling correction, thanks [@sehe][@sehe]. +### Development -## tmuxp 0.10.0 (2016-01-30) +#### Ruff and Poetry maintenance (#928) -- {issue}`135` Load multiple tmux sessions at once, thanks [@madprog][@madprog]. -- {issue}`131` {issue}`133` README and Documentation fixes +The release includes another Ruff cleanup pass and a Poetry 1.8.2 update. -## tmuxp 0.9.3 (2016-01-06) +## tmuxp 1.45.0 (2024-03-24) -- switch to `.venv` for virtualenv directory to not conflict - with `.env` (used by [autoenv][autoenv]). -- {issue}`130` move to [entr(1)][entr(1)] for file watching in tests. update docs. -- [compatibility] Support [Anaconda Python][anaconda python] 2 and 3 +tmuxp 1.45.0 is a code-quality maintenance release. -## tmuxp 0.9.2 (2015-10-21) +### Development -- {issue}`122` Update to support tmux 2.1, thank you [@estin][@estin]. -- use travis container infrastructure for faster tests -- change test in workspace builder test to use `top(1)` instead of - `man(1)`. `man(1)` caused errors on some systems where `PAGER` is set. +#### Ruff 0.3.4 cleanup (#922) -## tmuxp 0.9.1 (2015-08-23) +The codebase was run through Ruff's automated fixes and formatter, including preview and unsafe fixes where they were accepted by maintainers. -- {issue}`119` Add fix python 3 for [sysutils/pytmuxp][sysutils/pytmuxp] on FreeBSD ports. - See GH issue 119 and [#201564][#201564] @ FreeBSD Bugzilla. Thanks Ruslan - Makhmatkhanov. +## tmuxp 1.44.0 (2024-03-24) -## tmuxp 0.9.0 (2015-07-08) +tmuxp 1.44.0 tracks libtmux 0.36.0. -- Renamed `config.expandpath` to `config.expandshell`. -- compat 2.7/3.3 wrapper for `EnvironmentVarGuard` for - testing. -- You can now use environment variables inside of - `start_directory`, `before_script`, `shell_command_before`, - `session_name` and `window_name`. -- [examples]: add example for environmental variables, - `examples/env-variables.json` and `examples/env-variables.yaml`. -- {issue}`110` de-vendorize [colorama][colorama]. Thanks [@marbu][@marbu]. -- {issue}`109` fix failure of test_pane_order on fedora machines from - [@marbu][@marbu] -- {issue}`105` append `.txt` extension to manuals (repo ony) - from [@yegortimoshenko][@yegortimoshenko]. -- {issue}`107` Fix Server.attached_sessions return type by - [@thomasballinger][@thomasballinger]. -- update travis to use new tmux git repository. +### Dependencies -## tmuxp 0.8.1 (2015-05-09) +#### Minimum `libtmux~=0.36.0` (was `~=0.35.1`) (#923) -- [testing]: fix sniffer test runner in python 3 -- new animated image demo for RTD and README +The libtmux bump carries internal refactoring and maintenance. -## tmuxp 0.8.0 (2015-05-07) +## tmuxp 1.43.1 (2024-03-24) -- version bump 0.1.13 -> 0.8.0 -- tmux 2.0 support -- Fix documentation for :meth:`Session.switch_client()`. -- Add `--log-level` argument. -- Refactor `{Server,Session,Window,Pane}.tmux` into: +tmuxp 1.43.1 picks up libtmux multi-client fixes. - - {meth}`Server.cmd()` - - {meth}`Session.cmd()` - - {meth}`Window.cmd()` - - {meth}`Pane.cmd()` +### Dependencies - (See conversation at ) +#### Minimum `libtmux~=0.35.1` (was `~=0.35.0`) -- Refactor `util.tmux` into {meth}`util.tmux_cmd`. +The update improves behavior when multiple clients are attached to a session in one server. -## tmuxp 0.1.13 (2015-03-25) +## tmuxp 1.43.0 (2024-03-17) -- Remove `package_metadata.py` in favor of `__about__.py`. -- `scent.py` for building docs -- docutils from 0.11 to 0.12 -- `bootstrap_env.py` will check for linux, darwin (OS X) and - windows and install the correct [sniffer][sniffer] file watcher plugin. -- testsuite for cli uses {py:func}`tempfile.mkdtemp()` instead - `TMP_DIR` (which resolved to `.tmuxp` in the testsuite directory. -- replace [watchingtestrunner][watchingtestrunner] with [sniffer][sniffer] in examples. - `.tmuxp.conf` and `.tmux.json` updated -- updates to doc links -- `make checkbuild` for verifying internal / intersphinx doc - references. -- Add Warning tmux versions less than 1.4 from [@techtonik][@techtonik]. -- Add documentation on leading space in `send_keys` - from [@thomasballinger][@thomasballinger]. -- Update about page from teamocil and erb support from [@raine][@raine]. +tmuxp 1.43.0 follows libtmux's target-handling cleanup. -## tmuxp 0.1.12 (2014-08-06) +### Dependencies -- [config] {meth}`config.expand` now resolves directories in configuration - via {py:func}`os.path.expanduser` and {py:func}`os.path.expandvars`. -- [config] {meth}`config.expandpath` for helping resolve paths. -- improved support for loading tmuxp project files from - outside current working directory. e.g. +#### Minimum `libtmux~=0.35.0` (was `~=0.34.0`) (#920) - ``` - $ tmuxp load /path/to/my/project/.tmuxp.yaml - ``` +tmuxp adapts to libtmux changes that simplify redundant target passing and window-index usage. - Will behave better with relative directories. +## tmuxp 1.42.0 (2024-03-17) -## tmuxp 0.1.11 (2014-04-06) +tmuxp 1.42.0 follows libtmux's explicit-target command behavior. -- `before_script` now loads relative to project directory with - `./`. -- Use `bootstrap_env.py` in tmuxp's `.tmuxp.yaml` and - `.tmuxp.json` project files. -- Improvements to {meth}`util.run_before_script()`, - {class}`exc.BeforeLoadScriptFailed` behavior to print `stdout` and - return `stderr` is a non-zero exit is returned. -- `run_script_before` has moved to `util`. -- `BeforeLoadScriptFailed` and `BeforeLoadScriptNotExists` - has moved to the `exc` module. -- Tests for `run_script_before` refactored. +### Dependencies -## tmuxp 0.1.10 (2014-04-02) +#### Minimum `libtmux~=0.34.0` (was `~=0.33.0`) (#919) -- 2 bug fixes and allow panes with no shell commands to accept options, - thanks for these 3 patches, [@ThiefMaster][@thiefmaster]: -- {issue}`73` Fix an error caused by spaces in - `start_directory`. -- {issue}`77` Fix bug where having a `-` in a - `shell_command` would cauesd a build error. -- {issue}`76` Don't require `shell_command` to - pass options to panes (like `focus: true`). +The libtmux update makes explicit command targets the expected path. -## tmuxp 0.1.9 (2014-04-01) +## tmuxp 1.41.1 (2024-03-17) -- The `--force` was not with us. +tmuxp 1.41.1 is a small compatibility patch after the split-window API change. -## tmuxp 0.1.8 (2014-03-30) +### Development -- {issue}`72` Create destination directory if it doesn't exist. Thanks - [@ThiefMaster][@thiefmaster]. -- New context manager for tests, `temp_session`. -- New testsuite, `testsuite.test_utils` for testing testsuite - tools. -- New command, `before_script`, which is a file to - be executed with a return code. It can be a bash, perl, python etc. - script. -- {issue}`56` {ref}`python_api_quickstart ` +#### Workspace builder uses `Pane.split` -## tmuxp 0.1.7 (2014-02-25) +The builder now uses the newer `Pane.split` path instead of the historical `Window.split_window` helper. -- {issue}`55` where tmuxp would crash with letter numbers in version. - Write tests. +## tmuxp 1.41.0 (2024-03-17) -## tmuxp 0.1.6 (2014-02-08) +tmuxp 1.41.0 tracks libtmux's split API rename and refreshes development tooling. -- {meth}`Window.split_window()` now allows `-c start_directory`. -- {issue}`35` Builder will now use `-c start_directory` to - create new windows and panes. +### Dependencies - This removes a hack where `default-path` would be set for new pane and - window creations. This would bleed into tmux user sessions after - creations. +#### Minimum `libtmux~=0.33.0` (was `~=0.32.0`) (#918) -## tmuxp 0.1.5-1 (2014-02-05) +libtmux moved the split-window behavior from `split_window()` to `split()`. The old method name is kept inline here as migration history rather than linked as current API. -- {issue}`49` bug where `package_manifest.py` missing - from `MANIFEST.in` would cause error installing. +### Development -## tmuxp 0.1.5 (2014-02-05) +#### Poetry 1.8.1 -- section heading normalization. -- tao of tmux section now treated as a chatper. tao of tmux may be - split off into its own project. -- use conventions from [tony/cookiecutter-pypackage][tony/cookiecutter-pypackage]. +Development tooling moved to Poetry 1.8.1. -## tmuxp 0.1.4 (2014-02-02) +## tmuxp 1.40.0 (2024-03-32) -- Fix `$ tmuxp freeze` CLI output. -- Update `_compat` support module. -- Fix extra space in [PEP 263][pep 263]. +tmuxp 1.40.0 is a maintenance release for libtmux export fixes and Ruff 0.3. -## tmuxp 0.1.3 (2014-01-29) +### Dependencies -- {issue}`48` Fix Python 3 CLI issue. -- {issue}`48` `$ tmuxp` without option raises an error. -- Add space before send-keys to not populate bash and zsh - history. +#### Minimum `libtmux~=0.32.0` (was `~=0.31.0.post0`) (#914) -## tmuxp 0.1.2 (2014-01-08) +The libtmux update includes an export fix and matching lint updates. -- now using werkzeug / flask style testsuites. -- {issue}`43` Merge `tmuxp -d` for loading in detached mode. - Thanks [roxit][roxit]. +### Development -## tmuxp 0.1.1 (2013-12-25) +#### Ruff 0.3 command shape (#913) -- {issue}`32` Fix bug where special characters caused unicode caused - unexpected outcomes loading and freezing sessions. +CI now uses `ruff check .`, matching the Ruff 0.3 command interface. -## tmuxp 0.1.0 (2013-12-18) +## tmuxp 1.39.0 (2024-02-17) -- fix duplicate print out of filename with using `tmuxp load .`. -- version to 0.1. No `--pre` needed. Future versions will not use rc. +tmuxp 1.39.0 tracks libtmux command-streamlining changes. -## tmuxp 0.1-rc8 (2013-12-17) +### Dependencies -- `unicode_literals` -- Move py2/py3 compliancy code to `_compat`. +#### Minimum `libtmux~=0.31.0` (was `~=0.30.2`) (#912) -## tmuxp 0.1-rc7 (2013-12-07) +Internal tmuxp code follows libtmux renames around command helpers and active window/pane accessors. Historical names such as `attached_windows` and `attached_panes` are left as migration notes. -- {issue}`33` Partial rewrite of {meth}`config.expand`. -- tmuxp will exit silently with `Ctrl-c`. +## tmuxp 1.38.0 (2024-02-16) -## tmuxp 0.1-rc6 (2013-12-06) +tmuxp 1.38.0 follows the libtmux 0.30 API-name cleanup. -- {issue}`31` [examples] from stratoukos add `window_index` option, - and example. +### Dependencies -## tmuxp 0.1-rc5 (2013-12-04) +#### Minimum `libtmux~=0.30.1` (was `~=0.28.1`) (#911) -- {issue}`28` shell_command_before in session scope of config causing - duplication. New test. -- {issue}`26` {issue}`29` for OS X tests. Thanks stratoukos. -- {issue}`27` `$ tmuxp freeze` raises unhelpful message if session doesn't - exist. +tmuxp internal method usage was updated to match the libtmux 0.30 naming style. -## tmuxp 0.1-rc4 (2013-12-03) +## tmuxp 1.37.1 (2024-02-15) -- fix bug were `focus: true` would not launch sessions when using - `$ tmuxp load` in a tmux session. +tmuxp 1.37.1 is a maintenance release for libtmux docs/CI fixes and CI action updates. -## tmuxp 0.1-rc3 (2013-12-03) +### Dependencies -- {issue}`25` `focus: true` not working in panes. Add - tests for focusing panes in config. -- {meth}`Pane.select_pane()`. -- add new example for `focus: true`. +#### Minimum `libtmux~=0.28.1` (was `~=0.28.0`) -## tmuxp 0.1-rc2 (2013-11-23) +The libtmux update is a maintenance release focused on docs and CI. -- {issue}`23` fix bug where workspace would not build with pane-base-index - set to 1. Update tests to fix if `pane-base-index` is not 0. -- removed `$ tmuxp load --list` functionality. Update - {ref}`quickstart` accordingly. +### Development -## tmuxp 0.1-rc1 (2013-11-23) +#### GitHub Actions updated to Node 20 -- [pep8][pep8] and [pep257][pep257] in unit tests. -- Changelog will now be updated on a version basis, use [pep440][pep440] - versioning. +CI actions were bumped to Node 20-compatible versions. -## tmuxp 0.1-dev (2013-11-21) +## tmuxp 1.37.0 (2024-02-14) -- {meth}`Session.show_options`, {meth}`Session.show_option` now - accept `g` to pass in `-g`. +tmuxp 1.37.0 tracks libtmux refresh and resize improvements. -## tmuxp 0.1-dev (2013-11-20) +### Dependencies -- {meth}`Window.show_window_options`, - {meth}`Window.show_window_option` now accept `g` to pass in `-g`. -- {issue}`15` Behavioral changes in the WorkspaceBuilder to fix pane - ordering. -- {issue}`21` Error with unit testing python 2.6 python configuration tests. - Use {py:mod}`tempfile` instead. -- WorkspaceBuilder tests have been improved to use async better. +#### Minimum `libtmux~=0.28.0` (was `~=0.27.0`) (#910) -## tmuxp 0.1-dev (2013-11-17) +The libtmux update brings refresh and resize improvements used by tmuxp. -- fix a bug where missing tmux didn't show correct warning. +### Development -## tmuxp 0.1-dev (2013-11-15) +#### tmux 3.4 in CI (#909) -- Travis now tests python 2.6 as requirement and not allowed to - fail. +The CI matrix now includes tmux 3.4. -## tmuxp 0.1-dev (2013-11-13) +## tmuxp 1.36.0 (2024-02-07) -- {issue}`19` accept `-y` argument to answer yes to questions. -- {meth}`cli.SessionCompleter` no longer allows a duplicate session - after one is added. -- ongoing work on {ref}`about-tmux`. +tmuxp 1.36.0 is a libtmux typing maintenance release. -## tmuxp 0.1-dev (2013-11-09) +### Dependencies -- [translation] [documentation in Chinese][documentation in chinese] from [wrongwaycn][wrongwaycn]. -- More work done on the {ref}`about-tmux` page. -- {meth}`Pane.split_window()` for splitting {class}`Window` at - `target-pane` location. +#### Minimum `libtmux~=0.27.0` (was `~=0.26.0`) (#908) -## tmuxp 0.1-dev (2013-11-08) +The libtmux update improves `QueryList` generic typing. -- [freeze] - `$ tmuxp freeze` will now freeze a window with a - `start_directory` when all panes in a window are inside the same - directory. -- [config] {meth}`config.inline` will now turn panes with no - other attributes and 1 command into a single item value. +## tmuxp 1.35.0 (2024-02-07) - ``` - - panes: - - shell_command: top +tmuxp 1.35.0 tightens linting and follows libtmux docstring maintenance. - # will now inline to: +### Dependencies - - panes - - top +#### Minimum `libtmux~=0.26.0` (was `~=0.25.0`) (#906) - This will improve ``$ tmuxp freeze`` - ``` +The libtmux update carries docstring and linting maintenance. -## tmuxp 0.1-dev (2013-11-07) +### Development -- Remove old logger (based on [tornado's log.py][tornado's log.py]), replace - with new, simpler one. -- fix [teamocil][teamocil] import. -- support import teamocil `root` to - `start_directory`. +#### Stricter Ruff rule families (#907) -## tmuxp 0.1-dev (2013-11-06) +Linting now includes additional rule families for commas, builtins, and exception-message style. -- tagged v0.0.37. Many fixes. Python 2.6 support. Will - switch to per-version changelog after 0.1 release. -- support for blank panes (null, `pane`, `blank`) and panes - with empty strings. -- tmuxp freeze supports exporting to blank panes. -- tmuxp freeze will now return a blank pane for panes that would - previously return a duplicate shell command, or generic python, node - interpreter. +## tmuxp 1.34.0 (2023-12-21) -## tmuxp 0.1-dev (2013-11-05) +tmuxp 1.34.0 is an API documentation and internal package-boundary cleanup release. -- Support for `[-L socket-name]` and `[-S socket-path]` in - autocompletion and when loading. Note, switching client into another - socket may cause an error. -- Documentation tweaking to {ref}`API`, {ref}`about-tmux`. -- [pep257][pep257], [pep8][pep8]. +### Breaking changes -## tmuxp 0.1-dev (2013-11-04) +#### Internal modules moved under `tmuxp._internal` (#897, #900) -- [pep257][pep257], [pep8][pep8]. -- tagged version `v0.0.36`. +`config_reader` and `_types` moved into the internal namespace. These were implementation details, and the changelog leaves the old names unlinked because they are not stable public entrypoints. -## tmuxp 0.1-dev (2013-11-02) +### Dependencies -- Many documentation, [pep257][pep257], [pep8][pep8] fixes -- move old {class}`Server` methods `__list_panes()`, - `__list_windows` and `__list_sessions` into the single underscore. -- {issue}`12` fix for `$ tmuxp freeze` by @finder. -- Support for spaces in `$ tmuxp attach-session` and - `$ tmuxp kill-session`, and `$ tmuxp freeze`. -- [config] support for relative paths of `start_directory`. Add an - update config in _Start Directory_ on {ref}`examples`. +#### Minimum `libtmux~=0.25.0` (was `~=0.24.1`) (#896) -## tmuxp 0.1-dev (2013-11-01) +The libtmux update improves docstring style through pydocstyle work. -- New servers for {class}`Server` arguments `socket_name`, - `socket_path`, `config_file`. -- {class}`Server` support for `-2` with `colors=256` and - `colors=8`. -- `$ tmuxp -2` for forcing 256 colors and `tmuxp -8` for forcing 88. -- [config] Concatenation with `start_directory` via - {meth}`config.trickle()` if window `start_directory` is alphanumeric / - relative (doesn't start with `/`). See {ref}`Examples` in _start directory_. -- Fix bug with import teamocil and tmuxinator -- Improve quality of tmuxinator imports. Especially `session_name` - and `start_directory`. -- Allow saving with `~` in file destination. +### Documentation -## tmuxp 0.1-dev (2013-10-31) +#### API docs split across pages (#898) -- {meth}`util.is_version()` -- correctly {meth}`config.trickle()` the `start_directory`. -- get `start_directory` working for configs -- fix :meth:`Window.kill_window()` target to - `session_id:window_index` for compatibility and pass tests. -- [examples]: Example for `start_directory`. -- fix bug where first and second window would load in mixed order -- {class}`Window.move_window()` for moving window. -- doc overhaul. front page, renamed orm_al.rst to internals.rst. +API documentation was broken out into multiple pages and stale reStructuredText section headings were removed. -## tmuxp 0.1-dev (2013-10-30) +## tmuxp 1.33.0 (2023-12-21) -- fix bug where if inside tmux, loading a workspace via switch_client - wouldn't work. -- fix bug where `tmuxp load .` would return an error instead of a - notice. -- `tmuxp freeze ` experimental -- tmuxp now has experimental support for freezing live - sessions. -- {meth}`Window.kill_window()` -- support for `start_directory` (work in progress) +tmuxp 1.33.0 makes documentation and CI stricter. -## tmuxp 0.1-dev (2013-10-29) +### Documentation -- {meth}`Window.select_pane` now accepts `-l`, `-U`, `-D`, - `-L`, `-R`. -- support for `automatic-rename` option. -- 3 new {ref}`examples`, 'main-pane-height', 'automatic-rename', and - 'shorthands'. -- enhancements to prompts -- `tmuxp import` for teamocil and tmuxinator now has a wizard and offers - to save in JSON or YAML format. -- [b6c2e84] Fix bug where tmuxp load w/ session already loaded would - switch/attach even if no was entered -- when workspace loader crashes, give option to kill session, attach it or - detach it. -- tmux 1.8 `set-option` / `set-window-options` command - `target-window` fix. -- {class}`WorkspaceBuilder` now has `.session` attribute accessible - publicly. -- tmux will now use {meth}`Session.switch_client` and - {meth}`Session.attach_session` to open new sessions instead of `os.exec`. -- [config] tmuxp now allows a new shorter form for panes. Panes can just be a - string. See the shorthand form in the {ref}`examples` section. -- [config] support loading `.yml`. +#### More complete docstrings (#891) -## tmuxp 0.1-dev (2013-10-28) +Functions, methods, classes, and packages received docstrings, and pydocstyle enforcement was added through Ruff. -- fix `tmuxp load .` fixed -- fix `tmuxp convert ` fixed. -- [pep257][pep257] fixes. -- {class}`Pane` now has {meth}`Pane.set_width` and - {meth}`Pane.set_height`. -- `./run_tests.py --tests` now automatically prepends - `tmuxp.testsuite` to names. -- {meth}`Window.tmux` and {meth}`Pane.tmux` will automatically add - their `{window/pane}_id` if one isn't specific. +### Development -## tmuxp 0.1-dev (2013-10-27) +#### CodeQL defaults -- [argcomplete][argcomplete] overhaul for CLI bash completion. -- `tmuxp load`, `tmuxp convert` and `tmuxp import` now support - relative and full filenames in addition to searching the config - directory. +CodeQL moved from an advanced configuration file to GitHub's default setup. -## tmuxp 0.1-dev (2013-10-26) +## tmuxp 1.32.1 (2023-11-23) -- initial version of [tmuxinator][tmuxinator] and [teamocil][teamocil] - config importer. it does not support all options and it not guaranteed - to fully convert window/pane size and state. -- {meth}`config.in_dir` supports a list of `extensions` for - filetypes to search, i.e. `['.yaml', '.json']`. -- {meth}`config.is_config_file` now supports `extensions` - argument as a string also. -- fix `$ tmuxp load -l` to work correctly alongside - `$ tmuxp load filename`. +tmuxp 1.32.1 is a test dependency and fixture-maintenance patch. -## tmuxp 0.1-dev (2013-10-25) +### Dependencies -- fix bug where `-v` and `--version` wouldn't print version. -- property handle case where no tmux server exists when - `attach-session` or `kill-session` is used. -- test fixtures and inital work for importing - [tmuxinator][tmuxinator] and [teamocil][teamocil] configs +#### libtmux 0.24.1 and `gp-lib` test dependency -## tmuxp 0.1-dev (2013-10-24) +The test dependency group gained `gp-lib`, and libtmux moved to the 0.24.1 maintenance release. -- clean out old code for `automatic-rename` option. it will - be reimplemented fresh. -- check for `oh-my-zsh` when using `$SHELL` `zsh`. Prompt if - `DISABLE_AUTO_TITLE` is unset or set to `true`. -- tmuxp can now `$ tmuxp convert ` from JSON <=> YAML, back - and forth. -- New examples in JSON. Update the {ref}`examples` page in the - docs. -- [dev] `.tmuxp.json` now exists as a config for tmuxp development and - as an example. -- Fix bug where `tmuxp kill-session` would give bad output -- Fix bug in tab completion for listing sessions with no tmux server - is active. +### Development -## tmuxp 0.1-dev (2013-10-23) +#### Named shell test fixtures (#893) -- zsh/bash/tcsh completion improvements for tab-completion options -- tmuxp `kill-session` with tab-completion. -- tmuxp `attach-session` with tab-completion. Attach session will - `switch-client` for you if you are inside of of a tmux client. -- tmuxp `load` for loading configs. -- unit test fixes. +Shell tests now use named, typed fixtures for clearer failures. -## tmuxp 0.1-dev (2013-10-21) +## tmuxp 1.32.0 (2023-11-19) -- Make 1.8 the official minimym version, give warning notice to - upgrade tmux if out of date -- Fix regression causing unexpected build behavior due to unremoved code - supporting old tmux versions. -- Added 2 new examples to the {ref}`examples` page. -- Examples now have graphics -- `$ tmuxp -v` will print the version info. +tmuxp 1.32.0 consolidates test configuration and moves formatting from Black to Ruff. -## tmuxp 0.1-dev (2013-10-19) +### Packaging -- tmuxp will now give warning and sys.exit() with a message if - `tmux` not found in system PATH -- internal overhaul of {class}`Server`, {class}`Session` - , {class}`Window`, and {class}`Pane` continues. - - - {class}`Server` has @property {meth}`Server.sessions`, which is forward - to {meth}`Server.list_sessions()` (kept to keep tmux commands in - serendipty with api), {meth}`Server._list_sessions()` returns dict - object from {meth}`Server.__list_sessions()` tmux command. - {meth}`Server.__list_sessions()` exists to keep the command layered so - it can be tested against in a matrix with travis and compatibility - methods can be made. - - {class}`Session` now has @proprety {meth}`Session.windows` returning a - list of {class}`Window` objects via {meth}`Session.list_windows()`. - @property {meth}`Session._windows` to {meth}`Session._list_windows()` - to return a list of dicts without making objects. - - {class}`Window` now has @proprety {meth}`Window.panes` returning a - list of {class}`Pane` objects via {meth}`Window.list_panes()`. - @property {meth}`Window._panes` to {meth}`Window._list_panes()` - to return a list of dicts without making objects. +#### Pytest and Poetry metadata cleanup (#886) -## tmuxp 0.1-dev (2013-10-18) +Pytest configuration moved into `pyproject.toml`, Python 3.12 classifiers were added, and development dependency groups were corrected to match Poetry's dependency-group model. -- internal overhaul of {class}`Server`, {class}`Session`, - {class}`Window`, and {class}`Pane`. +### Development - - `Session`, `Window` and `Pane` now refer to a data object - in {class}`Server` internally and always pull the freshest data. - - A lot of code and complexity regarding setting new data for objects - has been reduced since objects use their unique key identifier to - filter their objects through the windows and panes in `Server` - object. - - `Server` object is what does the updating now. +#### Ruff formatter replaces Black (#890) -- [project] some research into supporting legacy tmux versions. tmux 1.6 - and 1.7 support seem likely eventually if there is enough demand. -- python 3 support +Formatting now uses [Ruff format](https://docs.astral.sh/ruff/formatter/), eliminating a separate Black dependency while keeping the same style. -## tmuxp 0.1-dev (2013-10-17) +#### libtmux 0.24.0 and CI action updates -- updated README docs with new project details, screenshots -- new example `.tmuxp.yaml` file updated to include - development workflow. Removed nodemon as the tool for checking files for - now. -- Support for switching sessions from within tmux. In both cases - after the the session is built and if session already exists. +The dependency stack moved to libtmux 0.24.0, Poetry 1.7.0, and newer GitHub Actions packages. -## tmuxp 0.1-dev (2013-10-16) +## tmuxp 1.31.0 (2023-09-23) -- use {meth}`util.which()` from salt.util to find tmux binary. -- add MANIFEST.in, fix issue where package would not - install because missing file -- bash / zsh completion. -- New page on {ref}`internals`. -- Updates to {ref}`about-tmux` page. -- add vim modeline for rst to bottom of this page -- Server is now a subclass of `util.TmuxObject`. -- subclasses of {class}`util.TmuxRelationalObject`, - {class}`Server`, {class}`Session`, {class}`Window`, {class}`Pane` now - have {meth}`util.TmuxRelationalObject.getById` (similar to [.get()][.get()] in - [backbone.js][backbone.js] collection), {meth}`util.TmuxRelationalObject.where` and - {meth}`util.TmuxRelationalObject.findWhere` ([.where()][.where()] and - [.findWhere()][.findwhere()] in [underscore.js][underscore.js]), to easily find child objects. -- tmux object mapping has been split into - {class}`util.TmuxMappingObject`. The mapping and the relational has been - decoupled to allow {class}`Server` to have children while not being a - dict-like object. -- {class}`Server`, {class}`Session`, {class}`Window`, - {class}`Pane` now explicitly mixin subclasses. +tmuxp 1.31.0 drops Python 3.7 and reaches strict mypy compliance. -## tmuxp 0.1-dev (2013-10-15) +### Breaking changes -- new theme -- initial examples, misc. updates, front page update. -- support for `$ tmux .` to load `.tmuxp.{yaml/json/py}` in current - working directory. -- support for `socket-name` (`-L`) and `socket-path` - (`socket-path`) -- [config] Support for 1-command pane items. - - ``` - session_name: my session - windows: - - window_name: hi - panes: - - bash - - htop - ``` - -- If session name is already exists, prompt to attach. +#### Python 3.8+ is now required (#885) -## tmuxp 0.1-dev (2013-10-14) +Python 3.7 support ends on this line. -- can now -l to list configs in current directory and $HOME/.tmuxp -- tmuxp can now launch configs and build sessions -- new exceptions -- {meth}`config.check_consistency()` to verify and diagnose - issues with config files. -- {meth}`cli.startup()` -- {meth}`config.is_config_file()` -- {meth}`config.in_dir()` -- {meth}`config.in_cwd()` +### Development -## tmuxp 0.1-dev (2013-10-13) +#### Strict typing (#859) -- {meth}`config.inline()` to produce far far better looking - config exports and tests. -- {meth}`Pane.resize_pane()` and tests -- documentation fixes and updates -- {meth}`Session.refresh()`, {meth}`Window.refresh()`, - {meth}`Pane.refresh()`. -- {meth}`Server.find()`, {meth}`Session.find()`, - {meth}`Window.find()`. +The project is now `mypy --strict` compliant, and Poetry moved to 1.6.1. -## tmuxp 0.1-dev (2013-10-12) +## tmuxp 1.30.1 (2023-09-09) -- Test documentation updates -- Builder is now {class}`WorkspaceBuilder` + tests. - - WorkspaceBuilder can build panes - - WorkspaceBuilder can build windows and set options -- {meth}`Window.show_window_options()`, - {meth}`Window.show_window_option()`, {meth}`Window.set_window_option()` -- {meth}`Session.show_options()`, - {meth}`Session.show_option()`, {meth}`Session.set_option()` +tmuxp 1.30.1 is the final Python 3.7 release line. -## tmuxp 0.1-dev (2013-10-11) +### Breaking changes -- More preparation for builder / session maker utility. -- test runner and test suite overhaul. -- Documentation for development environment and test runner updated. -- Travis now tests against tmux 1.8 and latest source. Door open for - future testing against python 3 and earlier tmux versions in the future. -- Quiet logger down in some areas -- **future** imports for future python 3 compatibility -- setup.py import **version** via regex from tmuxp package -- move beginnings of cli to `tmuxp.cli` +#### Python 3.7 maintenance branch -## tmuxp 0.1-dev (2013-10-09) +Security updates, if needed, can target the 1.30.x branch after Python 3.7 end of life. -- New logging module -- Removed dependency logutils -- Removed dependency sh +## tmuxp 1.30.0 (2023-09-04) -## tmuxp 0.1-dev (2013-10-08) +tmuxp 1.30.0 is a code-quality release built around Ruff. + +### Development + +#### Faster linting and import sorting with Ruff (#879) + +Ruff now handles more code-quality rules and runs quickly over the full codebase. CI also checks Black formatting. + +### Dependencies + +#### libtmux 0.23.2 + +The libtmux update is the final Python 3.7-compatible libtmux line. + +### Documentation + +#### Nix README example (#883) + +The README gained a Nix example from @ChristopherHarwell. + +## tmuxp 1.29.1 (2023-09-02) + +tmuxp 1.29.1 is a typo-fix release. + +### Fixes + +#### Documentation and dependency typo fixes (#884) + +tmuxp docs received typo fixes from @kianmeng, and libtmux moved to a typo-fix patch release. + +## tmuxp 1.29.0 (2023-08-20) + +tmuxp 1.29.0 tracks libtmux code-quality work. + +### Dependencies + +#### libtmux 0.23.0 (#882) + +The libtmux update brings upstream code-quality improvements from [libtmux#488](https://github.com/tmux-python/libtmux/pull/488). + +### Fixes + +#### Post-release libtmux comments restored + +The `v1.29.0post0` follow-up re-added comments that were accidentally dropped during formatter work. + +## tmuxp 1.28.2 (2023-08-20) + +tmuxp 1.28.2 is a packaging-maintenance patch. + +### Dependencies + +#### libtmux 0.22.2 + +The libtmux update removes `setuptools` from build-system requirements. + +## tmuxp 1.28.1 (2023-05-28) + +tmuxp 1.28.1 restores Black as a development dependency while Ruff formatting matured. + +### Development + +#### Black restored temporarily + +Black returned alongside Ruff until Ruff could fully replace it. + +## tmuxp 1.28.0 (2023-05-27) + +tmuxp 1.28.0 starts the move from the older Black/isort/flake8 stack to Ruff. + +### Development + +#### Ruff for linting, sorting, and formatting + +Ruff replaces Black, isort, flake8, and flake8 plugins for much faster whole-repo checks. The release also updates libtmux to 0.22.0 and Poetry to 1.5.0. + +## tmuxp 1.27.1 (2022-04-07) + +tmuxp 1.27.1 is a typing-maintenance patch. + +### Development + +#### mypy 1.2 and libtmux 0.21.1 + +The release updates mypy and picks up libtmux typing-only changes. + +## tmuxp 1.27.0 (2022-01-29) + +tmuxp 1.27.0 updates libtmux's tmux-format separator behavior. + +### Dependencies + +#### libtmux 0.21.0 (#865) + +The libtmux bump uses a rarer separator for tmux format output, reducing the chance of parsing collisions. See [libtmux#475](https://github.com/tmux-python/libtmux/pull/475). + +## tmuxp 1.26.0 (2023-01-15) + +tmuxp 1.26.0 improves new-session parameter support through libtmux. + +### Dependencies + +#### libtmux 0.20.0 (#863) + +The libtmux bump improves `Server.new_session` support for tmux size flags such as `-x` and `-y`. + +## tmuxp 1.25.0 (2023-01-07) + +tmuxp 1.25.0 is a libtmux patch-line update. + +### Dependencies + +#### libtmux 0.19.1 (#862) + +The update includes a fix for the historical `Window.set_window_option()` path. + +## tmuxp 1.24.1 (2023-01-07) + +tmuxp 1.24.1 improves test reliability and tracks a libtmux patch release. + +### Dependencies + +#### libtmux 0.18.3 (#861) + +The libtmux update follows [libtmux#466](https://github.com/tmux-python/libtmux/pull/466). + +### Development + +#### More reliable pane path test + +`test_pane_order` became less timing-sensitive around `pane_current_path`. + +## tmuxp 1.24.0 (2022-12-30) + +tmuxp 1.24.0 fixes session creation through libtmux and tightens test organization. + +### Dependencies + +#### libtmux 0.18.2 + +The libtmux update fixes starting new sessions at the default socket and temporary directory ([libtmux#464](https://github.com/tmux-python/libtmux/pull/464)). + +### Development + +#### CLI test and builder constructor cleanup (#857, #858) + +CLI tests were reorganized, directory resolution was fixed, and `WorkspaceBuilder` now requires an explicit `Server` in its constructor. + +## tmuxp 1.23.0 (_yanked_, 2022-12-28) + +tmuxp 1.23.0 was yanked because of `tmuxp load` issues tracked in #856. + +### Development + +#### More type annotations (#796) + +The yanked release carried additional mypy typing work. + +## tmuxp 1.22.1 (2022-12-27) + +tmuxp 1.22.1 is a documentation-only libtmux patch. + +### Dependencies + +#### libtmux 0.18.1 + +The libtmux update contains code documentation fixes. + +## tmuxp 1.22.0 (2022-12-27) + +tmuxp 1.22.0 improves {ref}`tmuxp-shell` server detection. + +### What's new + +#### `tmuxp shell` detects the current server from `TMUX` (#854) + +The shell command can now infer the active tmux server from the `TMUX` environment variable. + +## tmuxp 1.21.0 (2022-12-27) + +tmuxp 1.21.0 is a libtmux maintenance release. + +### Dependencies + +#### libtmux 0.18.0 + +The libtmux update improves `Server.__repr__`. + +## tmuxp 1.20.3 (2022-12-27) + +tmuxp 1.20.3 removes builder warning noise. + +### Fixes + +#### `_update_panes()` warnings fixed + +Builder warnings around `_update_panes()` were corrected. + +## tmuxp 1.20.2 (2022-12-27) + +tmuxp 1.20.2 is an internal libtmux deprecation-warning update. + +### Dependencies + +#### libtmux 0.17.2 + +The update carries more upstream deprecation warning coverage. + +## tmuxp 1.20.1 (2022-12-27) + +tmuxp 1.20.1 is a small libtmux and tooling maintenance release. + +### Dependencies + +#### libtmux 0.17.1 + +The update carries deprecation-warning and documentation fixes. + +### Development + +#### Poetry 1.3.1 + +Development tooling moved to Poetry 1.3.1. + +## tmuxp 1.20.0 (2022-12-26) + +tmuxp 1.20.0 adopts the libtmux 0.17 API overhaul. + +### Breaking changes + +#### libtmux 0.17 API update (#850) + +The dependency bump includes the upstream API overhaul from [libtmux#426](https://github.com/tmux-python/libtmux/pull/426). Historical method names are intentionally not linked here because this entry describes a migration point. + +### Development + +#### Automatic-rename test stabilization (#853) + +The automatic rename test received a reliability fix. + +## tmuxp 1.19.1 (2022-12-12) + +tmuxp 1.19.1 removes an indirect packaging dependency. + +### Fixes + +#### libtmux 0.16.1 removes `packaging` + +The libtmux patch removes the underlying dependency on `packaging`. + +## tmuxp 1.19.0 (2022-12-10) + +tmuxp 1.19.0 adds scoped environment variables for sessions, windows, and panes. + +### What's new + +#### Environment variables for windows and panes (#845) + +Workspace configuration can now define environment variables at the session, window, and pane levels for tmux 3.0+. See {ref}`environmental-variables` for the documented environment surface and {ref}`configuration` for workspace structure. + +### Dependencies + +#### libtmux 0.16.0 and distutils warning fixes (#727) + +The libtmux update supports the environment-variable work and removes reliance on `distutils.version.LooseVersion`. + +## tmuxp 1.18.2 (2022-11-06) + +tmuxp 1.18.2 is a maintenance release with no user-facing features or fixes. + +### Development + +#### libtmux and Poetry defaults + +libtmux moved to 0.15.10 for test tweaks, and Poetry no longer forces `in-project: true`. + +## tmuxp 1.18.1 (2022-10-31) + +tmuxp 1.18.1 fixes tmux config pass-through. + +### Fixes + +#### `tmuxp load` passes through config files (#843) + +The load command correctly forwards tmux config file arguments again. + +## tmuxp 1.18.0 (2022-10-30) + +tmuxp 1.18.0 renames the project vocabulary from "config" toward "workspace" and splits the old large modules into focused workspace modules. + +### Development + +#### Workspace package split (#840) + +Finder, freezer, importer, validation, and builder behavior moved into the `tmuxp.workspace` package. Tests moved with those boundaries, making workspace loading, freezing, importing, and validation easier to maintain separately. + +## tmuxp 1.17.3 (2022-10-30) + +tmuxp 1.17.3 adds Python 3.11 metadata and CI coverage. + +### Development + +#### Python 3.11 in the test grid (#842) + +CI, pyenv/asdf files, and package classifiers now include Python 3.11. + +## tmuxp 1.17.2 (2022-10-29) + +tmuxp 1.17.2 fixes multi-workspace loading. + +### Fixes + +#### Multiple workspace loads (#838) + +`tmuxp load` can load multiple workspace arguments correctly again, fixing #837. + +## tmuxp 1.17.1 (2022-10-15) + +tmuxp 1.17.1 improves shell completions after the argparse migration. + +### Fixes + +#### Better file completions for `tmuxp load` (#834) + +shtab completions now handle file completion for `tmuxp load` more usefully, and leftover Click completion code was removed. + +## tmuxp 1.17.0 (2022-10-09) + +tmuxp 1.17.0 replaces Click with argparse and switches completions to shtab. + +### Breaking changes + +#### Completion setup changed (#830) + +Completions are now generated with [shtab](https://docs.iterative.ai/shtab/). Users with older tmuxp completions may need to remove those before installing the new completions. See {ref}`cli-completions`. + +#### Click dependency removed (#830) + +The CLI moved from Click to argparse, so Click is no longer a runtime dependency. + +## tmuxp 1.16.2 (2022-10-08) + +tmuxp 1.16.2 fixes package metadata for YAML support. + +### Packaging + +#### YAML runtime dependency declared (#833) + +`yaml` is now included in required dependencies. Thanks @heindsight. + +## tmuxp 1.16.1 (2022-10-02) + +tmuxp 1.16.1 improves blank window-name behavior through libtmux. + +### Fixes + +#### Blank `window_name` support + +The libtmux 0.15.8 update improves handling for configurations such as `window_name: ''`. + +## tmuxp 1.16.0 (2022-10-01) + +tmuxp 1.16.0 replaces the kaptan configuration dependency with tmuxp's own typed reader. + +### Development + +#### `ConfigReader` replaces kaptan (#828) + +The new reader handles raw strings and files with a smaller typed, doctested implementation. + +### Packaging + +#### kaptan removed (#828) + +The kaptan dependency was dropped. + +## tmuxp 1.15.3 (2022-10-01) + +tmuxp 1.15.3 fixes `start_directory` behavior in the workspace builder. + +### Fixes + +#### `start_directory` builder fix (#829) + +The workspace builder now handles the reported `start_directory` case correctly. Thanks @heindsight. + +## tmuxp 1.15.2 (2022-09-24) + +tmuxp 1.15.2 fixes packaging around test configuration. + +### Packaging + +#### Root `conftest.py` kept out of wheels (#826) + +The test `conftest.py` moved to the repository root so it is not accidentally packaged in wheels. + +## tmuxp 1.15.1 (2022-09-23) + +tmuxp 1.15.1 is an infrastructure release for CI, packaging, and dependency cleanup. + +### Development + +#### Faster CI and cleaner coverage configuration (#819, #824) + +CI no longer pulls the PyPI upload image for ordinary runs, CodeQL was cleaned up, Poetry moved to 1.2, coverage configuration moved into `pyproject.toml`, and libtmux advanced through its 0.15.x pytest-plugin improvements. + +### Packaging + +#### Poetry-managed package contents + +`MANIFEST.in` was removed in favor of Poetry metadata, and the project tmuxp config no longer references `.tmuxp-before-script.sh`. + +## tmuxp 1.15.0 (2022-09-11) + +tmuxp 1.15.0 moves the project into `src/` layout. + +### Development + +#### Source layout migration (#814) + +Package code now lives under `src/`, aligning tmuxp with the surrounding Python packaging convention. + +## tmuxp 1.14.0 (2022-09-11) + +tmuxp 1.14.0 starts a maintenance series for infrastructure and API upgrades. + +### Development + +#### libtmux 0.15.1 and doctested docs + +The libtmux bump brings a major upstream retooling, including `src/` layout and doctest coverage for documentation. + +### Documentation + +#### Changelog, autodoc, and doctest helpers (#812) + +The docs gained issue-linking, a Sphinx autodoc TOC rendering fix, and documentation doctests through the gp-libs doctest helpers. + +## tmuxp 1.13.3 (2022-09-10) + +tmuxp 1.13.3 rolls back the 1.13.1 layout change while the loading behavior is studied further. + +### Fixes + +#### Layout change reverted (#811) + +The #793 layout fix was reverted so future releases can address pane layout behavior more deliberately. + +## tmuxp 1.13.2 (2022-09-10) + +tmuxp 1.13.2 adjusts layout sizing for users hitting pane spacing issues. + +### Fixes + +#### Larger default layout size (#809) + +The layout size was bumped as a mitigation for spacing issues tracked in #800. + +### Development + +#### Additional lint plugins (#807, #808) + +flake8-bugbear and flake8-comprehensions were added to the linting stack. + +## tmuxp 1.13.1 (2022-08-21) + +tmuxp 1.13.1 attempted to fix several layout-related issues. + +### Fixes + +#### Layout issue fixes (#793) + +Layout issues from #667, #704, and #737 were addressed with help from @nvasilas. + +## tmuxp 1.13.0 (2022-08-14) + +tmuxp 1.13.0 adds early typing and doctest infrastructure. + +### Development + +#### mypy, doctest, and libtmux updates (#786, #790, #791) + +The release adds basic mypy annotations, doctest support through pytest, and a libtmux update from 0.12 to 0.14. + +## tmuxp 1.12.1 (2022-08-04) + +tmuxp 1.12.1 fixes first-pane `start_directory` handling. + +### Fixes + +#### First pane `start_directory` fix (#787) + +The first pane now respects `start_directory` correctly, fixing #724. Thanks @nvasilas. + +## tmuxp 1.12.0 (2022-07-31) + +tmuxp 1.12.0 is mostly test cleanup. + +### Development + +#### Test reliability work (#774, #777, #781, #783) + +The suite was adjusted so tests finish reliably, old retry helpers moved to `retry_until()`, symlink edge cases for pane order were covered, and zsh startup files were mocked for cleaner pane output. + +## tmuxp 1.11.1 (2022-05-02) + +tmuxp 1.11.1 tightens the Click compatibility floor. + +### Fixes + +#### Click 8+ required (#775) + +The 1.11 line now requires Click 8 or newer. tmuxp 1.10 remains the Click 7-compatible line. + +## tmuxp 1.11.0 (2022-04-24) + +tmuxp 1.11.0 updates Click compatibility, shell completion behavior, and project maintenance tooling. + +### Fixes + +#### Click 8.1 and shell completion fixes (#770, #773) + +The CLI allows Click 8.1.x and fixes completions for `tmuxp load` and `tmuxp freeze`. + +### Development + +#### CLI module split and publishing cleanup (#761, #762) + +The large CLI module was split into per-command modules, tests/constants were refactored, tox was removed, package publishing moved to GitHub Actions tags, and `-V`/`--version` shows the libtmux version. + +## tmuxp 1.10.1 (2022-04-17) + +tmuxp 1.10.1 backports Click 8.1 compatibility. + +### Fixes + +#### Click 8.1 allowed (#773) + +The compatibility fix from the 1.11 line was backported to the final Python 3.7/3.8 branch. + +## tmuxp 1.10.0 (2022-03-19) + +tmuxp 1.10.0 is the final Python 3.7 and 3.8 release and a major command-execution ergonomics release. + +### Breaking changes + +#### Final Python 3.7 and 3.8 release + +Bug fixes and security updates for those Python versions moved to the [`v1.10.x`](https://github.com/tmux-python/tmuxp/tree/v1.10.x) branch. + +### What's new + +#### Command execution controls: `enter`, `sleep_before`, and `sleep_after` (#747, #750) + +Pane commands can now be sent without pressing Enter via `enter: false`, and execution can pause before or after individual commands. See {ref}`enter` and {ref}`sleep` for examples. + +#### Non-interactive freeze improvements (#701) + +{ref}`cli-freeze` gained `--quiet`, `--yes`, `--config-format`, and `--save-to`, making one-command save flows and tmux key bindings practical. + +#### Pane `shell` command support (#672) + +Pane configuration can specify a `shell` for the initial command, matching tmux's `split-window [shell-command]` behavior. Thanks @jerri. + +### Fixes + +#### `.yml` conversion support (#725) + +{ref}`cli-convert` now loads `.yml` files correctly. Thanks @kalixi. + +### Development + +#### Command parsing refactor (#752) + +Internally, command entries moved from bare strings to dictionaries so per-command options can be represented without changing normal user configuration. + +#### Tooling and docs cleanup (#738, #745) + +The codebase was formatted with Black and isort, run through pyupgrade, and the docs moved to Furo with expanded command-execution examples. + +## tmuxp 1.9.4 (2022-01-10) + +tmuxp 1.9.4 moves packaging to Poetry and adds the edit command. + +### What's new + +#### `tmuxp edit` (#707) + +The new {ref}`cli-edit` command opens workspace files from a project or config directory for editing. + +### Breaking changes + +#### Python 3.6 support removed (#726) + +Python 3.6 support ends on this release line. + +### Packaging + +#### Poetry build and publish (#729) + +Packages are built with `poetry build` and published with `poetry publish`; libtmux is pinned to a matching Poetry-built release. + +### Development + +#### Pre-commit trial and formatter updates (#726) + +The project began trying pre-commit in pull requests, updated Poetry, and moved Black to 21.12b0. + +## tmuxp 1.9.3 (2021-10-30) + +tmuxp 1.9.3 is a small CLI and tooling maintenance release. + +### Fixes + +#### Help flag and typo fixes (#696, #700) + +The CLI gained `-h`/`--help`, and documentation typos were fixed. + +### Development + +#### Poetry 1.1 update (#689) + +CI and lock files moved to Poetry 1.1.7. + +## tmuxp 1.9.2 (2021-06-17) + +tmuxp 1.9.2 allows Click 8.0.x and moves tmux manuals out of the repo. + +### Fixes + +#### Click 8.0 compatibility (#686) + +The dependency range now allows Click 8.0.x. + +### Documentation + +#### tmux manuals split out + +The old `manual/` tree moved to [tmux-python/tmux-manuals](https://github.com/tmux-python/tmux-manuals). + +## tmuxp 1.9.1 (2021-06-16) + +tmuxp 1.9.1 picks up a libtmux window-selection fix. + +### Dependencies + +#### libtmux 0.10.1+ + +The libtmux bump includes the `Window.select_window()` fix from [libtmux#271](https://github.com/tmux-python/libtmux/pull/271). + +## tmuxp 1.9.0 (2021-06-16) + +tmuxp 1.9.0 moves to libtmux 0.10.x. + +### Dependencies + +#### libtmux 0.10.x + +tmuxp tracks the current libtmux 0.10 release line. + +## tmuxp 1.8.2 (2021-06-15) + +tmuxp 1.8.2 republishes the package with missing test files restored. + +### Packaging + +#### Source distribution test files (#474) + +The release was rebuilt with `python setup.py sdist bdist_wheel` to include the missing test files. + +## tmuxp 1.8.1 (2021-06-14) + +tmuxp 1.8.1 is a Homebrew packaging helper release. + +### Packaging + +#### Version bump for Homebrew (#681) + +The version was bumped to make the Homebrew release flow easier. + +## tmuxp 1.8.0.post0 (2021-06-14) + +tmuxp 1.8.0.post0 announces the Homebrew package. + +### Packaging + +#### Homebrew availability (#681) + +tmuxp became available on Homebrew. Thanks @jvcarli. + +## tmuxp 1.8.0 (2021-06-14) + +tmuxp 1.8.0 drops Python 2.7, raises the Python floor, and cleans up plugin-test packaging. + +### Breaking changes + +#### Python 3.6+ is now required (#661) + +Python 2.7 support was removed, syntax was modernized, and Black moved to 21.6b0. + +### Development + +#### Plugin test package handling (#666) + +Test plugin packages moved from `pyproject.toml` metadata into pytest fixtures, fixing #658. + +#### Docs deploy only when changed (#662) + +CI avoids unnecessary docs updates when docs are untouched. + +## tmuxp 1.7.2 (2021-02-03) + +tmuxp 1.7.2 backports plugin test package handling. + +### Development + +#### Plugin test packages through pytest fixtures (#666) + +The 1.8 test package fix was also applied on this line. + +## tmuxp 1.7.1 (2021-02-03) + +tmuxp 1.7.1 adds support for a tmux config file flag. + +### What's new + +#### tmux config file pass-through (#665) + +tmuxp can pass `-f` to tmux for a specific configuration file. Thanks @jfindlay. + +## tmuxp 1.6.5 (2021-02-03) + +tmuxp 1.6.5 backports tmux config file support to the 1.6 line. + +### What's new + +#### tmux config file pass-through (#665) + +The `-f` config-file support from 1.7.1 was applied to the 1.6 branch. + +## tmuxp 1.7.0 (2021-01-09) + +tmuxp 1.7.0 is the last Python 2.7 release and the first stable release with plugins, append-loading, file logging, and debug information. + +### Breaking changes + +#### Last Python 2.7 release + +Bug fixes for Python 2.7 moved to the [`1.7.x`](https://github.com/tmux-python/tmuxp/tree/v1.7.x) branch. + +### What's new + +#### Plugin system (#530) + +The release adds the plugin system, tests, public plugin interface, and documentation. See {ref}`plugins`. + +#### Append windows to the current session (#656) + +`tmuxp load -a configfile` can append a workspace to the current tmux session. Thanks @will-ockmore. + +#### Load logging and debug info (#643, #647) + +`tmuxp load` can write output to a log file, and {ref}`tmuxp-debug-info` is available for collecting issue-reporting context. Thanks @joseph-flinn. + +## tmuxp 1.7.0a4 (2021-01-06) + +tmuxp 1.7.0a4 carries a Click packaging fix onto the alpha line. + +### Fixes + +#### Click package fix + +The 1.6.4 Click packaging fix was ported. + +## tmuxp 1.7.0a3 (2020-11-22) + +tmuxp 1.7.0a3 ports load logging to the alpha line. + +### What's new + +#### Load output log file + +The `tmuxp load --log-file` feature from 1.6.3 was ported. + +## tmuxp 1.7.0a2 (2020-11-08) + +tmuxp 1.7.0a2 ports debug information support to the alpha line. + +### What's new + +#### `tmuxp debug-info` + +The debug-info command from 1.6.2 was ported. + +## tmuxp 1.7.0a1 (2020-11-07) + +tmuxp 1.7.0a1 previews the plugin system. + +### What's new + +#### Plugin system (#530) + +The alpha adds plugin hooks, tests, interface code, and documentation. Thanks @joseph-flinn. + +## tmuxp 1.6.4 (2021-01-06) + +tmuxp 1.6.4 fixes a Click packaging issue. + +### Fixes + +#### Click packaging fix (#651) + +The release fixes the Click package issue tracked in #649. Thanks @dougharris. + +## tmuxp 1.6.3 (2020-11-22) + +tmuxp 1.6.3 adds log-file output to `tmuxp load`. + +### What's new + +#### Load output to a file (#647) + +`tmuxp load file.yaml --log-file yourfile.txt` writes load output to a file, and the root `--log-level` flag controls verbosity. + +## tmuxp 1.6.2 (2020-11-08) + +tmuxp 1.6.2 adds a debug-info command for issue reporting. + +### What's new + +#### `tmuxp debug-info` (#643) + +The new command collects system information for GitHub issues, fixing #352. Thanks @joseph-flinn. + +(v1-6-1)= + +## tmuxp 1.6.1 (2020-11-07) + +tmuxp 1.6.1 improves the interactive Python shell command. + +### What's new + +#### Smarter `tmuxp shell` selection (#641) + +`tmuxp shell` deprecates `shell_plus`, chooses the best available shell by default, honors `PYTHONBREAKPOINT` on Python 3.7+, and exposes explicit `--pdb`, `--code`, `--bpython`, `--ipython`, `--ptpython`, and `--ptipython` choices. + +## tmuxp 1.6.0 (2020-11-06) + +tmuxp 1.6.0 introduces {ref}`tmuxp-shell`, a Python console preloaded with the current tmux session, window, and pane. + +### What's new + +#### Python shell with tmux context (#636, #638) + +`tmuxp shell` opens an interactive Python environment with libtmux objects already available. It can also execute expressions directly with `-c`, making quick session/window/pane inspection possible from scripts. + +## tmuxp 1.5.8 (2020-10-31) + +tmuxp 1.5.8 fixes session `start_directory` propagation. + +### Fixes + +#### `start_directory` passed to new sessions (#639) + +New tmux sessions receive the configured `start_directory`, fixing #631. Thanks @joseph-flinn. + +## tmuxp 1.5.7 (2020-10-31) + +tmuxp 1.5.7 fixes project path loading for directories with periods. + +### Fixes + +#### Directory paths with periods load correctly (#637) + +`tmuxp load ~/work/your.project` now works without requiring the explicit `.tmuxp.yaml` path, fixing #212 and #201. + +## tmuxp 1.5.6 (2020-10-12) + +tmuxp 1.5.6 is a mixed freeze, prompt, session-name, docs, CI, and packaging update. + +### What's new + +#### Freeze overwrite and session-name options (#618, #626) + +`tmuxp freeze` can accept `--overwrite`, and the CLI gained a new session-name option with tests and docs. + +#### Auto-confirm prompt option (#589) + +The confirm command can auto-confirm prompts. Thanks @aRkedos. + +### Development + +#### Docs, CI, and packaging modernization (#619, #623, #629) + +Docs moved to the self-hosted site, tests moved to GitHub Actions, Makefiles were modernized, Poetry packaging experiments began, isort moved to 5.x, and Black moved to 20.08b1. + +## tmuxp 1.5.5 (2020-07-26) + +tmuxp 1.5.5 adds the `ls` command and includes a broad maintenance pass. + +### What's new + +#### `tmuxp ls` (#616) + +The new `tmuxp ls` command lists workspaces available from the config directory so they can be loaded by name. Thanks @pythops. + +### Fixes + +#### Documentation typos and CI cleanup (#480, #506, #519, #578) + +The release includes community typo fixes, Travis cleanup, cached tmux builds, tmux 2.9/3.0a testing, and the move from Pipenv to Poetry. + +## tmuxp 1.5.4 (2019-11-06) + +tmuxp 1.5.4 fixes window focus and Python 3.7 CI. + +### Fixes + +#### Window focus and Travis CI (#500) + +Window focus handling was corrected, and Travis builds were fixed for Python 3.7. + +## tmuxp 1.5.3 (2019-06-06) + +tmuxp 1.5.3 fixes the source distribution contents. + +### Packaging + +#### Examples included in sdist (#377) + +Example files are included in the source distribution. + +## tmuxp 1.5.2 (2019-06-02) + +tmuxp 1.5.2 fixes packaging constraints, XDG config handling, and freeze defaults. + +### Fixes + +#### Freeze and config-path fixes (#483, #487, #490, #491) + +`tmuxp freeze` can infer the active session, XDG `$XDG_CONFIG_HOME` handling was fixed, empty-pane configs were simplified, docs were corrected, and libtmux constraints were loosened for the 0.8.2 release. + +### Documentation + +#### CHANGES converted to plain reStructuredText (#484) + +The changelog format moved to plain reStructuredText at that point in the project history. + +## tmuxp 1.5.1 (2019-02-18) + +tmuxp 1.5.1 fixes package contents and development dependencies. + +### Packaging + +#### Test scripts included in source distributions + +Shell test scripts are included in package manifests, and Twine was added to development dependency files. + +## tmuxp 1.5.0 (2018-10-02) + +tmuxp 1.5.0 is a broad maintenance and documentation release around Click 7, XDG support, import sorting, and API docs. + +### What's new + +#### XDG base directory support (#404) + +tmuxp gained support for the XDG base directory convention. + +#### Click 7 compatibility + +The CLI supports Click 7.0. + +### Development + +#### Documentation and style modernization (#471) + +The release updates libtmux, removes stale `__future__` imports, adds isort configuration, adopts NumPy-style docstrings, updates Sphinx tooling, and expands documentation for contributors working near the load workspace path. + +## tmuxp 1.4.2 (2018-09-30) + +tmuxp 1.4.2 fixes source distribution contents. + +### Packaging + +#### Tests included in source distributions (#431) + +Test files are included in the source distribution. + +## tmuxp 1.4.1 (2018-09-26) + +tmuxp 1.4.1 loosens the Click dependency constraint. + +### Dependencies + +#### Click constraint loosened + +The Click version constraint was relaxed to `<7`. + +## tmuxp 1.4.0 (2018-03-11) + +tmuxp 1.4.0 updates licensing, CI, docs, and dependency versions. + +### Breaking changes + +#### License changed to MIT (#264) + +The project license moved from BSD to MIT. + +### Development + +#### CI and dependency refresh (#348, #349) + +Travis moved to Trusty, older Python 3 versions were removed from CI, PyPy versions were updated, flake8 entered CI, time-sensitive tests became more reliable, and Sphinx, theme, and pytest versions were bumped. + +### Dependencies + +#### libtmux 0.8.0 + +tmuxp tracks libtmux 0.8.0. + +## tmuxp 1.3.5 (2017-11-10) + +tmuxp 1.3.5 improves tmux 2.6 layout handling. + +### Fixes + +#### tmux 2.6 layout support (#308, #312) + +Layouts now set correctly across outside-tmux loads, switch-client loads, detached sessions, and background loads followed by reattach or switch. libtmux moved to 0.7.7. + +## tmuxp 1.3.4 (2017-10-12) + +tmuxp 1.3.4 fixes `before_script` working directories. + +### Fixes + +#### `before_script` respects session `start_directory` + +Pre-load scripts now run relative to the session root, which makes project setup commands such as `pipenv install` work as expected. + +## tmuxp 1.3.3 (2017-10-07) + +tmuxp 1.3.3 is a tmux 2.6 hotfix release. + +### Dependencies + +#### libtmux 0.7.5 + +The libtmux update carries a tmux 2.6 hotfix. + +## tmuxp 1.3.2 (2017-08-20) + +tmuxp 1.3.2 fixes session-scope environment variables through libtmux. + +### Fixes + +#### Session-scope environment variables (#184) + +The libtmux 0.7.4 update fixes environment variables in the session scope and refreshes pytest dependencies. + +## tmuxp 1.3.1 (2017-05-29) + +tmuxp 1.3.1 fixes ambiguous session-name matching and refreshes docs dependencies. + +### Fixes + +#### Exact session-name matching (#252) + +Loading a session whose name is a subset of another session no longer causes the wrong attach/switch behavior. + +### Dependencies + +#### libtmux 0.7.3 and docs theme update + +tmuxp updated libtmux, switched docs to the alagitpull theme, and removed unneeded docs dependencies. + +## tmuxp 1.3.0 (2017-04-27) + +tmuxp 1.3.0 adds better formatted-option support, symlinked directory support, and `options_after`. + +### What's new + +#### Formatted options, symlinked directories, and `options_after` (#235, #236, #239) + +Freezing and loading handle formatted options more accurately, symlinked directories are supported, and `options_after` can set late tmux options such as `synchronize-panes`. + +### Breaking changes + +#### Python 2.7+ baseline (#248) + +Python 2.6 support was dropped, libtmux moved to 0.7.1, and colorama was updated. + +## tmuxp 1.2.8 (2017-04-02) + +tmuxp 1.2.8 improves missing-tmux errors. + +### Fixes + +#### Helpful missing-tmux message (#229) + +Systems without `tmux` on PATH receive a clearer error message. libtmux moved from 0.6.4 to 0.6.5. + +## tmuxp 1.2.7 (2017-03-25) + +tmuxp 1.2.7 adds OpenBSD support. + +### What's new + +#### OpenBSD support + +The release adds platform support for OpenBSD. + +## tmuxp 1.2.6 (2017-02-24) + +tmuxp 1.2.6 fixes pane ordering. + +### Fixes + +#### Layout selected before splits (#218) + +Pane order is preserved by running `select-layout` before creating splits. + +## tmuxp 1.2.5 (2017-02-08) + +tmuxp 1.2.5 adds custom config-directory support and tracks tmux master. + +### What's new + +#### `TMUXP_CONFIGDIR` (#207) + +Users can choose a custom tmuxp config directory with `TMUXP_CONFIGDIR`. See {ref}`TMUXP_CONFIGDIR`. + +### Development + +#### tmux master support (#199) + +The project added support for running against tmux master and updated libtmux to 0.6.3. + +## tmuxp 1.2.4 (2017-01-13) + +tmuxp 1.2.4 is a dependency pin release. + +### Dependencies + +#### Click 6.7 and pinned docs dependencies (#195, #198) + +Click moved from 6.6 to 6.7, and colorama/docs dependencies were pinned. + +## tmuxp 1.2.3 (2016-12-21) + +tmuxp 1.2.3 is a small libtmux, test, and docs patch. + +### Fixes + +#### Suppress-history test and docs fixes (#186, #191, #193) + +The release improves suppress-history tests, fixes documentation typos, and bumps libtmux from 0.6.0 to 0.6.1. + +## tmuxp 1.2.2 (2016-09-16) + +tmuxp 1.2.2 adds tmux 2.3 support. + +### What's new + +#### tmux 2.3 support (#181) + +tmuxp now supports tmux 2.3. + +## tmuxp 1.2.1 (2016-09-16) + +tmuxp 1.2.1 fixes invalid session-name handling. + +### Fixes + +#### Invalid session names (#132) + +Invalid session names are handled correctly, and libtmux moved from 0.5.0 to 0.6.0. + +## tmuxp 1.2.0 (2016-06-16) + +tmuxp 1.2.0 adds configuration support for tmux options and environment variables. + +### What's new + +#### Session options and global options (#65) + +Workspace configuration can define `options`, `global_options`, and environment variables, with examples and tests added. + +## tmuxp 1.1.1 (2016-06-02) + +tmuxp 1.1.1 fixes attach behavior and restores version output. + +### Fixes + +#### Multiple-session attach and CLI polish (#165, #166, #167) + +Attaching multiple sessions works again, typo fixes landed, zsh/bash completion docs were added, and `tmuxp -V` returned for version info. + +## tmuxp 1.1.0 (2016-06-01) + +tmuxp 1.1.0 rewrites the CLI around Click and adds config-name loading. + +### What's new + +#### Load configs by name (#160) + +tmuxp can load named configs from the configured search paths. + +#### Click CLI rewrite (#134, #158) + +Click replaced argparse for completions, importing, config finding, conversion, and prompts. The `-l` option was removed from `tmuxp import tmuxinator|teamocil`. + +## tmuxp 1.0.2 (2016-05-25) + +tmuxp 1.0.2 fixes reattaching and improves tmuxinator imports. + +### Fixes + +#### Already-loaded session reattach (#159, #161, #163) + +Reattaching to loaded sessions was fixed, tmuxinator imports improved, and README links were corrected. + +## tmuxp 1.0.1 (2016-05-25) + +tmuxp 1.0.1 moves docs hosting and updates libtmux. + +### Documentation + +#### Read the Docs hosting + +Documentation moved to readthedocs.io. + +### Dependencies + +#### libtmux 0.4.1 (#157) + +tmuxp tracks libtmux 0.4.1. + +## tmuxp 1.0.0-rc1 (2016-05-25) + +tmuxp 1.0.0-rc1 is the release-candidate jump from the 0.11 line to 1.0. + +### What's new + +#### libtmux split and pytest migration (#145, #146, #147) + +Core tmux APIs split into [libtmux](https://github.com/tmux-python/libtmux), tests moved to pytest, new-window support landed, shell-history suppression became configurable, Makefile-based docs/test tasks were refreshed, and the README was overhauled. + +## tmuxp 0.11.0 (2016-02-29) + +tmuxp 0.11.0 adds environment settings in configs. + +### What's new + +#### Config environment settings (#137) + +Configuration files can now include environment settings. Thanks @tasdomas. + +### Documentation + +#### Spelling correction + +Spelling fixes landed from @sehe. + +## tmuxp 0.10.0 (2016-01-30) + +tmuxp 0.10.0 adds multi-session loading. + +### What's new + +#### Load multiple sessions (#135) + +tmuxp can load multiple tmux sessions at once. Thanks @madprog. + +### Documentation + +#### README and docs fixes (#131, #133) + +Documentation fixes landed alongside the feature. + +## tmuxp 0.9.3 (2016-01-06) + +tmuxp 0.9.3 improves development environment compatibility. + +### Development + +#### `.venv`, entr, and Anaconda support (#130) + +Virtualenv directories moved from `.env` to `.venv`, tests moved to [entr](http://entrproject.org/) for file watching, and Anaconda Python 2 and 3 were supported. + +## tmuxp 0.9.2 (2015-10-21) + +tmuxp 0.9.2 adds tmux 2.1 support and fixes test portability. + +### What's new + +#### tmux 2.1 support (#122) + +tmuxp now supports tmux 2.1. Thanks @estin. + +### Development + +#### Faster CI and safer pane-order tests + +Travis moved to container infrastructure, and pane-order tests stopped relying on `man(1)`. + +## tmuxp 0.9.1 (2015-08-23) + +tmuxp 0.9.1 fixes FreeBSD Python 3 packaging support. + +### Fixes + +#### FreeBSD ports Python 3 fix (#119) + +The release includes the Python 3 fix for [sysutils/pytmuxp](http://www.freshports.org/sysutils/py-tmuxp/) in FreeBSD ports. + +## tmuxp 0.9.0 (2015-07-08) + +tmuxp 0.9.0 expands shell expansion, environment-variable support, and tmux 2.0-era compatibility. + +### Breaking changes + +#### `config.expandpath` renamed to `config.expandshell` + +The old config helper was renamed as environment and shell expansion behavior grew. + +### What's new + +#### Environment variables in workspace fields + +Environment variables can be used in `start_directory`, `before_script`, `shell_command_before`, `session_name`, and `window_name`, with JSON and YAML examples added. + +### Fixes + +#### Test and dependency cleanup (#105, #107, #109, #110) + +The release devendorizes colorama, fixes Fedora pane-order tests, updates manual file extensions, fixes an attached-sessions return type, and tracks the new tmux git repository. + +## tmuxp 0.8.1 (2015-05-09) + +tmuxp 0.8.1 is a test and docs media patch. + +### Fixes + +#### Python 3 sniffer test runner + +The sniffer test runner works under Python 3, and a new animated demo was added to docs and README. + +## tmuxp 0.8.0 (2015-05-07) + +tmuxp 0.8.0 jumps from 0.1.13 to the 0.8 line for tmux 2.0 support and command API cleanup. + +### What's new + +#### tmux 2.0 support and log-level option + +The release adds tmux 2.0 support, improves `Session.switch_client()` docs, and adds `--log-level`. + +### Breaking changes + +#### `.tmux` helpers renamed to `.cmd` + +Historical `{Server,Session,Window,Pane}.tmux` helpers were refactored into `.cmd()` methods, and `util.tmux` became `util.tmux_cmd`. These old names remain inline here because they describe the 2015 API surface. + +## tmuxp 0.1.13 (2015-03-25) + +tmuxp 0.1.13 cleans up package metadata, docs building, and tests. + +### Development + +#### Metadata and docs runner cleanup + +`package_metadata.py` was replaced with `__about__.py`, docs building moved to `scent.py`, docutils moved to 0.12, `bootstrap_env.py` learned platform-specific watcher setup, CLI tests stopped using the old `TMP_DIR`, and stale watchingtestrunner examples were replaced. + +### Documentation + +#### tmux compatibility and history docs + +The release adds warnings for tmux versions below 1.4, documents leading spaces in `send_keys`, and updates the about page from teamocil and erb support. + +## tmuxp 0.1.12 (2014-08-06) + +tmuxp 0.1.12 improves path expansion when loading project files from outside the current directory. + +### Fixes + +#### Relative project file loading + +Config path expansion now resolves user and environment variables, and project files such as `/path/to/project/.tmuxp.yaml` behave better with relative directories. + +## tmuxp 0.1.11 (2014-04-06) + +tmuxp 0.1.11 improves `before_script` handling. + +### Fixes + +#### Project-relative `before_script` + +`before_script` runs relative to the project directory, reports stdout/stderr more clearly on failure, and the script-runner exceptions moved into the exception module. + +## tmuxp 0.1.10 (2014-04-02) + +tmuxp 0.1.10 fixes early pane-option and command parsing bugs. + +### Fixes + +#### Pane command and option edge cases (#73, #76, #77) + +Patches from @ThiefMaster fix spaces in `start_directory`, dashes in `shell_command`, and panes that need options but no shell command. + +## tmuxp 0.1.9 (2014-04-01) + +tmuxp 0.1.9 fixes force behavior. + +### Fixes + +#### `--force` restored + +The release restores the intended `--force` behavior. + +## tmuxp 0.1.8 (2014-03-30) + +tmuxp 0.1.8 adds pre-load scripts and directory creation. + +### What's new + +#### `before_script` and destination directory creation (#56, #72) + +tmuxp can create missing destination directories and run a `before_script` before starting a tmux session. The release also adds test helpers and links users toward the libtmux Python API quickstart. + +## tmuxp 0.1.7 (2014-02-25) + +tmuxp 0.1.7 fixes version parsing. + +### Fixes + +#### Lettered version support (#55) + +tmuxp no longer crashes on lettered tmux version strings. + +## tmuxp 0.1.6 (2014-02-08) + +tmuxp 0.1.6 stops relying on tmux `default-path` for start directories. + +### Fixes + +#### `-c start_directory` for windows and panes (#35) + +Window and pane creation now use tmux's `-c start_directory`, avoiding the old `default-path` hack that could bleed into user sessions. + +## tmuxp 0.1.5-1 (2014-02-05) + +tmuxp 0.1.5-1 fixes an installation manifest issue. + +### Packaging + +#### Manifest package metadata fix (#49) + +`package_manifest.py` is included correctly so installs no longer fail. + +## tmuxp 0.1.5 (2014-02-05) + +tmuxp 0.1.5 normalizes docs structure and packaging conventions. + +### Documentation + +#### Tao of tmux and section-heading cleanup + +The Tao of tmux docs became their own chapter candidate, headings were normalized, and project structure borrowed conventions from `tony/cookiecutter-pypackage`. + +## tmuxp 0.1.4 (2014-02-02) + +tmuxp 0.1.4 fixes CLI output and compatibility helpers. + +### Fixes + +#### Freeze output and compatibility cleanup + +`tmuxp freeze` output was corrected, `_compat` was updated, and a PEP 263 spacing issue was fixed. + +## tmuxp 0.1.3 (2014-01-29) + +tmuxp 0.1.3 fixes Python 3 CLI behavior and shell-history suppression. + +### Fixes + +#### Python 3 CLI and history suppression (#48) + +Running `tmuxp` without an option raises the expected error, Python 3 CLI behavior was fixed, and sent keys are prefixed with a space to avoid populating bash and zsh history. + +## tmuxp 0.1.2 (2014-01-08) + +tmuxp 0.1.2 adds detached loading and refreshes test style. + +### What's new + +#### Detached load mode (#43) + +`tmuxp -d` can load sessions detached. Thanks roxit. + +### Development + +#### Werkzeug/Flask-style tests + +The testsuite moved toward Werkzeug/Flask-style testing helpers. + +## tmuxp 0.1.1 (2013-12-25) + +tmuxp 0.1.1 fixes special-character handling. + +### Fixes + +#### Unicode and special characters (#32) + +Loading and freezing sessions now handle special characters more predictably. + +## tmuxp 0.1.0 (2013-12-18) + +tmuxp 0.1.0 marks the first stable 0.1 release. + +### Fixes + +#### Current-directory load output + +The duplicate filename output from `tmuxp load .` was fixed, and future releases no longer require `--pre`. + +## tmuxp 0.1-rc8 (2013-12-17) + +tmuxp 0.1-rc8 tightens Python 2/3 compatibility. + +### Development + +#### Compatibility helpers + +`unicode_literals` was adopted, and Python 2/3 compatibility code moved into `_compat`. + +## tmuxp 0.1-rc7 (2013-12-07) + +tmuxp 0.1-rc7 improves config expansion and interrupt behavior. + +### Fixes + +#### Config expansion and Ctrl-C (#33) + +The config expansion path was partially rewritten, and tmuxp exits silently on `Ctrl-C`. + +## tmuxp 0.1-rc6 (2013-12-06) + +tmuxp 0.1-rc6 adds window-index configuration. + +### What's new + +#### `window_index` option (#31) + +`window_index` support and examples were added from stratoukos. + +## tmuxp 0.1-rc5 (2013-12-04) + +tmuxp 0.1-rc5 fixes early pre-command and test behavior. + +### Fixes + +#### Session-scope `shell_command_before` and freeze errors (#26, #27, #28, #29) + +The release fixes duplicated session-scope pre-commands, improves OS X tests, and makes the missing-session error from `tmuxp freeze` less unhelpful. + +## tmuxp 0.1-rc4 (2013-12-03) + +tmuxp 0.1-rc4 fixes focused pane loading inside tmux. + +### Fixes + +#### `focus: true` inside tmux + +Focused panes no longer prevent sessions from launching when `tmuxp load` is run inside tmux. + +## tmuxp 0.1-rc3 (2013-12-03) + +tmuxp 0.1-rc3 adds focused-pane support and tests. + +### Fixes + +#### Pane focus option (#25) + +`focus: true` works for panes, tests cover focused pane config, and an example was added. + +## tmuxp 0.1-rc2 (2013-11-23) + +tmuxp 0.1-rc2 fixes pane-base-index handling. + +### Fixes + +#### `pane-base-index` set to 1 (#23) + +Workspaces build correctly when `pane-base-index` is not zero. + +### Breaking changes + +#### `tmuxp load --list` removed + +The old list behavior was removed and docs were updated. + +## tmuxp 0.1-rc1 (2013-11-23) + +tmuxp 0.1-rc1 starts per-version changelogging. + +### Development + +#### PEP 8 tests and PEP 440 versions + +Unit tests adopted PEP 8 checks, and changelog entries moved to a versioned PEP 440 scheme. + +## tmuxp 0.1-dev (2013-11-21) + +The November 21 development snapshot adds global option reads for sessions. + +### Development + +#### Session option helpers accept `g` + +`Session.show_options` and `Session.show_option` can pass `-g`. + +## tmuxp 0.1-dev (2013-11-20) + +The November 20 development snapshot improves pane ordering and option tests. + +### Fixes + +#### Pane ordering and base-index tests (#15, #21) + +Workspace builder behavior changed to fix pane ordering, Python 2.6 config tests were corrected, and async builder tests improved. + +### Development + +#### Window option helpers accept `g` + +`Window.show_window_options` and `Window.show_window_option` can pass `-g`. + +## tmuxp 0.1-dev (2013-11-17) + +The November 17 development snapshot improves missing-tmux errors. + +### Fixes + +#### Missing tmux warning + +Missing tmux now produces the correct warning. + +## tmuxp 0.1-dev (2013-11-15) + +The November 15 development snapshot makes Python 2.6 a required CI target. + +### Development + +#### Python 2.6 required in Travis + +Travis no longer treats Python 2.6 as an allowed failure. + +## tmuxp 0.1-dev (2013-11-13) + +The November 13 development snapshot adds yes-to-all prompting and duplicate-session completion protection. + +### What's new + +#### `-y` prompt confirmation (#19) + +The CLI can answer yes to questions. + +### Fixes + +#### Session completion deduplication + +The session completer no longer offers a duplicate session after one is added, and work continued on {ref}`about-tmux`. + +## tmuxp 0.1-dev (2013-11-09) + +The November 9 development snapshot adds translated docs and pane splitting at a target location. + +### Documentation + +#### Chinese documentation and about page work + +Chinese documentation was linked, and the tmux background page continued to improve. + +### Development + +#### Targeted pane splitting + +The old `Pane.split_window()` path can split a window at a target pane. + +## tmuxp 0.1-dev (2013-11-08) + +The November 8 development snapshot improves freeze output and pane shorthand. + +### What's new + +#### Freeze start directories and inline pane output + +`tmuxp freeze` can record a window `start_directory` when panes share a directory, and config exports inline simple one-command panes for more readable output. + +## tmuxp 0.1-dev (2013-11-07) + +The November 7 development snapshot replaces the logger and improves teamocil imports. + +### Development + +#### Simpler logging and teamocil root import + +The old Tornado-derived logger was replaced, teamocil import was fixed, and teamocil `root` maps to `start_directory`. + +## tmuxp 0.1-dev (2013-11-06) + +The November 6 development snapshot improves blank panes and freeze output. + +### What's new + +#### Blank panes and cleaner freeze output + +tmuxp supports blank panes (`null`, `pane`, `blank`, and empty strings), and freeze exports blank panes where it previously emitted duplicate or generic commands. + +### Development + +#### Python 2.6 support + +Python 2.6 support was restored while the project prepared to switch to per-version changelogs. + +## tmuxp 0.1-dev (2013-11-05) + +The November 5 development snapshot adds socket options to completion and loading. + +### What's new + +#### `-L` and `-S` socket support + +Autocompletion and loading support socket name and socket path arguments. Switching clients across sockets could still error in this early implementation. + +### Documentation + +#### API and about-tmux docs + +The API and {ref}`about-tmux` docs received more updates, and PEP 257 work continued. + +## tmuxp 0.1-dev (2013-11-04) + +The November 4 development snapshot is a PEP 257 and version-tag update. + +### Development + +#### PEP 257 cleanup + +Documentation style work continued, and `v0.0.36` was tagged. + +## tmuxp 0.1-dev (2013-11-02) + +The November 2 development snapshot fixes freeze paths, paths with spaces, and internal server method names. + +### Fixes + +#### Freeze, spaces, and relative `start_directory` (#12) + +`tmuxp freeze`, attach-session, kill-session, and relative `start_directory` support improved, including paths with spaces. + +### Development + +#### Internal server method cleanup + +Old double-underscore server list methods moved to single-underscore names, and docs/PEP 257 work continued. + +## tmuxp 0.1-dev (2013-11-01) + +The November 1 development snapshot expands server construction and import quality. + +### What's new + +#### Socket, config-file, and color flags + +Server construction gained `socket_name`, `socket_path`, and `config_file` support, plus `-2`/`-8` color options. + +### Fixes + +#### Relative `start_directory` and import quality + +Relative start directories concatenate correctly, teamocil and tmuxinator imports improved, and saving to `~` destinations became possible. + +## tmuxp 0.1-dev (2013-10-31) + +The October 31 development snapshot gets `start_directory` working and fixes early window ordering. + +### Fixes + +#### Window order, kill targets, and start directories + +The first and second windows no longer load in mixed order, `Window.kill_window()` targets were corrected for compatibility, and examples for `start_directory` were added. + +### Development + +#### Window moving and docs overhaul + +`Window.move_window()` landed, `util.is_version()` appeared, and the front page/internal docs were overhauled. + +## tmuxp 0.1-dev (2013-10-30) + +The October 30 development snapshot adds experimental session freezing. + +### What's new + +#### Experimental `tmuxp freeze` + +tmuxp can experimentally freeze live sessions to a file, while `tmuxp load .` and inside-tmux switching bugs were fixed. + +### Development + +#### Start directory work + +Support for `start_directory` continued, with `Window.kill_window()` work in the same snapshot. + +## tmuxp 0.1-dev (2013-10-29) + +The October 29 development snapshot adds shorthand panes, import wizards, and better crash handling. + +### What's new + +#### Pane shorthand, `.yml`, and import wizard + +Panes can be written as strings, `.yml` files load, and teamocil/tmuxinator imports gained a wizard that can save JSON or YAML. + +#### Automatic rename and pane selection + +The configuration gained `automatic-rename`, examples for pane sizing and shorthands, and pane selection accepted directional flags. + +### Fixes + +#### Loaded-session prompt and builder crash recovery + +tmuxp no longer switches or attaches after a negative prompt response, and workspace loader crashes offer kill, attach, or detach recovery options. + +## tmuxp 0.1-dev (2013-10-28) + +The October 28 development snapshot fixes current-directory load and conversion. + +### Fixes + +#### `tmuxp load .` and `tmuxp convert` + +Both commands were fixed, and test runner name handling improved. + +### Development + +#### Pane sizing and target defaults + +Historical pane sizing helpers and automatic window/pane target behavior landed during this API-building period. + +## tmuxp 0.1-dev (2013-10-27) + +The October 27 development snapshot overhauls filename completion. + +### What's new + +#### Relative and full filenames in CLI commands + +`tmuxp load`, `tmuxp convert`, and `tmuxp import` accept relative and full filenames in addition to config-directory discovery. + +### Development + +#### argcomplete CLI completion + +The CLI completion implementation was overhauled with argcomplete. + +## tmuxp 0.1-dev (2013-10-26) + +The October 26 development snapshot starts tmuxinator import support. + +### What's new + +#### Initial tmuxinator importer + +The first tmuxinator importer can convert some options, but was not expected to fully preserve window/pane size and state. + +### Fixes + +#### Config file extension handling + +Config discovery accepts extension lists and string extension arguments, and `tmuxp load -l` works alongside `tmuxp load filename`. + +## tmuxp 0.1-dev (2013-10-25) + +The October 25 development snapshot fixes version output and serverless session commands. + +### Fixes + +#### Version flag and no-server attach/kill + +`-v` and `--version` print correctly, and attach-session/kill-session handle the case where no tmux server exists. + +### Development + +#### Import fixtures + +Test fixtures and initial teamocil import work were added. + +## tmuxp 0.1-dev (2013-10-24) + +The October 24 development snapshot adds config conversion and JSON examples. + +### What's new + +#### JSON/YAML conversion + +`tmuxp convert` can convert between JSON and YAML in both directions, and JSON examples were added to the docs. + +### Fixes + +#### zsh auto-title prompts and session output + +tmuxp checks oh-my-zsh auto-title settings, fixes bad kill-session output, and improves tab completion with no active tmux server. + +## tmuxp 0.1-dev (2013-10-23) + +The October 23 development snapshot expands tab completion and core CLI commands. + +### What's new + +#### Completion for sessions and commands + +zsh, bash, and tcsh completions improved for `kill-session`, `attach-session`, and command options. `attach-session` switches clients when already inside tmux, and `load` can load configs. + +## tmuxp 0.1-dev (2013-10-21) + +The October 21 development snapshot makes tmux 1.8 the official minimum and restores version output. + +### Breaking changes + +#### tmux 1.8 minimum + +tmuxp warns when tmux is out of date and removed old compatibility code that caused build regressions. + +### Documentation + +#### Examples and graphics + +Two examples and graphics were added to {ref}`examples`, and `$ tmuxp -v` prints version info. + +## tmuxp 0.1-dev (2013-10-19) + +The October 19 development snapshot improves missing-binary handling and continues the early object-model overhaul. + +### Fixes + +#### Missing tmux exits clearly + +tmuxp exits with a clear message when `tmux` is not on PATH. + +### Development + +#### Server, session, window, and pane object refresh + +The early libtmux-style object model continued to separate public object lists from lower-level dict data returned by tmux commands. + +## tmuxp 0.1-dev (2013-10-18) + +The October 18 development snapshot moves more state refresh behavior into the server object. + +### Development + +#### Object data refresh model + +Session, window, and pane objects now refer back to server-owned data and refresh through unique identifiers, reducing manual data-setting complexity. + +#### Python 3 and legacy tmux research + +Python 3 support and possible tmux 1.6/1.7 support were under active research. + +## tmuxp 0.1-dev (2013-10-17) + +The October 17 development snapshot refreshes project docs and session switching. + +### What's new + +#### Switching sessions from inside tmux + +tmuxp can switch sessions from inside tmux after a session is built and when a session already exists. + +### Documentation + +#### README and development example update + +The README, screenshots, and development `.tmuxp.yaml` example were updated. + +## tmuxp 0.1-dev (2013-10-16) + +The October 16 development snapshot adds manifest fixes, completions, and an early object mapping layer. + +### Fixes + +#### Missing manifest file + +`MANIFEST.in` was added so packages install with required files. + +### What's new + +#### Shell completion and tmux binary lookup + +bash/zsh completion landed, and tmuxp uses a `which` helper to find the tmux binary. + +### Development + +#### Early relational object API + +Server/session/window/pane object mapping gained Backbone-inspired lookup helpers and split mapping behavior from relational behavior. + +## tmuxp 0.1-dev (2013-10-15) + +The October 15 development snapshot adds the first practical config loading flow. + +### What's new + +#### Load current-directory configs and one-command panes + +`tmuxp .` can load `.tmuxp.{yaml,json,py}` in the current directory, configs can express one-command panes compactly, and sessions prompt to attach when already present. + +#### Socket options and theme refresh + +Socket name/path support, initial examples, and a new theme landed in the same snapshot. + +## tmuxp 0.1-dev (2013-10-14) + +The October 14 development snapshot can list and build configs. + +### What's new + +#### Config listing and session building + +tmuxp can list configs in the current directory and `$HOME/.tmuxp`, launch configs, and build sessions. + +### Development + +#### Config validation helpers + +Early helpers for config consistency, config-file detection, and config-directory discovery were added. + +## tmuxp 0.1-dev (2013-10-13) + +The October 13 development snapshot improves exported config readability and object refresh helpers. + +### Development + +#### Inline config output and refresh helpers + +`config.inline()` produces cleaner exported configs, pane resizing tests were added, and session/window/pane refresh and find helpers landed. + +## tmuxp 0.1-dev (2013-10-12) + +The October 12 development snapshot introduces the workspace builder. + +### Development + +#### WorkspaceBuilder starts building sessions + +The builder can create panes, build windows, and set tmux options, with early option helpers on session and window objects. + +## tmuxp 0.1-dev (2013-10-11) + +The October 11 development snapshot prepares the builder and test runner. + +### Development + +#### Test runner, Travis matrix, and CLI beginnings + +The test suite and runner were overhauled, Travis tested tmux 1.8 and tmux source, logging was quieted, Python 3 compatibility imports were added, setup reads package version via regex, and CLI code began moving into `tmuxp.cli`. + +## tmuxp 0.1-dev (2013-10-09) + +The October 9 development snapshot simplifies logging dependencies. + +### Development + +#### Logging dependency cleanup + +A new logging module replaced the `logutils` and `sh` dependencies. + +## tmuxp 0.1-dev (2013-10-08) + +The October 8 development snapshot moves the project to semantic versioning. + +### Development + +#### Semantic versioning -- switch to semver - -[tmuxinator]: https://github.com/aziz/tmuxinator -[teamocil]: https://github.com/remiprev/teamocil -[argcomplete]: https://github.com/kislyuk/argcomplete -[pep257]: http://www.python.org/dev/peps/pep-0257/ -[pep8]: http://www.python.org/dev/peps/pep-0008/ -[pep440]: http://www.python.org/dev/peps/pep-0440/ -[tony/cookiecutter-pypackage]: https://github.com/tony/cookiecutter-pypackage -[@tasdomas]: https://github.com/tasdomas -[@sehe]: https://github.com/sehe -[@madprog]: https://github.com/madprog -[autoenv]: https://github.com/kennethreitz/autoenv -[entr(1)]: http://entrproject.org/ -[anaconda python]: http://docs.continuum.io/anaconda/index -[@estin]: https://github.com/estin -[sysutils/pytmuxp]: http://www.freshports.org/sysutils/py-tmuxp/ -[#201564]: https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=201564 -[colorama]: https://pypi.python.org/pypi/colorama -[@marbu]: https://github.com/marbu -[@yegortimoshenko]: https://github.com/yegortimoshenko -[@thomasballinger]: https://github.com/thomasballinger -[sniffer]: https://github.com/jeffh/sniffer -[watchingtestrunner]: https://pypi.python.org/pypi/watching_testrunner/1.0 -[@raine]: https://github.com/raine -[@techtonik]: https://github.com/techtonik -[@thiefmaster]: https://github.com/ThiefMaster -[pep 263]: http://www.python.org/dev/peps/pep-0263/ -[roxit]: https://github.com/roxit -[documentation in chinese]: http://tmuxp-zh.readthedocs.io -[wrongwaycn]: https://github.com/wrongwaycn -[tornado's log.py]: https://github.com/facebook/tornado/blob/master/tornado/log.py -[underscore.js]: http://underscorejs.org/ -[backbone.js]: http://backbonejs.org/ -[.get()]: http://backbonejs.org/#Collection-get -[.where()]: http://underscorejs.org/#where -[.findwhere()]: http://underscorejs.org/#findWhere -[libtmux]: https://github.com/tmux-python/libtmux - - +tmuxp switched to semver for early release numbering. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000000..8f79f960ed --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,13 @@ +cff-version: 1.2.0 +message: >- + If you use this software, please cite it as below. + NOTE: Change "x.y" by the version you use. If you are unsure about which version + you are using run: `pip show tmuxp`." +authors: +- family-names: "Narlock" + given-names: "Tony" + orcid: "https://orcid.org/0000-0002-2568-415X" +title: "tmuxp" +type: software +version: x.y +url: "https://tmuxp.git-pull.com" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 3ef459d5af..0000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include README.md LICENSE CHANGES pyproject.toml .tmuxp.yaml -recursive-include docs *.md -recursive-include tests *.py *.yaml *.json *.sh -recursive-include examples *.yaml *.json diff --git a/MIGRATION b/MIGRATION new file mode 100644 index 0000000000..0d31b4e377 --- /dev/null +++ b/MIGRATION @@ -0,0 +1,69 @@ +# Migration notes + +Migration and deprecation notes for tmuxp are here, see {ref}`changelog` as +well. + +```{admonition} Welcome on board! 👋 +1. 📌 For safety, **always** pin the package +2. 📖 Check the migration notes _(You are here)_ +3. 📣 If you feel something got deprecated and it interrupted you - past, present, or future - voice your opinion on the [tracker]. + + We want to make tmuxp fun, reliable, and useful for users. + + API changes can be painful. + + If we can do something to draw the sting, we'll do it. We're taking a balanced approach. That's why these notes are here! + + (Please pin the package. 🙏) + + [tracker]: https://github.com/tmux-python/tmuxp/discussions +``` + +## Next release + +_Notes on the upcoming release will be added here_ + + + +## tmuxp 1.18.0 (2022-10-30) + +**Restructuring** (#840) + +"Config files" and "configs" are now referred to as workspace files. + +Additionally, there's been a major file structure update: + +- `cli/utils.py` functions moved to `workspace/finders.py` +- `config.py` split between: + + - `workspace/finders.py` + - `workspace/freezer.py` + - `workspace/importers.py` + - `workspace/validation.py` + +- `workspacebuilder.py` split into: + + - `workspace/builder.py` + - `workspace/freezer.py` + + `config.inline` moved to freezer + +Tests: + +- `tests/fixtures/{workspacebuilder,workspacefreezer}` -> `tests/fixtures/workspace/{builder,freezer}` +- `tests/test_import_{teamocil,tmuxinator}.py` -> `tests/workspace/test_import_{teamocil,tmuxinator}.py` + +## tmuxp 1.17.0 (2022-10-09) + +**Completions have changed** (#830) + +Completions now use a different tool: [shtab]. See the [completions page] for more information. + +If you were using earlier versions of tmuxp (earlier than 1.17.0), you may need to uninstall the old completions, first. + +[completions page]: https://tmuxp.git-pull.com/cli/completion.html +[shtab]: https://docs.iterative.ai/shtab/ + + diff --git a/Makefile b/Makefile deleted file mode 100644 index 1638d15821..0000000000 --- a/Makefile +++ /dev/null @@ -1,54 +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 http://entrproject.org/ " - @echo "----------------------------------------------------------" - -isort: - poetry run isort `${PY_FILES}` - -black: - poetry run black `${PY_FILES}` - -test: - poetry run py.test $(test) - -start: - $(MAKE) test; poetry 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 - -flake8: - poetry run flake8 - -watch_flake8: - if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) flake8; else $(MAKE) flake8 entr_warn; fi - -format_markdown: - npx prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES diff --git a/README.md b/README.md index 3558787915..74befa3c7a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # tmuxp -A session manager for tmux. Built on [libtmux](https://github.com/tmux-python/libtmux). +Session manager for tmux. Save and load your tmux sessions through simple configuration files. Powered by [libtmux](https://github.com/tmux-python/libtmux). [![Python Package](https://img.shields.io/pypi/v/tmuxp.svg)](https://pypi.org/project/tmuxp/) [![Docs](https://github.com/tmux-python/tmuxp/workflows/docs/badge.svg)](https://tmuxp.git-pull.com/) @@ -13,7 +13,7 @@ is available on Leanpub and [Amazon Kindle](http://amzn.to/2gPfRhC). Read and browse the book for free [on the web](https://leanpub.com/the-tao-of-tmux/read). -**Have some spare time?** Help us triage and code review and the tracker. See [issue +**Have some spare time?** Help us triage and code-review on the tracker. See [issue #290](https://github.com/tmux-python/tmuxp/discussions/290)! # Installation @@ -21,19 +21,38 @@ web](https://leanpub.com/the-tao-of-tmux/read). pip: ```console -$ pip install --user tmuxp +pip install --user tmuxp +``` + +If you're managing the project with [uv](https://docs.astral.sh/uv/), add tmuxp as a dependency instead: + +```console +uv add tmuxp +``` + +To run tmuxp without installing it globally, similar to `pipx`, invoke it with +[uvx](https://docs.astral.sh/uv/guides/tools/): + +```console +uvx tmuxp ``` Homebrew: ```console -$ brew install tmuxp +brew install tmuxp ``` Debian / ubuntu: ```console -$ sudo apt install tmuxp +sudo apt install tmuxp +``` + +Nix: + +```console +[[ -z $(which tmux) ]] && (nix-env -i tmux && nix-env -i tmuxp) || nix-env -i tmuxp ``` Find the package for your distro on repology: @@ -43,13 +62,26 @@ Developmental releases: - [pip](https://pip.pypa.io/en/stable/): ```console - $ pip install --user --upgrade --pre tmuxp + pip install --user --upgrade --pre tmuxp + ``` + + Or request the pre-release from a uv project environment: + + ```console + uv add 'tmuxp>=1.10.0b1' + ``` + + +- [uvx](https://docs.astral.sh/uv/guides/tools/): + + ```console + uvx tmuxp ``` - [pipx](https://pypa.github.io/pipx/docs/): ```console - $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force + pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force ``` Then use `tmuxp@next load [session]`. @@ -73,36 +105,36 @@ windows: - ls -al | grep \.log - echo second pane # pane no. 2 - echo third pane # pane no. 3 - - echo forth pane # pane no. 4 + - echo fourth pane # pane no. 4 ``` Save as _mysession.yaml_, and load: ```console -$ tmuxp load ./mysession.yaml +tmuxp load ./mysession.yaml ``` Projects with _.tmuxp.yaml_ or _.tmuxp.json_ load via directory: ```console -$ tmuxp load path/to/my/project/ +tmuxp load path/to/my/project/ ``` Load multiple at once (in bg, offer to attach last): ```console -$ tmuxp load mysession ./another/project/ +tmuxp load mysession ./another/project/ ``` Name a session: ```console -$ tmuxp load -s session_name ./mysession.yaml +tmuxp load -s session_name ./mysession.yaml ``` -[simple](http://tmuxp.git-pull.com/examples.html#short-hand-inline) and +[simple](https://tmuxp.git-pull.com/configuration/examples/#short-hand-inline-style) and [very -elaborate](http://tmuxp.git-pull.com/examples.html#super-advanced-dev-environment) +elaborate](https://tmuxp.git-pull.com/configuration/examples/#super-advanced-dev-environment) config examples # User-level configurations @@ -117,7 +149,7 @@ Load your tmuxp config from anywhere by using the filename, assuming _\~/.config/tmuxp/mysession.yaml_ (or _.json_): ```console -$ tmuxp load mysession +tmuxp load mysession ``` See [author's tmuxp configs](https://github.com/tony/tmuxp-config) and @@ -133,7 +165,7 @@ server, session, and window in [libtmux](https://github.com/tmux-python/libtmux) objects. ```console -$ tmuxp shell +tmuxp shell (Pdb) server @@ -153,28 +185,26 @@ Window(@3 1:your_window, Session($1 your_project)) Pane(%6 Window(@3 1:your_window, Session($1 your_project)) ``` -Python 3.7+ supports [PEP +Supports [PEP 553](https://www.python.org/dev/peps/pep-0553/) `breakpoint()` (including `PYTHONBREAKPOINT`). Also supports direct commands via `-c`: ```console -$ tmuxp shell -c 'print(window.name)' +tmuxp shell -c 'print(window.name)' my_window -$ tmuxp shell -c 'print(window.name.upper())' +tmuxp shell -c 'print(window.name.upper())' MY_WINDOW ``` -Read more on [tmuxp shell](https://tmuxp.git-pull.com/cli/#shell) in +Read more on [tmuxp shell](https://tmuxp.git-pull.com/cli/shell/) in the CLI docs. # Pre-load hook -Run custom startup scripts (such as installing project dependencies +Run custom startup scripts (such as installing project dependencies) before loading tmux. See the -[bootstrap_env.py](https://github.com/tmux-python/tmuxp/blob/master/bootstrap_env.py) -and -[before_script](http://tmuxp.git-pull.com/examples.html#bootstrap-project-before-launch) +[before_script](https://tmuxp.git-pull.com/configuration/examples/#bootstrap-project-before-launch) example # Load in detached state @@ -190,18 +220,18 @@ You can also load sessions in the background by passing `-d` flag Snapshot your tmux layout, pane paths, and window/session names. ```console -$ tmuxp freeze session-name +tmuxp freeze session-name ``` See more about [freezing -tmux](http://tmuxp.git-pull.com/commands/#freeze-sessions) sessions. +tmux](https://tmuxp.git-pull.com/cli/freeze/) sessions. # Convert a session file Convert a session file from yaml to json and vice versa. ```console -$ tmuxp convert filename +tmuxp convert filename ``` This will prompt you for confirmation and shows you the new file that is @@ -210,14 +240,14 @@ going to be written. You can auto confirm the prompt. In this case no preview will be shown. ```console -$ tmuxp convert -y filename -$ tmuxp convert --yes filename +tmuxp convert -y filename +tmuxp convert --yes filename ``` # Plugin System tmuxp has a plugin system to allow for custom behavior. See more about -the [Plugin System](http://tmuxp.git-pull.com/plugin_system.html). +the [Plugin System](https://tmuxp.git-pull.com/topics/plugins/). # Debugging Helpers @@ -225,13 +255,13 @@ The `load` command provides a way to log output to a log file for debugging purposes. ```console -$ tmuxp load --log-file . +tmuxp load --log-file . ``` Collect system info to submit with a Github issue: ```console -$ tmuxp debug-info +tmuxp debug-info ------------------ environment: system: Linux @@ -242,13 +272,13 @@ environment: # Docs / Reading material -See the [Quickstart](http://tmuxp.git-pull.com/quickstart.html). +See the [Quickstart](https://tmuxp.git-pull.com/quickstart/). -[Documentation](http://tmuxp.git-pull.com) homepage (also in +[Documentation](https://tmuxp.git-pull.com) homepage (also in [中文](http://tmuxp-zh.rtfd.org/)) Want to learn more about tmux itself? [Read The Tao of Tmux -online](http://tmuxp.git-pull.com/about_tmux.html). +online](https://tmuxp.git-pull.com/about_tmux/). # Donations @@ -257,18 +287,19 @@ Your money will go directly to maintenance and development of the project. If you are an individual, feel free to give whatever feels right for the value you get out of the project. -See donation options at . +See donation options at . # Project details -- tmux support: 1.8, 1.9a, 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6 -- python support: >= 3.7, pypy, pypy3 +- tmux support: 3.2+ +- python support: >= 3.10, pypy, pypy3 - Source: - Docs: -- API: -- Changelog: +- API: +- Changelog: - Issues: - Test Coverage: - pypi: - Open Hub: +- Repology: - License: [MIT](http://opensource.org/licenses/MIT). diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000000..ad606b5342 --- /dev/null +++ b/conftest.py @@ -0,0 +1,151 @@ +"""Conftest.py (root-level). + +We keep this in root pytest fixtures in pytest's doctest plugin to be available, as well +as avoiding conftest.py from being included in the wheel, in addition to pytest_plugin +for pytester only being available via the root directory. + +See "pytest_plugins in non-top-level conftest files" in +https://docs.pytest.org/en/stable/deprecations.html +""" + +from __future__ import annotations + +import logging +import os +import pathlib +import shutil +import typing as t + +import pytest +from _pytest.doctest import DoctestItem +from libtmux.test.random import namer + +from tests.fixtures import utils as test_utils +from tmuxp.workspace.finders import get_workspace_dir + +if t.TYPE_CHECKING: + from libtmux.session import Session + +logger = logging.getLogger(__name__) +USING_ZSH = "zsh" in os.getenv("SHELL", "") + + +@pytest.fixture(autouse=True) +def _pin_test_shell_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Pin ``$SHELL`` to ``/bin/sh`` for every test. + + tmux falls back to ``$SHELL`` for new panes when ``default-shell`` is + unset, so pinning the env var here forces every test pane to spawn + ``/bin/sh`` instead of the contributor's interactive shell. Eliminates + rcfile init cost (zsh / oh-my-zsh / bash profile chains) and yields + deterministic prompt output that doesn't flake capture-pane assertions. + ``monkeypatch`` restores the prior value at teardown. + """ + monkeypatch.setenv("SHELL", "/bin/sh") + + +@pytest.fixture(autouse=USING_ZSH, scope="session") +def zshrc(user_path: pathlib.Path) -> pathlib.Path | None: + """Quiets ZSH default message. + + Needs a startup file .zshenv, .zprofile, .zshrc, .zlogin. + """ + if not USING_ZSH: + return None + p = user_path / ".zshrc" + p.touch() + return p + + +@pytest.fixture(autouse=True) +def home_path_default(monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path) -> None: + """Set HOME to user_path (random, temporary directory).""" + monkeypatch.setenv("HOME", str(user_path)) + + +@pytest.fixture +def tmuxp_configdir(user_path: pathlib.Path) -> pathlib.Path: + """Ensure and return tmuxp config directory.""" + xdg_config_dir = user_path / ".config" + xdg_config_dir.mkdir(exist_ok=True) + + tmuxp_configdir = xdg_config_dir / "tmuxp" + tmuxp_configdir.mkdir(exist_ok=True) + return tmuxp_configdir + + +@pytest.fixture +def tmuxp_configdir_default( + monkeypatch: pytest.MonkeyPatch, + tmuxp_configdir: pathlib.Path, +) -> None: + """Set tmuxp configuration directory for ``TMUXP_CONFIGDIR``.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmuxp_configdir)) + assert get_workspace_dir() == str(tmuxp_configdir) + + +@pytest.fixture +def monkeypatch_plugin_test_packages(monkeypatch: pytest.MonkeyPatch) -> None: + """Monkeypatch tmuxp plugin fixtures to python path.""" + paths = [ + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/", + "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/", + ] + for path in paths: + monkeypatch.syspath_prepend(str(pathlib.Path(path).resolve())) + + +@pytest.fixture +def session_params(session_params: dict[str, t.Any]) -> dict[str, t.Any]: + """Terminal-friendly tmuxp session_params for dimensions.""" + session_params.update({"x": 800, "y": 600}) + return session_params + + +@pytest.fixture +def socket_name(request: pytest.FixtureRequest) -> str: + """Random socket name for tmuxp.""" + return f"tmuxp_test{next(namer)}" + + +# Modules that actually need tmux fixtures in their doctests +DOCTEST_NEEDS_TMUX = { + "tmuxp.workspace.builder.classic", +} + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Add rerun markers to tmux-dependent doctests for flaky shell timing.""" + for item in items: + if isinstance(item, DoctestItem): + module_name = item.dtest.globs.get("__name__", "") + if module_name in DOCTEST_NEEDS_TMUX: + item.add_marker(pytest.mark.flaky(reruns=2)) + + +@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): + # 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/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000000..da88da5f44 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,119 @@ +# Documentation voice + +This file covers the *voice* of prose under `docs/` — how to frame a +feature page so a reader meets the idea before its configuration. It +complements the repository-root `AGENTS.md`, which already governs code +blocks, shell-command formatting, changelog conventions, and MyST +roles. When the two overlap, the root file wins; this one only answers +the question it leaves open: how should the prose sound? + +## Who you are writing for + +The default reader runs tmuxp and writes workspace files in YAML or +JSON. They are fluent in tmux itself — servers, sessions, windows, +panes, layouts, the shell and its prompt — but you cannot assume they +read Python, know tmuxp's internals, or have heard of its builder +architecture, entry points, or `sys.path`. + +A second, smaller reader writes Python: custom builders, plugins, code +against libtmux. Serve them too, but mark their material as opt-in +("for the braver cases", "advanced") so the default reader knows they +can stop. Never make the common case pay a comprehension tax for the +advanced one. + +## Voice + +- **Second person, present tense, active.** "You name the builder", not + "The builder is selected". Address the reader who is doing the thing. +- **Concept before configuration.** Open by saying what the thing *is* + and what it does for the reader. The YAML surface — the keys, the + flags — is the last detail they need, not the first. A page that + opens with "set these keys" has buried the idea under its mechanics. +- **Say when they can stop.** Lead with the default and the + reassurance: most readers never touch this, it works out of the box, + everything here is optional. Let a skimmer leave after one sentence. +- **Progressive disclosure.** Order by how many readers need it: + default → the one option a few will tune → swapping the whole thing + → writing your own. Each step is for a smaller audience than the last. +- **Name the trade-off.** If an option costs something — load time, a + slower attach — say so, and say what it buys ("a little slower, but + the workspace is fully prepped before you attach"). State it; don't + sell it. +- **Frame by concept, not by mechanism.** Don't call a feature "the + keys" or "the flags" in prose; that names the implementation surface, + which is the reader's last concern. Name the concept. The mechanics + vocabulary — a `Key` / `Type` / `Default` table — is correct in a + reference table, and only there. + +## What stays precise + +Warm the framing, never the facts. Resolution-order lists, value +tables, exact error strings, and class or function cross-references +carry meaning in their exact form — leave them alone. The friendly +voice belongs in the sentences *around* a precise block, introducing +it, not inside it paraphrasing it into vagueness. + +## Cross-references + +Point the advanced reader at the deep-dive rather than inlining it, and +put the link where their interest peaks — on the phrase that made them +curious ("write your own") — not as a standalone footnote the eye +skips. Use the MyST roles listed in the root `AGENTS.md`. + +Link the first prose mention of any symbol that has a useful destination on +that page. This includes Python objects, tmuxp APIs, libtmux APIs, CLI command +pages, topic/configuration pages, and external tools or projects. Use the most +specific target available: `{class}`, `{meth}`, `{func}`, `{mod}`, `{exc}`, or +`{attr}` for API objects; `{ref}` or `{doc}` for documentation pages and +section anchors; and a Markdown link or reference link for external projects. +After the first linked mention on a page, later mentions can stay plain unless +the distance or context makes another link useful. + +Do not rely on a later reference section to satisfy the first-mention rule. If +the first occurrence would be a heading, grid-card teaser, or introductory +sentence, link that occurrence or retitle the heading so the first prose mention +can carry the link. Leave command examples, code blocks, Mermaid node labels, +and literal configuration values as code; link the surrounding prose instead. + +## A page that does this + +`docs/configuration/workspace-builders.md` is the worked example: +a concept-first intro, an out-of-the-box reassurance, sections ordered +by shrinking audience, an honest trade-off on the prompt wait, and +precise reference tables left precise. Read it before reshaping another +page. + +## Diagrams and reference pages + +Two mechanical conventions, separate from voice: + +- **Mermaid diagrams** render to inline SVG at build time (via the + `sphinx-gp-mermaid` package). Tag any node whose label is a command, + code identifier, config key, or other symbol with `:::cmd` so it + renders monospace — + the way that text reads as code inline; leave prose and concept nodes + unstyled. Prefer top-to-bottom (`flowchart TD`); wide left-to-right + charts don't scale on narrow viewports. Add `:alt:`, `:name:`, and + `:responsive: fit` to every diagram; use `:responsive: preserve` only + when the wide artifact is intentional and should scroll instead of + shrinking. `docs/configuration/workspace-builders.md` is the reference. +- **Internal API pages** document a module with an `{eval-rst}` block + wrapping `.. automodule:: ` (with `:members:`), the way the + existing `docs/internals/api/**` pages do. A bare `.. py:module::` + registers a cross-reference target but renders an empty page — reach + for it only to add a *package* target to an index page that already + carries its own content (grids, prose), where `automodule` would + duplicate members documented on the leaf pages. + +## Before you commit + +- Does the page open with what the feature *is*, or with how to + configure it? +- Can a reader who needs only the default stop after the first + paragraph? +- Is anything framed as "the keys/flags" that should be named by + concept instead? +- Are the advanced and Python-only parts clearly marked opt-in? +- Did you leave every table, error string, and cross-reference exact? +- Are diagram command/symbol nodes tagged `:::cmd`, and is the chart + vertical unless it has a reason to be wide? diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 38e6bf8ea8..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 = poetry 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: - poetry run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} $(O) - -design: - # This adds additional watch directories (for _static file changes) and disable incremental builds - poetry run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} --watch "." -a $(O) diff --git a/docs/_ext/aafig.py b/docs/_ext/aafig.py index 8d4ec5d99f..6f90d51378 100644 --- a/docs/_ext/aafig.py +++ b/docs/_ext/aafig.py @@ -1,27 +1,34 @@ -""" - sphinxcontrib.aafig - ~~~~~~~~~~~~~~~~~~~ +"""aafig plugin for sphinx. + +sphinxcontrib.aafig +~~~~~~~~~~~~~~~~~~~ - Allow embeded ASCII art to be rendered as nice looking images - using the aafigure reStructuredText extension. +Allow embedded ASCII art to be rendered as nice looking images +using the aafigure reStructuredText extension. - See the README file for details. +See the README file for details. - :author: Leandro Lucarella - :license: BOLA, see LICENSE for details +:author: Leandro Lucarella +:license: BOLA, see LICENSE for details """ + +from __future__ import annotations + +import locale +import logging +import pathlib import posixpath +import typing as t +from hashlib import sha1 as sha from os import path from docutils import nodes from docutils.parsers.rst.directives import flag, images, nonnegative_int from sphinx.errors import SphinxError -from sphinx.util import ensuredir, logging, relative_uri +from sphinx.util.osutil import ensuredir, relative_uri -try: - from hashlib import sha1 as sha -except ImportError: - from sha import sha +if t.TYPE_CHECKING: + from sphinx.application import Sphinx try: @@ -32,23 +39,30 @@ logger = logging.getLogger(__name__) -DEFAULT_FORMATS = dict(html="svg", latex="pdf", text=None) +DEFAULT_FORMATS = {"html": "svg", "latex": "pdf", "text": None} -def merge_dict(dst, src): - for (k, v) in src.items(): +def merge_dict( + dst: dict[str, str | None], + src: dict[str, str | None], +) -> dict[str, str | None]: + for k, v in src.items(): if k not in dst: dst[k] = v return dst -def get_basename(text, options, prefix="aafig"): +def get_basename( + text: str, + options: dict[str, str], + prefix: str | None = "aafig", +) -> str: options = options.copy() if "format" in options: del options["format"] hashkey = text + str(options) - id = sha(hashkey.encode("utf-8")).hexdigest() - return f"{prefix}-{id}" + id_ = sha(hashkey.encode("utf-8")).hexdigest() + return f"{prefix}-{id_}" class AafigError(SphinxError): @@ -56,34 +70,34 @@ class AafigError(SphinxError): class AafigDirective(images.Image): - """ - Directive to insert an ASCII art figure to be rendered by aafigure. - """ + """Directive to insert an ASCII art figure to be rendered by aafigure.""" has_content = True required_arguments = 0 - own_option_spec = dict( - line_width=float, - background=str, - foreground=str, - fill=str, - aspect=nonnegative_int, - textual=flag, - proportional=flag, + own_option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "line_width": float, + "background": str, + "foreground": str, + "fill": str, + "aspect": nonnegative_int, + "textual": flag, + "proportional": flag, + } + option_spec = ( + images.Image.option_spec.copy() if images.Image.option_spec is not None else {} ) - option_spec = images.Image.option_spec.copy() option_spec.update(own_option_spec) - def run(self): - aafig_options = dict() - own_options_keys = [self.own_option_spec.keys()] + ["scale"] - for (k, v) in self.options.items(): + def run(self) -> list[nodes.Node]: + aafig_options = {} + own_options_keys = [self.own_option_spec.keys(), "scale"] + for k, v in self.options.items(): if k in own_options_keys: # convert flags to booleans if v is None: v = True # convert percentage to float - if k == "scale" or k == "aspect": + if k in {"scale", "aspect"}: v = float(v) / 100.0 aafig_options[k] = v del self.options[k] @@ -92,17 +106,17 @@ def run(self): if isinstance(image_node, nodes.system_message): return [image_node] text = "\n".join(self.content) - image_node.aafig = dict(options=aafig_options, text=text) + image_node.aafig = {"options": aafig_options, "text": text} # type: ignore[attr-defined] return [image_node] -def render_aafig_images(app, doctree): +def render_aafig_images(app: Sphinx, doctree: nodes.Node) -> None: format_map = app.builder.config.aafig_format merge_dict(format_map, DEFAULT_FORMATS) if aafigure is None: - logger.warn( + logger.warning( "aafigure module not installed, ASCII art images " - "will be redered as literal text" + "will be rendered as literal text", ) for img in doctree.traverse(nodes.image): if not hasattr(img, "aafig"): @@ -111,15 +125,17 @@ def render_aafig_images(app, doctree): continue options = img.aafig["options"] text = img.aafig["text"] - format = app.builder.format + format_ = app.builder.format merge_dict(options, app.builder.config.aafig_default_options) - if format in format_map: - options["format"] = format_map[format] + if format_ in format_map: + options["format"] = format_map[format_] else: - logger.warn( - 'unsupported builder format "%s", please ' - "add a custom entry in aafig_format config " - "option for this builder" % format + logger.warning( + ( + 'unsupported builder format "%s", please add a custom entry in ' + "aafig_format config option for this builder" + ), + format_, ) img.replace_self(nodes.literal_block(text, text)) continue @@ -127,9 +143,9 @@ def render_aafig_images(app, doctree): img.replace_self(nodes.literal_block(text, text)) continue try: - fname, outfn, id, extra = render_aafigure(app, text, options) + fname, _outfn, _id, extra = render_aafigure(app, text, options) except AafigError as exc: - logger.warn("aafigure error: " + str(exc)) + logger.warning("aafigure error: " + str(exc)) img.replace_self(nodes.literal_block(text, text)) continue img["uri"] = fname @@ -142,33 +158,40 @@ def render_aafig_images(app, doctree): img["height"] = height -def render_aafigure(app, text, options): - """ - Render an ASCII art figure into the requested format output file. - """ +class AafigureNotInstalled(AafigError): + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__("aafigure module not installed", *args, **kwargs) + +def render_aafigure( + app: Sphinx, + text: str, + options: dict[str, str], +) -> tuple[str, str, str | None, str | None]: + """Render an ASCII art figure into the requested format output file.""" if aafigure is None: - raise AafigError("aafigure module not installed") + raise AafigureNotInstalled fname = get_basename(text, options) fname = "{}.{}".format(get_basename(text, options), options["format"]) if app.builder.format == "html": # HTML - imgpath = relative_uri(app.builder.env.docname, "_images") + target_uri = app.builder.get_target_uri(app.builder.env.docname) + imgpath = relative_uri(target_uri, "_images") relfn = posixpath.join(imgpath, fname) outfn = path.join(app.builder.outdir, "_images", fname) else: # Non-HTML if app.builder.format != "latex": - logger.warn( - "aafig: the builder format %s is not officially " + logger.warning( + f"aafig: the builder format {app.builder.format} is not officially " "supported, aafigure images could not work. " "Please report problems and working builder to " - "avoid this warning inthe future" % app.builder.format + "avoid this warning in the future", ) relfn = fname outfn = path.join(app.builder.outdir, fname) - metadata_fname = "%s.aafig" % outfn + metadata_fname = f"{outfn}.aafig" try: if path.isfile(outfn): @@ -177,14 +200,15 @@ def render_aafigure(app, text, options): f = None try: try: - f = open(metadata_fname) - extra = f.read() - except Exception: - raise AafigError() + extra = pathlib.Path(metadata_fname).read_text( + encoding=locale.getpreferredencoding(False), + ) + except Exception as e: + raise AafigError from e finally: if f is not None: f.close() - return relfn, outfn, id, extra + return relfn, outfn, None, extra except AafigError: pass @@ -194,23 +218,24 @@ def render_aafigure(app, text, options): (visitor, output) = aafigure.render(text, outfn, options) output.close() except aafigure.UnsupportedFormatError as e: - raise AafigError(str(e)) + raise AafigError(str(e)) from e extra = None if options["format"].lower() == "svg": extra = visitor.get_size_attrs() - f = open(metadata_fname, "w") - f.write(extra) - f.close() + pathlib.Path(metadata_fname).write_text( + extra, + encoding=locale.getpreferredencoding(False), + ) - return relfn, outfn, id, extra + return relfn, outfn, None, extra -def setup(app): +def setup(app: Sphinx) -> None: app.add_directive("aafig", AafigDirective) app.connect("doctree-read", render_aafig_images) app.add_config_value("aafig_format", DEFAULT_FORMATS, "html") - app.add_config_value("aafig_default_options", dict(), "html") + app.add_config_value("aafig_default_options", {}, "html") # vim: set expandtab shiftwidth=4 softtabstop=4 : diff --git a/docs/_ext/conftest.py b/docs/_ext/conftest.py new file mode 100644 index 0000000000..d02630946d --- /dev/null +++ b/docs/_ext/conftest.py @@ -0,0 +1,15 @@ +"""Pytest configuration for docs/_ext doctests. + +This module sets up sys.path so that sphinx_argparse_neo and other extension +modules can be imported correctly during pytest doctest collection. +""" + +from __future__ import annotations + +import pathlib +import sys + +# Add docs/_ext to sys.path so sphinx_argparse_neo can import itself +_ext_dir = pathlib.Path(__file__).parent +if str(_ext_dir) not in sys.path: + sys.path.insert(0, str(_ext_dir)) diff --git a/docs/_ext/tmux_layout.py b/docs/_ext/tmux_layout.py new file mode 100644 index 0000000000..1f74fc181a --- /dev/null +++ b/docs/_ext/tmux_layout.py @@ -0,0 +1,393 @@ +"""Render tmux window layouts as inline SVG that looks like a terminal. + +A ``tmux-layout`` directive declares a screen size and a tmux layout; the panes +tile the screen with no gaps, exactly the way tmux splits a window. Each pane's +shell commands are drawn top-left in a fixed-width font and syntax-highlighted +with Pygments, so the diagram reads like the real terminal. This is the +spiritual successor to the old ``aafig`` ASCII-art boxes, but tmux-aware. + +Authoring — panes are separated by a ``---`` line, each holding that pane's +commands:: + + :::{tmux-layout} + :size: 64x18 + :layout: even-vertical + + echo 'did you know' + echo 'you can inline' + --- + echo 'single commands' + --- + echo 'for panes' + ::: + +``:layout:`` is one of tmux's arrangements: ``even-vertical`` (default), +``even-horizontal``, ``main-vertical``, ``main-horizontal``, ``tiled``. + +Output is a single inline ```` whose colours are CSS custom properties +(``gp-tmux-layout.css``), so it paints with the page, adapts to light/dark via +``body[data-theme]`` with no JavaScript and no second render, and rides +gp-sphinx SPA swaps as live DOM — without the headless-browser dependency the +mermaid pipeline needs. +""" + +from __future__ import annotations + +import html +import itertools +import typing as t + +from docutils import nodes +from pygments.lexers.shell import BashLexer +from pygments.token import STANDARD_TYPES +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.writers.html5 import HTML5Translator + +logger = logging.getLogger(__name__) + +#: Pixels per tmux character cell (column width, row height). The viewBox is in +#: these pixels, so at the SVG's intrinsic size one unit is one CSS pixel and +#: the text renders at the page's code font size, like a real code block. +_CW = 8.0 +_CH = 17.0 +#: Padding (px) between a pane edge and its text. +_PAD = 6.0 +#: Approx. font ascent (px) for placing the first baseline below the padding. +_FONT = 12.0 +#: Baseline-to-baseline distance (px) for stacked command lines. +_LINE_H = 17.0 +#: A simple shell prompt prefixed to each command line. The ``gp`` class is +#: Pygments' Generic.Prompt, so it takes the theme's prompt colour (like the +#: ``$`` in the site's console blocks). +_PROMPT = '' # noqa: RUF001 + +_LAYOUTS = ( + "even-vertical", + "even-horizontal", + "main-vertical", + "main-horizontal", + "tiled", +) +_LEXER = BashLexer() +_SVG_IDS = itertools.count(1) + + +class TmuxLayoutError(ValueError): + """A tmux-layout directive could not be rendered.""" + + +class Rect(t.NamedTuple): + """A pane rectangle in character-cell coordinates.""" + + x: float + y: float + w: float + h: float + + +def _even(n: int, w: float, h: float, *, vertical: bool) -> list[Rect]: + """Split the screen into ``n`` equal panes, filling it completely. + + >>> [round(r.h, 2) for r in _even(2, 80, 24, vertical=True)] + [12.0, 12.0] + >>> [round(r.w, 2) for r in _even(2, 80, 24, vertical=False)] + [40.0, 40.0] + """ + rects = [] + for i in range(n): + if vertical: + y0, y1 = i * h / n, (i + 1) * h / n + rects.append(Rect(0.0, y0, w, y1 - y0)) + else: + x0, x1 = i * w / n, (i + 1) * w / n + rects.append(Rect(x0, 0.0, x1 - x0, h)) + return rects + + +def _main( + n: int, w: float, h: float, *, vertical: bool, ratio: float = 0.5 +) -> list[Rect]: + """One main pane plus the rest split evenly beside/below it. + + >>> r = _main(3, 80, 24, vertical=True) + >>> (round(r[0].w), round(r[1].x), len(r)) + (40, 40, 3) + """ + if n == 1: + return [Rect(0.0, 0.0, w, h)] + if vertical: + mw = w * ratio + rects = [Rect(0.0, 0.0, mw, h)] + rects += [ + Rect(mw + r.x, r.y, r.w, r.h) + for r in _even(n - 1, w - mw, h, vertical=True) + ] + else: + mh = h * ratio + rects = [Rect(0.0, 0.0, w, mh)] + rects += [ + Rect(r.x, mh + r.y, r.w, r.h) + for r in _even(n - 1, w, h - mh, vertical=False) + ] + return rects + + +def _tiled(n: int, w: float, h: float) -> list[Rect]: + """Tile ``n`` panes into a grid that fills the screen (tmux ``tiled``). + + >>> len(_tiled(4, 80, 24)), round(_tiled(4, 80, 24)[0].w) + (4, 40) + """ + rows = cols = 1 + while rows * cols < n: + rows += 1 + if rows * cols < n: + cols += 1 + rects = [] + for i in range(n): + r, c = divmod(i, cols) + in_row = cols if r < rows - 1 else n - cols * (rows - 1) + x0, x1 = c * w / in_row, (c + 1) * w / in_row + y0, y1 = r * h / rows, (r + 1) * h / rows + rects.append(Rect(x0, y0, x1 - x0, y1 - y0)) + return rects + + +def arrange(layout: str, n: int, w: int, h: int) -> list[Rect]: + """Return ``n`` pane rectangles filling a ``w`` x ``h`` screen. + + >>> [round(r.h) for r in arrange("even-vertical", 3, 80, 24)] + [8, 8, 8] + >>> len(arrange("tiled", 4, 80, 24)) + 4 + """ + if n < 1: + msg = "a tmux layout needs at least one pane" + raise TmuxLayoutError(msg) + if n == 1: + return [Rect(0.0, 0.0, float(w), float(h))] + if layout == "even-vertical": + return _even(n, w, h, vertical=True) + if layout == "even-horizontal": + return _even(n, w, h, vertical=False) + if layout == "main-vertical": + return _main(n, w, h, vertical=True) + if layout == "main-horizontal": + return _main(n, w, h, vertical=False) + if layout == "tiled": + return _tiled(n, w, h) + msg = f"unknown layout {layout!r}; expected one of {', '.join(_LAYOUTS)}" + raise TmuxLayoutError(msg) + + +def _highlight(line: str) -> str: + """Return a shell command as Pygments-classed ````s. + + The command name (a shell builtin like ``echo``/``pwd``) is left in the + default text colour, like a freshly typed command; strings, comments, and + the like keep their Pygments classes. + + >>> 'class="nb"' in _highlight("echo hello") + False + >>> 'class="s1"' in _highlight("echo 'hi'") + True + >>> "'hi'" in _highlight("echo 'hi'") + True + """ + spans = [] + for token, value in _LEXER.get_tokens(line): + text = value.rstrip("\n") + if not text: + continue + css = STANDARD_TYPES.get(token, "") + if css == "nb": # command builtins (echo, pwd) keep the default colour + css = "" + escaped = html.escape(text) + spans.append(f'{escaped}' if css else escaped) + return "".join(spans) + + +def _clip_id(prefix: str, index: int) -> str: + """Return a clip-path id for one pane in a rendered SVG. + + >>> _clip_id("tmux-layout-1", 0) + 'tmux-layout-1-clip-0' + """ + return f"{prefix}-clip-{index}" + + +def _parse_size(size: str) -> tuple[int, int]: + """Parse a ``WxH`` screen size. + + >>> _parse_size("64x18") + (64, 18) + """ + try: + w_str, h_str = size.lower().split("x", 1) + return int(w_str), int(h_str) + except ValueError as exc: + msg = f"invalid size {size!r}; expected WxH (e.g. 80x24)" + raise TmuxLayoutError(msg) from exc + + +def render_layout(panes: list[list[str]], layout: str, size: tuple[int, int]) -> str: + """Render panes (each a list of command lines) to an inline ````. + + >>> svg = render_layout([["echo a"], ["echo b"]], "even-vertical", (40, 12)) + >>> svg.startswith("', + ] + parts.append("") + for index, rect in enumerate(rects): + clip_x = rect.x * _CW + 1 + clip_y = rect.y * _CH + 1 + clip_w = max(rect.w * _CW - 2, 1.0) + clip_h = max(rect.h * _CH - 2, 1.0) + parts.append( + f'' + f'', + ) + parts.append("") + # Pane fills first; the window border and single dividers go on top so a + # shared edge is one line, not two abutting pane strokes. + parts.extend( + f'' + for rect in rects + ) + for rect in rects: + right, bottom = rect.x + rect.w, rect.y + rect.h + if right < w: # internal vertical divider + parts.append( + f'', + ) + if bottom < h: # internal horizontal divider + parts.append( + f'', + ) + parts.append( + f'', + ) + # Pygments colours come from furo's own `.highlight` rules (theme-matched, + # light/dark) via `fill: currentColor`; a ignores its background. + parts.append('') + for index, (rect, commands) in enumerate(zip(rects, panes, strict=True)): + tx = rect.x * _CW + _PAD + baseline = rect.y * _CH + _PAD + _FONT + clip_path = _clip_id(svg_id, index) + for i, line in enumerate(commands): + body = _highlight(line) + if not body: + continue + ty = baseline + i * _LINE_H + parts.append( + f'{_PROMPT}{body}', + ) + parts.append("") + return "".join(parts) + + +def _split_panes(content: list[str]) -> list[list[str]]: + """Split directive content on ``---`` lines into per-pane command lists. + + >>> _split_panes(["echo a", "echo b", "---", "echo c"]) + [['echo a', 'echo b'], ['echo c']] + """ + panes: list[list[str]] = [[]] + for line in content: + if line.strip() == "---": + panes.append([]) + else: + panes[-1].append(line.rstrip()) + return [ + [line for line in pane if line.strip()] + for pane in panes + if any(p.strip() for p in pane) + ] + + +class tmux_layout(nodes.General, nodes.Element): + """Doctree node carrying a rendered tmux-layout SVG.""" + + +class TmuxLayoutDirective(SphinxDirective): + """Render a declared tmux layout as an inline, terminal-styled SVG.""" + + has_content = True + required_arguments = 0 + optional_arguments = 0 + option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "size": lambda x: x, + "layout": lambda x: x, + "caption": lambda x: x, + } + + def run(self) -> list[nodes.Node]: + """Return a :class:`tmux_layout` node with the rendered SVG.""" + panes = _split_panes(list(self.content)) + if not panes: + warning = self.state.document.reporter.warning( + "tmux-layout directive has no panes", + line=self.lineno, + ) + return [warning] + layout = self.options.get("layout", "even-vertical") + try: + svg = render_layout( + panes, layout, _parse_size(self.options.get("size", "80x24")) + ) + except TmuxLayoutError as exc: + logger.warning("invalid tmux layout: %s", exc, location=self.get_location()) + return [ + nodes.literal_block("\n".join(self.content), "\n".join(self.content)) + ] + node = tmux_layout() + node["svg"] = svg + node["caption"] = self.options.get("caption", "") + return [node] + + +def html_visit_tmux_layout(self: HTML5Translator, node: tmux_layout) -> None: + """Append the inline SVG (wrapped in a figure) and skip the node.""" + caption = node.get("caption", "") + parts = [f'
{node["svg"]}'] + if caption: + parts.append(f"
{html.escape(caption)}
") + parts.append("
") + self.body.append("".join(parts)) + raise nodes.SkipNode + + +def _depart_tmux_layout(self: HTML5Translator, node: tmux_layout) -> None: + """No-op; :func:`html_visit_tmux_layout` raises ``SkipNode``.""" + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the directive, node, and stylesheet.""" + app.add_node(tmux_layout, html=(html_visit_tmux_layout, _depart_tmux_layout)) + app.add_directive("tmux-layout", TmuxLayoutDirective) + app.add_css_file("css/gp-tmux-layout.css") + return { + "version": "0.1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css deleted file mode 100644 index 8f5cc76cc6..0000000000 --- a/docs/_static/css/custom.css +++ /dev/null @@ -1,37 +0,0 @@ -h2 { - margin-bottom: 1.25rem; - margin-top: 1.25rem; - scroll-margin-top: 0.5rem; -} - -h3 { - margin-bottom: 1.25rem; - margin-top: 1.25rem; - scroll-margin-top: 0.5rem; -} - -h4 { - margin-bottom: 1.25rem; - scroll-margin-top: 0.5rem; -} - -.sidebar-tree p.indented-block { - padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 var(--sidebar-item-spacing-horizontal); - margin-bottom: 0; -} - -.sidebar-tree p.indented-block span.indent { - margin-left: var(--sidebar-item-spacing-horizontal); - display: block; -} - -.sidebar-tree p.indented-block > :not(.project-name) { - font-size: var(--toc-font-size); -} - -.sidebar-tree p.indented-block .project-name { - font-size: var(--sidebar-item-font-size); - font-weight: bold; - margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); -} - diff --git a/docs/_static/css/gp-tmux-layout.css b/docs/_static/css/gp-tmux-layout.css new file mode 100644 index 0000000000..8ed400bfeb --- /dev/null +++ b/docs/_static/css/gp-tmux-layout.css @@ -0,0 +1,73 @@ +/* + * tmux window layouts (docs/_ext/tmux_layout.py). + * + * Plain inline SVG: it inherits furo's CSS custom properties and adapts to + * light/dark via body[data-theme] with no JavaScript and no second render. + * + * Shell highlighting reuses furo's own Pygments rules: the command text is + * wrapped in , so `.highlight .nb`, `.highlight .gp` + * (the prompt), etc. apply, and SVG takes its colour from `color` via + * `fill: currentColor` — so the panes match the site's console blocks exactly, + * colours in both light and dark. The SVG itself owns the command text size + * because compact tiled panes need predictable cell math. + */ + +.gp-tmux-layout-wrapper { + margin: 1.5rem 0; + text-align: center; +} + +.gp-tmux-layout { + max-width: min(100%, 480px); + height: auto; + display: block; + margin: 0 auto; +} + +/* + * Pane background mirrors the Pygments code-block background, with the same + * light/dark switching furo uses for `.highlight` (pygments_style and + * pygments_dark_style backgrounds). + */ +.gp-tmux-layout .pane { + fill: #f8fafc; +} + +body[data-theme="dark"] .gp-tmux-layout .pane { + fill: #272822; +} + +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) .gp-tmux-layout .pane { + fill: #272822; + } +} + +/* Single-stroke window border and pane dividers. */ +.gp-tmux-layout .window { + fill: none; + stroke: var(--color-background-border, #cccccc); + stroke-width: 1; +} + +.gp-tmux-layout .divider { + stroke: var(--color-background-border, #cccccc); + stroke-width: 1; +} + +.gp-tmux-layout text { + font-family: var(--font-stack--monospace, "SFMono-Regular", Menlo, monospace); + font-size: 12px; + /* Match the site's code blocks (pre): even monospace advance, no kerning. */ + font-kerning: none; + text-rendering: optimizeSpeed; + letter-spacing: normal; + /* Colour comes from furo's .highlight / .highlight .nb / .gp rules. */ + fill: currentColor; +} + +.gp-tmux-layout-wrapper > figcaption { + text-align: center; + font-size: 0.9em; + margin-top: 0.5rem; +} diff --git a/docs/_static/img/icons/icon-128x128.png b/docs/_static/img/icons/icon-128x128.png index 5c1868fbe8..9b2a2ada23 100644 Binary files a/docs/_static/img/icons/icon-128x128.png and b/docs/_static/img/icons/icon-128x128.png differ diff --git a/docs/_static/img/icons/icon-144x144.png b/docs/_static/img/icons/icon-144x144.png index 9ddcefd721..f1012985ef 100644 Binary files a/docs/_static/img/icons/icon-144x144.png and b/docs/_static/img/icons/icon-144x144.png differ diff --git a/docs/_static/img/icons/icon-152x152.png b/docs/_static/img/icons/icon-152x152.png index 2950bed969..be9154a329 100644 Binary files a/docs/_static/img/icons/icon-152x152.png and b/docs/_static/img/icons/icon-152x152.png differ diff --git a/docs/_static/img/icons/icon-192x192.png b/docs/_static/img/icons/icon-192x192.png index d2cf205cc3..9f0ed6e500 100644 Binary files a/docs/_static/img/icons/icon-192x192.png and b/docs/_static/img/icons/icon-192x192.png differ diff --git a/docs/_static/img/icons/icon-384x384.png b/docs/_static/img/icons/icon-384x384.png index cb5df94ab5..b901c6922e 100644 Binary files a/docs/_static/img/icons/icon-384x384.png and b/docs/_static/img/icons/icon-384x384.png differ diff --git a/docs/_static/img/icons/icon-512x512.png b/docs/_static/img/icons/icon-512x512.png index 69f786ab2f..567e71d5fb 100644 Binary files a/docs/_static/img/icons/icon-512x512.png and b/docs/_static/img/icons/icon-512x512.png differ diff --git a/docs/_static/img/icons/icon-72x72.png b/docs/_static/img/icons/icon-72x72.png index 656a708cdb..df99774366 100644 Binary files a/docs/_static/img/icons/icon-72x72.png and b/docs/_static/img/icons/icon-72x72.png differ diff --git a/docs/_static/img/icons/icon-96x96.png b/docs/_static/img/icons/icon-96x96.png index 57fe070871..f421a709af 100644 Binary files a/docs/_static/img/icons/icon-96x96.png and b/docs/_static/img/icons/icon-96x96.png differ diff --git a/docs/_static/img/tmuxp.svg b/docs/_static/img/tmuxp.svg index be5a1531af..12bf640f7f 100644 --- a/docs/_static/img/tmuxp.svg +++ b/docs/_static/img/tmuxp.svg @@ -1,10 +1,14 @@ + + + tmuxp + inkscape:current-layer="layer2" + inkscape:snap-global="true" + inkscape:snap-page="true" + inkscape:snap-bbox="true" + inkscape:bbox-paths="true" + inkscape:deskcolor="#505050" /> + id="defs2"> + id="style12147">.cls-1{fill:#8db1d1;}.cls-2{fill:#3771a2;}.cls-3{fill:#ffcd23;}.cls-4{fill:#231f20;}.cls-5{fill:#fff;fill-rule:evenodd;opacity:0.23;} + + + + + + + + + + + + - tmuxp + inkscape:groupmode="layer" + id="layer2" + inkscape:label="Libtmux" + style="display:inline" + transform="translate(-210.28959,-409.98108)"> + + Bottom bar shadow object (Shape) + - + id="Blue-Square-group-object-Shape" + inkscape:label="Blue-Square-group-object-Shape" + transform="matrix(1.024,0,0,1.024,-5.0469422,-9.8395302)"> + Blue Square Group object (Shape) + id="Blue-Square-shadow-object-Shape" + style="fill:#224665;fill-opacity:1;stroke-width:5.79024" + class="cls-2" + inkscape:label="Blue-Square-shadow-object-Shape" + d="M 227.78906,0 213.98242,3.6992188 V 460.62109 H 241.4082 V 15.564453 Z" + transform="matrix(0.26458333,0,0,0.26458333,210.28959,409.98108)"> + Blue Square shadow object (Shape) +
+ d="m 210.28981,417.13553 v 113.86425 l 60.26885,2.60089 V 409.98109 h -53.11441 c -3.95129,0 -7.15444,3.20315 -7.15444,7.15444 z" + id="Blue-Square-object-Shape" + sodipodi:nodetypes="cccccc" + style="stroke-width:1.532" + inkscape:label="Blue-Square-object-Shape"> + Blue Square object (Shape) + + + + Yellow Square Group object (Shape) + + Yellow Square Shadow object (Shape) + + d="m 278.70874,475.07917 v 64.8875 l 60.26883,-8.98455 v -55.90295 z" + inkscape:label="Yellow-Square-object-Shape" + sodipodi:nodetypes="ccccc"> + Yellow Square object (Shape) + + + + Bottom bar object (Shape) + + + Light Blue Square Group object (Shape) + id="Light-Blue-Square-Shadow-1-object-Shape" + style="fill:#5d90bd;fill-opacity:1;stroke-width:5.79024" + class="cls-1" + inkscape:label="Light-Blue-Square-Shadow-1-object-Shape" + d="m 459.33984,0 -25.33593,2.1074219 h 21.38281 c 14.93397,0 27.04101,12.1070061 27.04101,27.0410161 l 1.51727,183.649512 2.43542,2.38046 -1.84468,1.91057 15.46424,13.65284 L 500,42.603516 C 499.99913,8.8176763 474.27408,-6.8031496e-4 459.33984,0 Z" + transform="matrix(0.26458333,0,0,0.26458333,210.28959,409.98108)" + sodipodi:nodetypes="ccsscccccc"> + Light Blue Square Shadow 1 object (Shape) + + + Light Blue Square object (Shape) + + + Light Blue Square Shadow 2 object (Shape) + + + + Gear object (Group) + id="metadata840"> diff --git a/docs/_templates/book.html b/docs/_templates/book.html index dd093cd2b0..08e43b88b9 100644 --- a/docs/_templates/book.html +++ b/docs/_templates/book.html @@ -4,4 +4,4 @@

The book!

The Tao of tmux is available on Leanpub and Kindle (Amazon).

Read and browse the book for free on the web.

-Amazon Kindle +Amazon Kindle diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html deleted file mode 100644 index 2943238cf7..0000000000 --- a/docs/_templates/layout.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "!layout.html" %} -{%- block extrahead %} - {{ super() }} - {%- if theme_show_meta_manifest_tag == true %} - - {% endif -%} - {%- if theme_show_meta_og_tags == true %} - - - - - - - - - - - - - - - - {% endif -%} - {%- if theme_show_meta_app_icon_tags == true %} - - - - - - - - - - - - - - - - - - - - {% endif -%} -{% endblock %} diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html deleted file mode 100644 index 7e9d6780de..0000000000 --- a/docs/_templates/sidebar/projects.html +++ /dev/null @@ -1,31 +0,0 @@ - diff --git a/docs/about.md b/docs/about.md deleted file mode 100644 index 74d770e375..0000000000 --- a/docs/about.md +++ /dev/null @@ -1,105 +0,0 @@ -```{module} tmuxp - -``` - -(about)= - -# About - -tmuxp helps you manage tmux workspaces. - -Built on a object relational mapper for tmux. tmux users can reload common -workspaces from YAML, JSON and {py:obj}`dict` configurations like -[tmuxinator][tmuxinator] and [teamocil][teamocil]. - -tmuxp is used by developers for tmux automation at great companies like -[Bugsnag][bugsnag], [Pragmatic Coders][pragmatic coders] and many others. - -To jump right in, see {ref}`quickstart` and {ref}`examples`. - -Interested in some kung-fu or joining the effort? {ref}`api` and -{ref}`developing`. - -[MIT-licensed][mit-licensed]. Code on [github](http://github.com/tmux-python/tmuxp). - -[bugsnag]: https://blog.bugsnag.com/benefits-of-using-tmux/ -[pragmatic coders]: http://pragmaticcoders.com/blog/tmuxp-preconfigured-sessions/ - -## Compared to tmuxinator / teamocil - -### Similarities - -**Load sessions** Loads tmux sessions from config - -**YAML** Supports YAML format - -**Inlining / shorthand configuration** All three support short-hand and -simplified markup for panes that have one command. - -**Maturity and stability** As of 2016, all three are considered stable, -well tested and adopted. - -### Missing - -**Version support** tmuxp only supports `tmux >= 1.8`. Teamocil and -tmuxinator may have support for earlier versions. - -### Differences - -**Programming Language** python. teamocil and tmuxinator uses ruby. - -**Workspace building process** teamocil and tmuxinator process configs -directly shell commands. tmuxp processes configuration via ORM layer. - -## Additional Features - -**CLI** tmuxp's CLI can attach and kill sessions with tab-completion -support. See {ref}`commands`. - -**Import config** import configs from Teamocil / Tmuxinator [^id4]. See -{ref}`cli-import`. - -**Session freezing** Supports session freezing into YAML and JSON -format [^id4]. See {ref}`cli-freeze`. - -**JSON config** JSON config support. See {ref}`Examples`. - -**ORM-based API** via [libtmux][libtmux] - Utilitizes tmux >= 1.8's unique ID's for -panes, windows and sessions to create an object relational view of the tmux -{class}`~libtmux.Server`, its {class}`~libtmux.Session`, -{class}`~libtmux.Window`, and {class}`~libtmux.Pane`. -See {ref}`libtmux's internals `. - -**Conversion** `$ tmuxp convert ` can convert files to and -from JSON and YAML. - -[^id4]: On freezing - - While freezing and importing sessions is a great way to save time, - tweaking will probably be required - There is no substitute to a - config made with love. - -[libtmux]: https://libtmux.git-pull.com - -## Minor tweaks - -- Unit tests against live tmux version to test statefulness of tmux - sessions, windows and panes. See {ref}`gh-actions`. -- Load + switch to new session from inside tmux. -- Resume session if config loaded. -- Pre-commands virtualenv / rvm / any other commands. -- Load config from anywhere `$ tmuxp load /full/file/path.json`. -- Load config `.tmuxp.yaml` or `.tmuxp.json` from current working - directory with `$ tmuxp load .`. -- `$ tmuxp -2`, `$ tmuxp -8` for forcing term colors a la - {term}`tmux(1)`. -- `$ tmuxp -L`, `$ tmuxp -S` for sockets and - `$ tmuxp -f` for config file. - -[attempt at 1.7 test]: https://travis-ci.org/tmux-python/tmuxp/jobs/12348263 -[kaptan]: https://github.com/emre/kaptan -[mit-licensed]: http://opensource.org/licenses/MIT -[tmuxinator]: https://github.com/aziz/tmuxinator -[teamocil]: https://github.com/remiprev/teamocil -[erb]: http://ruby-doc.org/stdlib-2.0.0/libdoc/erb/rdoc/ERB.html -[edit this page]: https://github.com/tmux-python/tmuxp/edit/master/doc/about.rst diff --git a/docs/about_tmux.md b/docs/about_tmux.md index 7cb0930c39..1eee7989e1 100644 --- a/docs/about_tmux.md +++ b/docs/about_tmux.md @@ -5,6 +5,7 @@ :::{figure} /\_static/tao-tmux-screenshot.png :scale: 60% :align: center +:loading: lazy ISC-licensed terminal multiplexer. @@ -37,29 +38,26 @@ inside the text dimension. Inside tmux you can: | ----------- | ------------------------------- | ------------------------------------- | | Multiplexer | Multi-tasking | Multiple applications simultaneously. | | Session | Desktop | Applications are visible here | -| Window | Virtual Desktop or applications | A desktop that stores it own screen | +| Window | Virtual Desktop or applications | A desktop that stores its own screen | | Pane | Application | Performs operations | -```{eval-rst} -.. aafig:: - :textual: - - +----------------------------------------------------------------+ - | +--------+--------+ +-----------------+ +-----------------+ | - | | pane | pane | | pane | | pane | | - | | | | | | | | | - | | | | | | | | | - | +--------+--------+ | | +-----------------+ | - | | pane | pane | | | | pane | | - | | | | | | | | | - | | | | | | | | | - | +--------+--------+ +-----------------+ +-----------------+ | - | | window | | window | | window | | - | \--------+--------/ \-----------------/ \-----------------/ | - +----------------------------------------------------------------+ - | session | - \----------------------------------------------------------------/ -``` +:::{mermaid} +:caption: A session holds windows; each window holds one or more panes. +:alt: tmux session containing windows and panes +:name: tmux-session-window-pane-tree +:responsive: fit + +flowchart TD + session["session"] --> w1["window"] + session --> w2["window"] + session --> w3["window"] + w1 --> p1["pane"] + w1 --> p2["pane"] + w1 --> p3["pane"] + w1 --> p4["pane"] + w3 --> p5["pane"] + w3 --> p6["pane"] +::: - 1 {term}`Server`. @@ -83,66 +81,39 @@ splitting up 1 terminal into multiple. One screen can be used to edit a file, and another may be used to `$ tail -F` a logfile. -```{eval-rst} -.. aafig:: - - +--------+--------+ - | $ bash | $ bash | - | | | - | | | - | | | - | | | - | | | - | | | - +--------+--------+ -``` +:::{tmux-layout} +:size: 40x10 +:layout: even-horizontal -```{eval-rst} -.. aafig:: - - +--------+--------+ - | $ bash | $ bash | - | | | - | | | - +--------+--------+ - | $ vim | $ bash | - | | | - | | | - +--------+--------+ -``` +bash +--- +bash +::: + +:::{tmux-layout} +:size: 40x12 +:layout: tiled + +bash +--- +bash +--- +vim +--- +bash +::: -tmux supports as manys terminals as you want. +tmux supports as many terminals as you want. -```{eval-rst} -.. aafig:: - :textual: - - +---------+---------+ - | $ bash | $ bash | - | | | - | | | /-----------------\ - +---------+---------+ --> |'switch-window 2'| - | $ bash | $ bash | \-----------------/ - | | | | - | | | | - +---------+---------+ | - | '1:sys* 2:vim' | | - +-------------------+ | - /------------------------/ - | - v - +---------+---------+ - | $ vim | - | | - | | - +-------------------+ - | $ bash | $ bash | - | | | - | | | - +-------------------+ - | '1:sys 2:vim*' | - +-------------------+ -``` +:::{mermaid} +:caption: Switch between the windows in a session. +:alt: switching from one active tmux window to another +:name: tmux-window-switching +:responsive: fit + +flowchart TD + w1["window 1 · sys (active)"] -->|"switch-window 2"| w2["window 2 · vim (active)"] +::: You can switch between the windows you create. @@ -151,57 +122,16 @@ You can switch between the windows you create. You can leave tmux and all applications running (detach), log out, make a sandwich, and re-(attach), all applications are still running! -```{eval-rst} -.. aafig:: - :textual: - - +--------+--------+ - | $ bash | $ bash | - | | | - | | | /------------\ - +--------+--------+ --> | detach | - | $ vim | $ bash | | 'Ctrl-b b' | - | | | \------------/ - | | | | - +--------+--------+ | - /------------------/ - | - v - +-----------------------+ - | $ [screen detached] | - | | - | | - | | - | | - | | - | | - +-----------------------+ - v - | - v - +-----------------------+ - | $ [screen detached] | - | $ tmux attach | - | | /------------\ - | | --> | attaching | - | | \------------/ - | | | - | | | - +-----------------------+ | - | - /---------------------------/ - | - v - +--------+--------+ - | $ bash | $ bash | - | | | - | | | - +--------+--------+ - | $ vim | $ bash | - | | | - | | | - +--------+--------+ -``` +:::{mermaid} +:caption: Detach, leave everything running, and reattach later. +:alt: detaching from and reattaching to a running tmux session +:name: tmux-detach-reattach-cycle +:responsive: fit + +flowchart TD + running["session running"] -->|"detach · Prefix d"| detached["screen detached — apps keep running"] + detached -->|"tmux attach"| running +::: ### Manage workflow @@ -209,7 +139,7 @@ a sandwich, and re-(attach), all applications are still running! - Programmers like to have an editor open with a CLI nearby. Applications running on a remote server can be launched inside of a tmux -session, detached, and reattached next timeyour ["train of +session, detached, and reattached next time your ["train of thought"](http://en.wikipedia.org/wiki/Train_of_thought) and work. Multitasking. Preserving the thinking you have. @@ -222,11 +152,10 @@ For the freshest results on how to get tmux installed on your system, "How to install tmux on \" will do, as directions change and are slightly different between distributions. -This documentation is written for version **1.8**. It's important that -you have the latest stable release of tmux. The latest stable version is -viewable on the [tmux homepage](http://tmux.sourceforge.net/). +tmuxp requires tmux **3.2 or newer**. The latest stable version is +viewable on the [tmux homepage](https://github.com/tmux/tmux). -**Mac OS X** users may install that latest stable version of tmux +**Mac OS X** users may install the latest stable version of tmux through [MacPorts](http://www.macports.org/), [fink](http://fink.thetis.ig42.org/) or [Homebrew](http://www.brew.sh) (aka brew). @@ -254,10 +183,10 @@ This can generate the error "failed to connect to server". This could be because: -- tmux server has killed its' last session, killing the server. +- tmux server has killed its last session, killing the server. - tmux server has encountered a crash. (tmux is highly stable, this will rarely happen) -- tmux has not be launched yet at all. +- tmux has not been launched yet at all. ::: @@ -270,7 +199,7 @@ carefully**, then try it yourself. First, you press the _prefix_ key. This is `C-b` by default. -Release. Then pause. For less than second. Then type what's next. +Release. Then pause. For less than a second. Then type what's next. `C-b o` means: Press `Ctrl` and `b` at the same time. Release, Then press `o`. @@ -456,7 +385,7 @@ Tmux windows can be split into multiple panes. ## Configuring tmux -Tmux can be configured via a configuration at `~/.tmux.conf`. +Tmux can be configured via a `tmux(1)` configuration at `~/.tmux.conf`. Depending on your tmux version, there is different options available. @@ -573,7 +502,7 @@ line. The interval is determined by the `status-interval`: | `C-Up, C-Down` `C-Left, C-Right` | Resize the current pane in steps of one cell. | | `M-Up, M-Down` `M-Left, M-Right` | Resize the current pane in steps of five cells. | -Source: tmux manpage[1]. +Source: tmux manpage[^id2]. To get the text documentation of a `.1` manual file: diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 301a2e69bd..0000000000 --- a/docs/api.md +++ /dev/null @@ -1,144 +0,0 @@ -(api)= - -# API Reference - -:::{seealso} -See {ref}`libtmux's API ` and {ref}`Quickstart ` to see how you can control -tmux via python API calls. -::: - -## Internals - -:::{warning} -These APIs are purely internal not covered by versioning policies, they can and will break between versions. -If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). -::: - -```{eval-rst} -.. automethod:: tmuxp.util.run_before_script -``` - -```{eval-rst} -.. automethod:: tmuxp.util.oh_my_zsh_auto_title -``` - -```{eval-rst} -.. automethod:: tmuxp.util.raise_if_tmux_not_running -``` - -```{eval-rst} -.. automethod:: tmuxp.util.get_current_pane -``` - -```{eval-rst} -.. automethod:: tmuxp.util.get_session -``` - -```{eval-rst} -.. automethod:: tmuxp.util.get_window -``` - -```{eval-rst} -.. automethod:: tmuxp.util.get_pane -``` - -## CLI - -```{eval-rst} -.. automethod:: tmuxp.cli.utils.get_config_dir -``` - -```{eval-rst} -.. automethod:: tmuxp.cli.utils._validate_choices -``` - -```{eval-rst} -.. automethod:: tmuxp.cli.import_config.get_teamocil_dir -``` - -```{eval-rst} -.. automethod:: tmuxp.cli.import_config.get_tmuxinator_dir -``` - -```{eval-rst} -.. automethod:: tmuxp.cli.load.load_workspace -``` - -```{eval-rst} -.. automethod:: tmuxp.cli.load._reattach -``` - -## Configuration - -### Finding - -```{eval-rst} -.. automethod:: tmuxp.config.is_config_file -``` - -```{eval-rst} -.. automethod:: tmuxp.config.in_dir -``` - -```{eval-rst} -.. automethod:: tmuxp.config.in_cwd -``` - -### Import and export - -```{eval-rst} -.. automethod:: tmuxp.config.validate_schema -``` - -```{eval-rst} -.. automethod:: tmuxp.config.expandshell -``` - -```{eval-rst} -.. automethod:: tmuxp.config.expand -``` - -```{eval-rst} -.. automethod:: tmuxp.config.inline -``` - -```{eval-rst} -.. automethod:: tmuxp.config.trickle -``` - -```{eval-rst} -.. automethod:: tmuxp.config.import_teamocil -``` - -```{eval-rst} -.. automethod:: tmuxp.config.import_tmuxinator -``` - -## Workspace Builder - -```{eval-rst} -.. autoclass:: tmuxp.workspacebuilder.WorkspaceBuilder - :members: -``` - -```{eval-rst} -.. automethod:: tmuxp.workspacebuilder.freeze -``` - -## Exceptions - -```{eval-rst} -.. autoexception:: tmuxp.exc.EmptyConfigException -``` - -```{eval-rst} -.. autoexception:: tmuxp.exc.ConfigError -``` - -```{eval-rst} -.. autoexception:: tmuxp.exc.BeforeLoadScriptError -``` - -```{eval-rst} -.. autoexception:: tmuxp.exc.BeforeLoadScriptNotExists -``` diff --git a/docs/cli/completion.md b/docs/cli/completion.md index b25e799634..4d52c74938 100644 --- a/docs/cli/completion.md +++ b/docs/cli/completion.md @@ -1,11 +1,75 @@ (completion)= -# Completion +(completions)= + +(cli-completions)= + +# Completions + +Shell completion teaches your shell to finish `tmuxp` subcommands and flags when +you press Tab. It's optional, and how you set it up depends on your tmuxp +version — 1.17 and newer use [shtab], while 1.1 through 1.16 used [click]. Pick the +section that matches yours. + +## tmuxp 1.17+ (experimental) + +```{note} +See the [shtab library's documentation on shell completion](https://docs.iterative.ai/shtab/use/#cli-usage) for the most up to date way of connecting completion for tmuxp. +``` + +Provisional support for completions in tmuxp 1.17+ are powered by [shtab]. This must be **installed separately**, as it's **not currently bundled with tmuxp**. + +```console +$ pip install shtab --user +``` + +With a [uv](https://docs.astral.sh/uv/getting-started/features/#python-versions) project you can add it directly as a development dependency: + +```console +$ uv add --dev shtab +``` + +Or reach for [uvx](https://docs.astral.sh/uv/guides/tools/) when you want a pipx-style ephemeral install: + +```console +$ uvx shtab --help +``` + +:::{tab} bash + +```console +$ shtab --shell=bash -u tmuxp.cli.create_parser \ + | sudo tee "$BASH_COMPLETION_COMPAT_DIR"/TMUXP +``` + +::: + +:::{tab} zsh + +```console +$ shtab --shell=zsh -u tmuxp.cli.create_parser \ + | sudo tee /usr/local/share/zsh/site-functions/_TMUXP +``` + +::: + +:::{tab} tcsh + +```console +$ shtab --shell=tcsh -u tmuxp.cli.create_parser \ + | sudo tee /etc/profile.d/TMUXP.completion.csh +``` + +::: + +## tmuxp 1.1 to 1.16 ```{note} -See the [click library's documentation on shell completion](https://click.palletsprojects.com/en/8.0.x/shell-completion/) for the most up to date way of connecting completion for vcspull. +See the [click library's documentation on shell completion](https://click.palletsprojects.com/en/8.0.x/shell-completion/) for the most up to date way of connecting completion for tmuxp. ``` +tmuxp 1.1 to 1.16 use [click]'s completion: + :::{tab} Bash _~/.bashrc_: @@ -29,3 +93,6 @@ eval "$(_TMUXP_COMPLETE=zsh_source tmuxp)" ``` ::: + +[shtab]: https://docs.iterative.ai/shtab/ +[click]: https://click.palletsprojects.com diff --git a/docs/cli/convert.md b/docs/cli/convert.md index 545e2ad2d1..9c0bf1a8b7 100644 --- a/docs/cli/convert.md +++ b/docs/cli/convert.md @@ -1,8 +1,20 @@ -(convert-config)= +(cli-convert)= # tmuxp convert -Convert between YAML and JSON +Convert workspace configuration files between YAML and JSON formats. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: convert +``` + +## Basic usage ````{tab} YAML -> JSON @@ -22,12 +34,3 @@ $ tmuxp convert /path/to/file.json tmuxp automatically will prompt to convert `.yaml` to `.json` and `.json` to `.yaml`. - -## Reference - -```{eval-rst} -.. click:: tmuxp.cli.convert:command_convert - :prog: tmuxp convert - :commands: convert - :nested: full -``` diff --git a/docs/cli/debug-info.md b/docs/cli/debug-info.md index cd8298d8c8..bd3d76c6cc 100644 --- a/docs/cli/debug-info.md +++ b/docs/cli/debug-info.md @@ -4,8 +4,19 @@ # tmuxp debug-info -Use to collect all relevant information for submitting an issue to -the project. +Collect and display system information useful for debugging tmuxp issues and submitting bug reports. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: debug-info +``` + +## Example output ```console @@ -17,12 +28,3 @@ environment: ... ``` - -## Reference - -```{eval-rst} -.. click:: tmuxp.cli.debug_info:command_debug_info - :prog: tmuxp debug-info - :commands: debug-info - :nested: full -``` diff --git a/docs/cli/edit.md b/docs/cli/edit.md index 89735f6455..4e84ca56b9 100644 --- a/docs/cli/edit.md +++ b/docs/cli/edit.md @@ -1,10 +1,17 @@ (edit-config)= +(cli-edit)= + # tmuxp edit +Open a workspace configuration file in your default editor for quick modifications. + +## Command + ```{eval-rst} -.. click:: tmuxp.cli.edit:command_edit - :prog: tmuxp edit - :commands: edit - :nested: full +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: edit ``` diff --git a/docs/cli/exit-codes.md b/docs/cli/exit-codes.md new file mode 100644 index 0000000000..42486e21cf --- /dev/null +++ b/docs/cli/exit-codes.md @@ -0,0 +1,35 @@ +(cli-exit-codes)= + +# Exit Codes + +tmuxp uses standard exit codes for scripting and automation. + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | General error (config validation, tmux command failure) | +| `2` | Usage error (invalid arguments, missing required options) | + +## Usage in scripts + +Because the exit code is meaningful, a script can check it and react. Test it +explicitly with `$?`: + +```bash +#!/bin/bash +tmuxp load my-workspace.yaml +if [ $? -ne 0 ]; then + echo "Failed to load workspace" + exit 1 +fi +``` + +Or short-circuit with `||` to handle the failure inline: + +```bash +#!/bin/bash +tmuxp load -d my-workspace.yaml || { + echo "tmuxp failed with exit code $?" + exit 1 +} +``` diff --git a/docs/cli/freeze.md b/docs/cli/freeze.md index 414ac8e869..6f3f8c55ee 100644 --- a/docs/cli/freeze.md +++ b/docs/cli/freeze.md @@ -1,34 +1,45 @@ (cli-freeze)= +(cli-freeze-reference)= + # tmuxp freeze -Freeze sessions +Export a running tmux session to a workspace configuration file. This allows you to save the current state of your tmux session for later restoration. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: freeze +``` + +## Basic usage + +Freeze the current session: ```console $ tmuxp freeze ``` +Freeze a specific session by name: + ```console $ tmuxp freeze [session_name] ``` +Overwrite an existing workspace file: + ```console $ tmuxp freeze --force [session_name] ``` -You can save the state of your tmux session by freezing it. +## Output format -Tmuxp will offer to save your session state to `.json` or `.yaml`. +tmuxp will offer to save your session state to `.json` or `.yaml`. If no session is specified, it will default to the attached session. -If the `--force` argument is passed, it will overwrite any existing config file with the same name. - -(cli-freeze-reference)= - -```{eval-rst} -.. click:: tmuxp.cli.freeze:command_freeze - :prog: tmuxp freeze - :commands: freeze - :nested: full -``` +If the `--force` argument is passed, it will overwrite any existing workspace file with the same name. diff --git a/docs/cli/import.md b/docs/cli/import.md index 4cffaed6bd..6418de3439 100644 --- a/docs/cli/import.md +++ b/docs/cli/import.md @@ -2,16 +2,27 @@ # tmuxp import +Import and convert workspace configurations from other tmux session managers like +[teamocil] and [tmuxinator]. + (import-teamocil)= ## From teamocil +Import teamocil configuration files and convert them to tmuxp format. + +### Command + ```{eval-rst} -.. click:: tmuxp.cli.import_config:command_import_teamocil - :prog: tmuxp import teamocil - :nested: full +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: import teamocil ``` +### Basic usage + ````{tab} YAML ```console @@ -32,12 +43,20 @@ $ tmuxp import teamocil /path/to/file.json ## From tmuxinator +Import tmuxinator configuration files and convert them to tmuxp format. + +### Command + ```{eval-rst} -.. click:: tmuxp.cli.import_config:command_import_tmuxinator - :prog: tmuxp import tmuxinator - :nested: short +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: import tmuxinator ``` +### Basic usage + ````{tab} YAML ```console @@ -53,3 +72,6 @@ $ tmuxp import tmuxinator /path/to/file.json ``` ```` + +[teamocil]: https://github.com/remiprev/teamocil +[tmuxinator]: https://github.com/aziz/tmuxinator diff --git a/docs/cli/index.md b/docs/cli/index.md index 0abfe3676f..fd38b681ea 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -2,7 +2,48 @@ (commands)= -# Commands +# CLI Reference + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} tmuxp load +:link: load +:link-type: doc +Load tmux sessions from workspace configs. +::: + +:::{grid-item-card} tmuxp shell +:link: shell +:link-type: doc +Interactive Python shell with tmux context. +::: + +:::{grid-item-card} tmuxp freeze +:link: freeze +:link-type: doc +Export running sessions to config files. +::: + +:::{grid-item-card} tmuxp convert +:link: convert +:link-type: doc +Convert between YAML and JSON formats. +::: + +:::{grid-item-card} Exit Codes +:link: exit-codes +:link-type: doc +Exit codes for scripting and automation. +::: + +:::{grid-item-card} Recipes +:link: recipes +:link-type: doc +Copy-pasteable command invocations. +::: + +:::: ```{toctree} :caption: General commands @@ -11,6 +52,7 @@ load shell ls +search ``` ```{toctree} @@ -21,7 +63,6 @@ edit import convert freeze - ``` ```{toctree} @@ -29,12 +70,40 @@ freeze :maxdepth: 1 debug-info - ``` ```{toctree} -:caption: More +:caption: Completion :maxdepth: 1 completion ``` + +```{toctree} +:caption: Reference +:maxdepth: 1 + +exit-codes +recipes +``` + +(cli-main)= + +(tmuxp-main)= + +## Main command + +The `tmuxp` command is the entry point for all tmuxp operations. Use subcommands to load sessions, manage configurations, and interact with tmux. + +### Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :nosubcommands: + + subparser_name : @replace + See :ref:`cli-ls` +``` diff --git a/docs/cli/load.md b/docs/cli/load.md index 59b48440dc..ce1ba88d5a 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -2,13 +2,32 @@ (tmuxp-load)= +(tmuxp-load-reference)= + # tmuxp load -You can load your tmuxp file and attach the vim session via a few +Load a tmux session from a workspace file — this is the command you reach for +most. Point it at a project directory or a YAML/JSON file and tmuxp builds the +session and attaches you to it; the flags below are for the cases where you want +something other than that default. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: load +``` + +## Basic usage + +You can load your tmuxp file and attach the tmux session via a few shorthands: 1. The directory with a `.tmuxp.{yaml,yml,json}` file in it -2. The name of the project file in your {}`$HOME/.tmuxp` folder +2. The name of the project file in your `$HOME/.tmuxp` folder 3. The direct path of the tmuxp file you want to load Path to folder with `.tmuxp.yaml`, `.tmuxp.yml`, `.tmuxp.json`: @@ -72,9 +91,9 @@ $ tmuxp load [filename] ## Inside sessions -If you try to load a config file from within a tmux session, it will ask you -if you want to load and attach to the new session, or just load detached. -You can also load a config file and append the windows to the current active session. +When you load a workspace from _inside_ an existing tmux client, tmuxp asks what +you want rather than guessing — switch to the freshly built session, append its +windows to the session you're in, or build it detached and stay put: ``` Already inside TMUX, switch to session? yes/no @@ -82,11 +101,24 @@ Or (a)ppend windows in the current active session? [y/n/a]: ``` +:::{mermaid} +:caption: Loading from inside an existing tmux client. +:alt: tmuxp load prompt choices inside an existing tmux client +:name: tmuxp-load-inside-client +:responsive: fit + +flowchart TD + load["tmuxp load"]:::cmd --> q{"y / n / a ?"} + q -->|yes| switch["build the new session, switch the client to it"] + q -->|a| append["append its windows to the current session"] + q -->|no| detached["build it detached, stay where you are"] +::: + ## Options All of these options can be preselected to skip the prompt: -- Attach / open client after load: +- Attach / open the client after load: ```console $ tmuxp load -y config @@ -137,13 +169,103 @@ $ tmuxp load [filename] --log-file [log_filename] $ tmuxp --log-level [LEVEL] load [filename] --log-file [log_filename] ``` -## Reference +## Progress display -(tmuxp-load-reference)= +When loading a workspace, tmuxp shows an animated spinner with build progress. The spinner updates as windows and panes are created, giving real-time feedback during session builds. -```{eval-rst} -.. click:: tmuxp.cli.load:command_load - :prog: tmuxp load - :commands: load - :nested: full +### Presets + +Five built-in presets control the spinner format: + +| Preset | Format | +|--------|--------| +| `default` | `Loading workspace: {session} {bar} {progress} {window}` | +| `minimal` | `Loading workspace: {session} [{window_progress}]` | +| `window` | `Loading workspace: {session} {window_bar} {window_progress_rel}` | +| `pane` | `Loading workspace: {session} {pane_bar} {session_pane_progress}` | +| `verbose` | `Loading workspace: {session} [window {window_index} of {window_total} · pane {pane_index} of {pane_total}] {window}` | + +Select a preset with `--progress-format`: + +```console +$ tmuxp load --progress-format minimal myproject ``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS_FORMAT=verbose tmuxp load myproject +``` + +### Custom format tokens + +Use a custom format string with any of the available tokens: + +| Token | Description | +|-------|-------------| +| `{session}` | Session name | +| `{window}` | Current window name | +| `{window_index}` | Current window number (1-based) | +| `{window_total}` | Total number of windows | +| `{window_progress}` | Window fraction (e.g. `1/3`) | +| `{window_progress_rel}` | Completed windows fraction (e.g. `1/3`) | +| `{windows_done}` | Number of completed windows | +| `{windows_remaining}` | Number of remaining windows | +| `{pane_index}` | Current pane number in the window | +| `{pane_total}` | Total panes in the current window | +| `{pane_progress}` | Pane fraction (e.g. `2/4`) | +| `{progress}` | Combined progress (e.g. `1/3 win · 2/4 pane`) | +| `{session_pane_progress}` | Panes completed across the session (e.g. `5/10`) | +| `{overall_percent}` | Pane-based completion percentage (0–100) | +| `{bar}` | Composite progress bar | +| `{pane_bar}` | Pane-based progress bar | +| `{window_bar}` | Window-based progress bar | +| `{status_icon}` | Status icon (⏸ during before_script) | + +Example: + +```console +$ tmuxp load --progress-format "{session} {bar} {overall_percent}%" myproject +``` + +### Panel lines + +The spinner shows script output in a panel below the spinner line. Control the panel height with `--progress-lines`: + +Hide the panel entirely (script output goes to stdout): + +```console +$ tmuxp load --progress-lines 0 myproject +``` + +Show unlimited lines (capped to terminal height): + +```console +$ tmuxp load --progress-lines -1 myproject +``` + +Set a custom height (default is 3): + +```console +$ tmuxp load --progress-lines 5 myproject +``` + +### Disabling progress + +Disable the animated spinner entirely: + +```console +$ tmuxp load --no-progress myproject +``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +When progress is disabled, logging flows normally to the terminal and no spinner is rendered. + +### Before-script behavior + +During `before_script` execution, the progress bar shows a marching animation and a ⏸ status icon, indicating that tmuxp is waiting for the script to finish before continuing with pane creation. diff --git a/docs/cli/ls.md b/docs/cli/ls.md index f46d4593f3..482b86793a 100644 --- a/docs/cli/ls.md +++ b/docs/cli/ls.md @@ -1,10 +1,17 @@ +(cli-ls)= + (ls-config)= # tmuxp ls +List available workspace configurations from your local project and global tmuxp directories. + +## Command + ```{eval-rst} -.. click:: tmuxp.cli.ls:command_ls - :prog: tmuxp ls - :commands: ls - :nested: full +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: ls ``` diff --git a/docs/cli/recipes.md b/docs/cli/recipes.md new file mode 100644 index 0000000000..91ccd02347 --- /dev/null +++ b/docs/cli/recipes.md @@ -0,0 +1,78 @@ +(cli-recipes)= + +# Recipes + +Copy-pasteable command invocations for common tasks. Each command has a full +reference — with every flag — under {ref}`cli`. + +## Load a workspace + +```console +$ tmuxp load my-workspace.yaml +``` + +## Load in detached mode + +```console +$ tmuxp load -d my-workspace.yaml +``` + +## Load from a project directory + +```console +$ tmuxp load . +``` + +## Freeze a running session + +```console +$ tmuxp freeze my-session +``` + +## Convert YAML to JSON + +```console +$ tmuxp convert my-workspace.yaml +``` + +## Convert JSON to YAML + +```console +$ tmuxp convert my-workspace.json +``` + +## List available workspaces + +```console +$ tmuxp ls +``` + +## Search workspaces + +```console +$ tmuxp search my-project +``` + +## Edit a workspace config + +```console +$ tmuxp edit my-workspace +``` + +## Collect debug info + +```console +$ tmuxp debug-info +``` + +## Shell with tmux context + +```console +$ tmuxp shell +``` + +Access [libtmux](https://libtmux.git-pull.com/) objects directly: + +```console +$ tmuxp shell --best --command 'print(server.sessions)' +``` diff --git a/docs/cli/search.md b/docs/cli/search.md new file mode 100644 index 0000000000..2521b7de28 --- /dev/null +++ b/docs/cli/search.md @@ -0,0 +1,17 @@ +(cli-search)= + +(search-config)= + +# tmuxp search + +Search for workspace configurations by name or content across your tmuxp directories. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: search +``` diff --git a/docs/cli/shell.md b/docs/cli/shell.md index 9843f9ada2..42b01bfb84 100644 --- a/docs/cli/shell.md +++ b/docs/cli/shell.md @@ -1,34 +1,30 @@ (cli-shell)= -# tmuxp shell - -```{eval-rst} -.. click:: tmuxp.cli.shell:command_shell - :prog: tmuxp shell - :commands: shell - :nested: none -``` +(tmuxp-shell)= -## Directly enter commands +# tmuxp shell -```console -$ tmuxp shell -c 'python code' -``` +Launch an interactive Python shell with [libtmux] objects pre-loaded. Like +Django's `shell` command, it hands you the current tmux server, sessions, +windows, and panes already wired up, so you can poke at a live session or +prototype a script without writing boilerplate. -## Example +## Command -```{image} ../_static/tmuxp-shell.gif -:width: 100% +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: shell ``` -## Guide - -Launch into a python console with [libtmux] objects. Compare to django's shell. +## Interactive usage -Automatically preloads current tmux {class}`server `, -{class}`session `, {class}`window ` -{class}`pane `. Pass additional arguments to select a -specific one of your choice: +Run `tmuxp shell` to drop into a Python console with the current tmux +{class}`server `, {class}`session `, +{class}`window `, and {class}`pane ` already bound. +Pass arguments to select a specific one: ```console (Pdb) server @@ -49,19 +45,22 @@ Window(@3 1:your_window, Session($1 your_project)) Pane(%6 Window(@3 1:your_window, Session($1 your_project))) ``` -Supports [PEP 553][pep 553]'s `PYTHONBREAKPOINT` and -compatible debuggers, for instance [ipdb][ipdb]: +## Running code directly + +Pass `-c` to run a snippet and exit, much like `python -c`: ```console -$ pip install --user ipdb +$ tmuxp shell -c 'python code' ``` -```console -$ env PYTHONBREAKPOINT=ipdb.set_trace tmuxp shell +```{image} ../_static/tmuxp-shell.gif +:width: 878 +:height: 109 +:loading: lazy ``` -You can also pass in python code directly, similar to `python -c`, do -this via `tmuxp -c`: +The same objects are in scope. Name a server, then a window, to narrow what the +snippet sees: ```console $ tmuxp shell -c 'print(session.name); print(window.name)' @@ -70,43 +69,60 @@ my_window ``` ```console -$ tmuxp shell my_server -c 'print(session.name); print(window.name)' -my_server -my_window +$ tmuxp shell my_server my_window -c 'print(window.name.upper())' +MY_WINDOW ``` +Inside a tmux pane — or attached to the default server — the pane is in scope +too: + ```console -$ tmuxp shell my_server my_window -c 'print(session.name); print(window.name)' -my_server +$ tmuxp shell -c 'print(pane.id); print(pane.window.name)' +%2 my_window ``` +## Debugger integration + +`tmuxp shell` supports [PEP 553][pep 553]'s `PYTHONBREAKPOINT` and compatible +debuggers, such as [ipdb][ipdb]: + ```console -$ tmuxp shell my_server my_window -c 'print(window.name.upper())' -MY_WINDOW +$ pip install --user ipdb ``` -Assuming inside a tmux pane or one is attached on default server: +Inside a [uv](https://docs.astral.sh/uv/getting-started/features/#python-versions)-managed +project, add `ipdb` as a development dependency: ```console -$ tmuxp shell -c 'print(pane.id); print(pane.window.name)' -%2 -my_window +$ uv add --dev ipdb ``` -[pep 553]: https://www.python.org/dev/peps/pep-0553/ -[ipdb]: https://pypi.org/project/ipdb/ -[libtmux]: https://libtmux.git-pull.com +For a pipx-style ad hoc install, run it through [uvx](https://docs.astral.sh/uv/guides/tools/): + +```console +$ uvx --from ipdb ipdb3 --help +``` + +```console +$ env PYTHONBREAKPOINT=ipdb.set_trace tmuxp shell +``` ## Shell detection -`tmuxp shell` detects the richest shell available in your _site packages_, you can also pick your shell via args: +`tmuxp shell` drops into the richest shell available in your _site packages_. Pick +one yourself with a flag: + +- `--pdb`: plain {func}`breakpoint` (python 3.7+) or {func}`pdb.set_trace` +- `--code`: drop into {func}`code.interact`, accepts `--use-pythonrc` +- `--bpython`: drop into [bpython] +- `--ipython`: drop into [IPython] +- `--ptpython`: drop into [ptpython], accepts `--use-vi-mode` +- `--ptipython`: drop into [IPython] + [ptpython], accepts `--use-vi-mode` -- `--pdb`: Use plain old `breakpoint()` (python 3.7+) or - `pdb.set_trace` -- `--code`: Drop into `code.interact`, accepts `--use-pythonrc` -- `--bpython`: Drop into bpython -- `--ipython`: Drop into ipython -- `--ptpython`: Drop into ptpython, accepts `--use-vi-mode` -- `--ptipython`: Drop into ipython + ptpython, accepts - `--use-vi-mode` +[pep 553]: https://www.python.org/dev/peps/pep-0553/ +[ipdb]: https://pypi.org/project/ipdb/ +[libtmux]: https://libtmux.git-pull.com +[bpython]: https://bpython-interpreter.org/ +[IPython]: https://ipython.org/ +[ptpython]: https://github.com/prompt-toolkit/ptpython diff --git a/docs/conf.py b/docs/conf.py index 305c64d207..f48447201c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,233 +1,72 @@ -# flake8: NOQA E5 -import inspect -import os -import sys -from os.path import dirname, relpath -from pathlib import Path +"""Sphinx documentation configuration for tmuxp.""" + +from __future__ import annotations -import tmuxp +import pathlib +import sys # Get the project root dir, which is the parent dir of this -cwd = Path.cwd() +cwd = pathlib.Path(__file__).parent project_root = cwd.parent +src_root = project_root / "src" -sys.path.insert(0, str(project_root)) -sys.path.insert(0, str(cwd / "_ext")) +sys.path.insert(0, str(src_root)) +sys.path.insert(0, str(cwd / "_ext")) # for local aafig extension # package data -about = {} -with open("../tmuxp/__about__.py") as fp: +about: dict[str, str] = {} +with (src_root / "tmuxp" / "__about__.py").open() as fp: exec(fp.read(), about) -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.intersphinx", - "sphinx_autodoc_typehints", - "sphinx.ext.todo", - "sphinx.ext.napoleon", - "sphinx.ext.linkcode", - "aafig", - "sphinx_issues", - "sphinx_click.ext", # sphinx-click - "sphinx_inline_tabs", - "sphinx_copybutton", - "sphinxext.opengraph", - "sphinxext.rediraffe", - "myst_parser", -] - -myst_enable_extensions = ["colon_fence", "substitution", "replacements"] - -issues_github_path = about["__github__"].replace("https://github.com/", "") - -templates_path = ["_templates"] - -source_suffix = {".rst": "restructuredtext", ".md": "markdown"} - -master_doc = "index" - -project = about["__title__"] -copyright = about["__copyright__"] - -version = "%s" % (".".join(about["__version__"].split("."))[:2]) -release = "%s" % (about["__version__"]) - -exclude_patterns = ["_build"] - -pygments_style = "monokai" -pygments_dark_style = "monokai" - -html_css_files = ["css/custom.css"] -html_extra_path = ["manifest.json"] -html_static_path = ["_static"] -html_theme = "furo" -html_theme_path = [] -html_theme_options = { - "light_logo": "img/tmuxp.svg", - "dark_logo": "img/tmuxp.svg", - "footer_icons": [ - { - "name": "GitHub", - "url": about["__github__"], - "html": """ - - - - """, - "class": "", - }, +from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config # noqa: E402 + +import tmuxp # noqa: E402 + +conf = merge_sphinx_config( + project=about["__title__"], + version=about["__version__"], + copyright=about["__copyright__"], + source_repository=f"{about['__github__']}/", + docs_url=about["__docs__"], + source_branch="master", + light_logo="img/tmuxp.svg", + dark_logo="img/tmuxp.svg", + extra_extensions=[ + "sphinx_autodoc_api_style", + "aafig", + "sphinx_gp_mermaid", + "sphinx_gp_highlighting", + "tmux_layout", + "sphinx_autodoc_argparse.exemplar", + ], + gp_highlighting_inline_literals="safe", + gp_highlighting_inline_commands=["tmuxp"], + # Route a plain ```mermaid fence to the mermaid directive (the colon and + # brace forms route there already via colon_fence). Redundant once the + # pinned gp-sphinx release auto-routes when sphinx_gp_mermaid is active; + # drop this on the next gp-sphinx bump. + myst_fence_as_directive=["mermaid"], + intersphinx_mapping={ + "python": ("https://docs.python.org/", None), + "libtmux": ("https://libtmux.git-pull.com/", None), + }, + linkcode_resolve=make_linkcode_resolve(tmuxp, about["__github__"]), + # tmuxp-specific overrides + theme_options={"mask_icon": "/_static/img/tmuxp.svg"}, + html_extra_path=["manifest.json"], + html_favicon="_static/favicon.ico", + aafig_format={"latex": "pdf", "html": "gif"}, + aafig_default_options={"scale": 0.75, "aspect": 0.5, "proportional": True}, + rediraffe_redirects="redirects.txt", + # Keep Sphinx out of non-document trees under docs/: the mermaid toolchain + # (node_modules) and its render cache, plus agent guidance (AGENTS.md and + # its CLAUDE.md symlink) which would otherwise be orphan documents. + exclude_patterns=[ + "_build", + "node_modules", + "_mermaid_cache", + "AGENTS.md", + "CLAUDE.md", ], -} -html_sidebars = { - "**": [ - "sidebar/scroll-start.html", - "sidebar/brand.html", - "sidebar/search.html", - "sidebar/navigation.html", - "sidebar/projects.html", - "sidebar/scroll-end.html", - ] -} - -# sphinxext.opengraph -ogp_site_url = about["__docs__"] -ogp_image = "_static/img/icons/icon-192x192.png" -ogp_desscription_length = about["__description__"] -ogp_site_name = about["__title__"] - -# sphinx-copybutton -copybutton_prompt_text = ( - r">>> |\.\.\. |> |\$ |\# | In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " ) -copybutton_prompt_is_regexp = True -copybutton_remove_prompts = True - -# sphinxext-rediraffe -rediraffe_redirects = "redirects.txt" -rediraffe_branch = "master~1" - -htmlhelp_basename = "%sdoc" % about["__title__"] - -latex_documents = [ - ( - "index", - "{}.tex".format(about["__package_name__"]), - "{} Documentation".format(about["__title__"]), - about["__author__"], - "manual", - ) -] - -man_pages = [ - ( - "index", - about["__package_name__"], - "{} Documentation".format(about["__title__"]), - about["__author__"], - 1, - ) -] - -texinfo_documents = [ - ( - "index", - "{}".format(about["__package_name__"]), - "{} Documentation".format(about["__title__"]), - about["__author__"], - about["__package_name__"], - about["__description__"], - "Miscellaneous", - ) -] - -intersphinx_mapping = { - "python": ("https://docs.python.org/", None), - "libtmux": ("https://libtmux.git-pull.com/", None), - "click": ("http://click.pocoo.org/5", None), -} - -# aafig format, try to get working with pdf -aafig_format = dict(latex="pdf", html="gif") - -aafig_default_options = dict(scale=0.75, aspect=0.5, proportional=True) - - -def linkcode_resolve(domain, info): # NOQA: C901 - """ - Determine the URL corresponding to Python object - - Notes - ----- - From https://github.com/numpy/numpy/blob/v1.15.1/doc/source/conf.py, 7c49cfa - on Jul 31. License BSD-3. https://github.com/numpy/numpy/blob/v1.15.1/LICENSE.txt - """ - if domain != "py": - return None - - modname = info["module"] - fullname = info["fullname"] - - submod = sys.modules.get(modname) - if submod is None: - return None - - obj = submod - for part in fullname.split("."): - try: - obj = getattr(obj, part) - except Exception: - return None - - # strip decorators, which would resolve to the source of the decorator - # possibly an upstream bug in getsourcefile, bpo-1764286 - try: - unwrap = inspect.unwrap - except AttributeError: - pass - else: - obj = unwrap(obj) - - try: - fn = inspect.getsourcefile(obj) - except Exception: - fn = None - if not fn: - return None - - try: - source, lineno = inspect.getsourcelines(obj) - except Exception: - lineno = None - - if lineno: - linespec = "#L%d-L%d" % (lineno, lineno + len(source) - 1) - else: - linespec = "" - - fn = relpath(fn, start=dirname(tmuxp.__file__)) - - if "dev" in about["__version__"]: - return "{}/blob/master/{}/{}{}".format( - about["__github__"], - about["__package_name__"], - fn, - linespec, - ) - else: - return "{}/blob/v{}/{}/{}{}".format( - about["__github__"], - about["__version__"], - about["__package_name__"], - fn, - linespec, - ) - - -def remove_tabs_js(app, exc): - if app.builder.format == "html" and not exc: - tabs_js = Path(app.builder.outdir) / "_static" / "tabs.js" - tabs_js.unlink() - - -def setup(app): - app.connect("build-finished", remove_tabs_js) +globals().update(conf) diff --git a/docs/configuration/environmental-variables.md b/docs/configuration/environmental-variables.md index d485da4574..0b3f8f6260 100644 --- a/docs/configuration/environmental-variables.md +++ b/docs/configuration/environmental-variables.md @@ -2,6 +2,13 @@ # Environmental variables +These environment variables tune how tmuxp finds your workspaces and how much it +shows you while loading — set from your shell, outside any workspace file. You +rarely need them: tmuxp works out of the box. Reach for one when you want to +point tmuxp at a different config directory, quiet or reshape the load progress +display, or work around a rare tmux quirk. The progress variables each mirror a +{ref}`tmuxp load ` flag, noted alongside the variable. + (TMUXP_CONFIGDIR)= ## `TMUXP_CONFIGDIR` @@ -14,7 +21,8 @@ Example: `TMUXP_CONFIGDIR=$HOME/.mytmuxpconfigdir tmuxp load cpython` :::{seealso} -[`LIBTMUX_TMUX_FORMAT_SEPARATOR`](https://libtmux.git-pull.com/api.html#tmux-format-separator) in libtmux API. +{ref}`LIBTMUX_TMUX_FORMAT_SEPARATOR ` +in the libtmux API. ::: @@ -24,3 +32,54 @@ building sessions. For this case you can override it here. ```console $ env LIBTMUX_TMUX_FORMAT_SEPARATOR='__SEP__' tmuxp load [session] ``` + +(TMUXP_PROGRESS)= + +## `TMUXP_PROGRESS` + +Master on/off switch for the animated progress spinner during `tmuxp load`. +Defaults to `1` (enabled). Set to `0` to disable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +Equivalent to the `--no-progress` CLI flag. + +(TMUXP_PROGRESS_FORMAT)= + +## `TMUXP_PROGRESS_FORMAT` + +Set the spinner line format. Accepts a preset name (`default`, `minimal`, `window`, `pane`, `verbose`) or a custom format string with tokens like `{session}`, `{bar}`, `{progress}`: + +```console +$ TMUXP_PROGRESS_FORMAT=minimal tmuxp load myproject +``` + +Custom format example: + +```console +$ TMUXP_PROGRESS_FORMAT="{session} {bar} {overall_percent}%" tmuxp load myproject +``` + +Equivalent to the `--progress-format` CLI flag. + +(TMUXP_PROGRESS_LINES)= + +## `TMUXP_PROGRESS_LINES` + +Number of script-output lines shown in the spinner panel. Defaults to `3`. + +Set to `0` to hide the panel entirely (script output goes to stdout): + +```console +$ TMUXP_PROGRESS_LINES=0 tmuxp load myproject +``` + +Set to `-1` for unlimited lines (capped to terminal height): + +```console +$ TMUXP_PROGRESS_LINES=-1 tmuxp load myproject +``` + +Equivalent to the `--progress-lines` CLI flag. diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 8ecb287bf5..bc3f085e47 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -2,30 +2,28 @@ # Examples -## Short hand / inline style +This is a gallery of working workspace files — each a small, complete YAML (with +its JSON twin) you can copy, load, and adapt. They run from the simplest +two-pane split up to a full development environment, and many preview the panes +they build. Skim for the feature you need — inline shorthand, blank panes, +environment variables, pane shells, focus, history, timed pauses — and lift the +snippet. -tmuxp has a short-hand syntax to for those who wish to keep their configs -punctual. +## Short hand / inline style -::::{sidebar} short hand +tmuxp has a short-hand syntax for those who like to keep a workspace concise. -```{eval-rst} -.. aafig:: - :textual: +:::{tmux-layout} +:size: 36x12 +:layout: even-vertical - +-------------------+ - | 'did you know' | - | 'you can inline' | - +-------------------+ - | 'single commands' | - | | - +-------------------+ - | 'for panes' | - | | - +-------------------+ -``` - -:::: +echo 'did you know' +echo 'you can inline' +--- +echo 'single commands' +--- +echo 'for panes' +::: ````{tab} YAML @@ -73,23 +71,14 @@ Note `''` counts as an empty carriage return. ## 2 panes -::::{sidebar} 2 pane +:::{tmux-layout} +:size: 30x10 +:layout: even-vertical -```{eval-rst} -.. aafig:: - - +-----------------+ - | $ pwd | - | | - | | - +-----------------+ - | $ pwd | - | | - | | - +-----------------+ -``` - -:::: +echo hello +--- +echo hello +::: ````{tab} YAML @@ -111,23 +100,17 @@ Note `''` counts as an empty carriage return. ## 3 panes -::::{sidebar} 3 panes - -```{eval-rst} -.. aafig:: +:::{tmux-layout} +:size: 44x12 +:layout: main-horizontal - +-----------------+ - | $ pwd | - | | - | | - +--------+--------+ - | $ pwd | $ pwd | - | | | - | | | - +--------+--------+ -``` - -:::: +cd /var/log +ls -al | grep \.log +--- +echo hello +--- +echo hello +::: ````{tab} YAML @@ -149,23 +132,19 @@ Note `''` counts as an empty carriage return. ## 4 panes -::::{sidebar} 4 panes - -```{eval-rst} -.. aafig:: - - +--------+--------+ - | $ pwd | $ pwd | - | | | - | | | - +--------+--------+ - | $ pwd | $ pwd | - | | | - | | | - +--------+--------+ -``` - -:::: +:::{tmux-layout} +:size: 44x12 +:layout: tiled + +cd /var/log +ls -al | grep \.log +--- +echo hello +--- +echo hello +--- +echo hello +::: ````{tab} YAML @@ -262,7 +241,12 @@ please make a ticket on the [issue tracker][issue tracker]. ## Environment variables -tmuxp will set session environment variables. +tmuxp will set session, window and pane environment variables. + +```{note} + +Setting environment variables for windows and panes requires tmuxp 1.19 or newer. +``` ````{tab} YAML @@ -308,7 +292,13 @@ selected upon loading. tmuxp allows `suppress_history: false` to override the default command / suppression when building the workspace. -This will add the `shell_command` to the bash history in the pane. +This will add the `shell_command` to the shell history in the pane. +The suppression of the `shell_command` commands from the shell's history +occurs by prefixing the commands with a space when `suppress_history: true`. +Accordingly, this functionality depends on the shell being appropriately +configured: bash requires the shell variable `HISTCONTROL` to be set and +include either of the values `ignorespace` or `ignoreboth` (to also ignore +sequential duplicate commands), and zsh requires `setopt HIST_IGNORE_SPACE`. ````{tab} YAML @@ -345,7 +335,7 @@ _Experimental setting_: behavior and api is subject to change until stable. ``` Omit sending {kbd}`enter` to key commands. Equivalent to -[`send_keys(enter=False)`](libtmux.Pane.send_keys). +{meth}`send_keys(enter=False) `. ````{tab} YAML @@ -398,13 +388,16 @@ _Experimental setting_: behavior and api is subject to change until stable. ``` ```{warning} -**Blocking.** This will delay loading as it runs synchronously for each pane as [`asyncio`](asyncio)) is not implemented yet. +**Blocking.** This will delay loading as it runs synchronously for each pane as +{mod}`asyncio` is not implemented yet. ``` Omit sending {kbd}`enter` to key commands. Equivalent to having -a [`time.sleep`](time.sleep) before and after [`send_keys`](libtmux.Pane.send_keys). +a {func}`time.sleep` before and after {meth}`~libtmux.Pane.send_keys`. -This is especially useful for expensive commands where the terminal needs some breathing room (virtualenv, poetry, pipenv, sourcing a configuration, launching a tui app, etc). +This is especially useful for expensive commands where the terminal needs some +breathing room (virtualenv, [poetry], [pipenv], [uv], sourcing a configuration, +launching a tui app, etc). ````{tab} Virtualenv @@ -542,6 +535,30 @@ pane during creation. ## Main pane height +### Percentage + +:::{versionadded} 1.46.0 + +Before this, tmuxp layouts would not detect the terminal's size. + +::: + +````{tab} YAML +```{literalinclude} ../../examples/main-pane-height-percentage.yaml +:language: yaml + +``` +```` + +````{tab} JSON +```{literalinclude} ../../examples/main-pane-height-percentage.json +:language: json + +``` +```` + +### Rows + ````{tab} YAML ```{literalinclude} ../../examples/main-pane-height.yaml :language: yaml @@ -600,9 +617,9 @@ windows: - focus: True - blank - > - poetry run ./manage.py migrate && + uv run ./manage.py migrate && npm -C js run start - - poetry run ./manage.py runserver + - uv run ./manage.py runserver options: main-pane-height: 35 ``` @@ -622,7 +639,7 @@ A successful script will exit with a status of `0`. Important: the script file must be chmod executable `+x` or `755`. -Run a python script (and check for it's return code), the script is +Run a python script (and check for its return code), the script is _relative to the `.tmuxp.yaml`'s root_ (Windows and panes omitted in this example): @@ -630,7 +647,7 @@ this example): ```yaml session_name: my session before_script: ./bootstrap.py -# ... the rest of your config +# ... the rest of your workspace ``` ```` @@ -653,7 +670,7 @@ and panes omitted in this example) ```yaml session_name: another example before_script: /absolute/path/this.sh # abs path to shell script -# ... the rest of your config +# ... the rest of your workspace ``` ```` @@ -671,13 +688,13 @@ before_script: /absolute/path/this.sh # abs path to shell script [exit status]: http://tldp.org/LDP/abs/html/exit-status.html -## Per-project tmux config +## Per-project tmuxp workspaces You can load your software project in tmux by placing a `.tmuxp.yaml` or -`.tmuxp.json` in the project's config and loading it. +`.tmuxp.json` in the project's workspace and loading it. -tmuxp supports loading configs via absolute filename with `tmuxp load` -and via `$ tmuxp load .` if config is in directory. +Use {ref}`tmuxp load ` with an absolute filename, or `$ tmuxp load .` +when the workspace is in the current directory. ```console @@ -685,15 +702,15 @@ $ tmuxp load ~/workspaces/myproject.yaml ``` -See examples of `tmuxp` in the wild. Have a project config to show off? +See examples of `tmuxp` in the wild. Have a project workspace to show off? Edit this page. - -- +- - You can use `start_directory: ./` to make the directories relative to -the config file / project root. +the workspace file / project root. ## Bonus: pipenv auto-bootstrapping @@ -704,7 +721,7 @@ the config file / project root. ::: -If you use [pipenv][pipenv] / [poetry][poetry], you can use a script like this to ensure +If you use [pipenv] / [poetry] / [uv], you can use a script like this to ensure your packages are installed: ````{tab} YAML @@ -748,15 +765,16 @@ windows: [pipenv]: https://docs.pipenv.org/ [poetry]: https://python-poetry.org/ +[uv]: https://github.com/astral-sh/uv ## Kung fu :::{note} tmuxp sessions can be scripted in python. The first way is to use the -ORM in the {ref}`API`. The second is to pass a {py:obj}`dict` into -{class}`~tmuxp.workspacebuilder.WorkspaceBuilder` with a correct schema. -See: {meth}`tmuxp.config.validate_schema`. +ORM in the {ref}`API`. The second is to pass a {class}`dict` into +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` with a correct schema. +See: {func}`~tmuxp.workspace.validation.validate_schema`. ::: diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 3fbc09dcde..0cde232732 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -2,11 +2,42 @@ (configuration)= -# Configuration +(workspace)= -tmuxp loads your terminal workspace into tmux using config files. +# Workspace files -The configuration file can be JSON or YAML. It's declarative style resembles tmux's object hierarchy: session, window and wanes. +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Top-level Options +:link: top-level +:link-type: doc +Session and window configuration keys. +::: + +:::{grid-item-card} Environment Variables +:link: environmental-variables +:link-type: doc +TMUXP_CONFIGDIR and other env vars. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +Sample workspace configurations. +::: + +:::{grid-item-card} Workspace builders +:link: workspace-builders +:link-type: doc +Select a builder and tune pane readiness. +::: + +:::: + +tmuxp loads your terminal workspace into tmux using workspace files. + +The workspace file can be JSON or YAML. Its declarative style resembles tmux's object hierarchy: session, window, and panes. ## Launching your session @@ -16,21 +47,36 @@ Once you have `tmuxp` installed alongside tmux, you can load a workspace with: $ tmuxp load ./path/to/file ``` -tmuxp will offers to assist when: +tmuxp will offer to assist when: - _Session already exists_: tmuxp will prompt you to re-attach. It does this - by checking if the configuration's `session_name` matches a session already + by checking if the workspace's `session_name` matches a session already running on the same server. - _When inside a tmux client_, `tmuxp` will let you create a new session and switch to it, or append the windows to your existing session. -## What's in a config file? +## What's in a workspace file? 1. A session name 2. A list of _windows_ 3. A list of _panes_ for each window 4. A list of _commands_ for each pane +:::{mermaid} +:caption: A workspace file mirrors tmux's own hierarchy. +:alt: tmuxp workspace file hierarchy from session to windows, panes, and shell commands +:name: tmuxp-workspace-hierarchy +:responsive: fit + +flowchart TD + session["session_name"]:::cmd --> w1["window"] + session --> w2["window"] + w1 --> p1["pane"] + w1 --> p2["pane"] + p1 --> c1["shell_command"]:::cmd + p1 --> c2["shell_command"]:::cmd +::: + ````{tab} Basics ```yaml @@ -57,7 +103,7 @@ As of 1.11.x. ```` -Breaking down the basic configuration into sections: +Breaking down the basic workspace into sections: 1. A session name @@ -98,14 +144,14 @@ Breaking down the basic configuration into sections: # command options ``` -## Where do I store config files? +## Where do I store workspace files? ### Direct -You can create a configuration and load it from anywhere in your file system. +You can create a workspace and load it from anywhere in your file system. ```console -$ tmuxp load [config_file] +$ tmuxp load [workspace-file] ``` ````{tab} Relative @@ -120,7 +166,7 @@ $ tmuxp load /opt/myapp/favorites.yaml ``` ```` -### User-based configurations +### User-based workspaces tmuxp uses the [XDG Base Directory] specification. @@ -143,12 +189,12 @@ This path can be overridden by {ref}`TMUXP_CONFIGDIR` ### Project-specific -You can store a configuration in your project's root directory as `.tmuxp.yaml` or `.tmuxp.json`, then: +You can store a workspace in your project's root directory as `.tmuxp.yaml` or `.tmuxp.json`, then: Assume `.tmuxp.yaml` inside `/opt/myapp` ```console -$ tmuxp load [config_path] +$ tmuxp load [workspace-file] ``` ````{tab} In project root @@ -165,10 +211,40 @@ $ tmuxp load /opt/myapp ## Reference and usage +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Top-level Options +:link: top-level +:link-type: doc +Session and window configuration keys. +::: + +:::{grid-item-card} Environment Variables +:link: environmental-variables +:link-type: doc +TMUXP_CONFIGDIR and other env vars. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +Sample workspace configurations. +::: + +:::{grid-item-card} Workspace builders +:link: workspace-builders +:link-type: doc +Select a builder and tune pane readiness. +::: + +:::: + ```{toctree} +:hidden: top-level environmental-variables examples - +workspace-builders ``` diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index 7976c6605c..845ee79b9c 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -3,40 +3,49 @@ # Top-level configuration -## `session_name` - -Used for: +Top-level keys describe the session as a whole — its name, where it starts, +the tmux options it sets — and sit above the `windows` and `panes` that fill +it. Only `session_name` is required; leave the rest out and a workspace with +just a name and a list of windows loads fine. This page covers `session_name` +and the keys for choosing a workspace builder. For the full set of session, +window, and pane keys, work through {ref}`examples`. -- tmux session name -- checking for existing sessions +## `session_name` -Notes: +The name tmux gives the session — and the name tmuxp checks against when it +decides whether that session is already running. It need not match the +workspace filename. -- Session names may differ from config filename. +For example, _apple.yaml_: - e.g. _apple.yaml_: +```yaml +session_name: banana +windows: + - panes: + - +``` - ```yaml - session_name: banana - windows: - - panes: - - - ``` +Load it detached: - Load detached: +```console +$ tmuxp load ./apple.yaml -d +``` - ```console - $ tmuxp load ./apple.yaml -d - ``` +tmuxp reads _apple.yaml_ from the current directory and builds a tmux session +called _banana_ in the background — `-d` is detached. Attach to it with tmux +directly: - Above: +```console +$ tmux attach -t banana +``` - - tmuxp loads a file named _apple.yaml_ from the current directory. - - tmuxp built a tmux session called _banana_. - - `-d` means _detached_, loading in background. +## Workspace builder keys - ```console - $ tmux attach -t banana - ``` +A workspace file can also choose a custom builder and tune its behavior with +`workspace_builder`, `workspace_builder_paths`, and `workspace_builder_options`. +Most workspaces never set these — leave them out and you get tmuxp's built-in +classic builder. - Above: Use `tmux` directly to attach _banana_. +```{seealso} +{ref}`workspace-builders` +``` diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md new file mode 100644 index 0000000000..0630477df7 --- /dev/null +++ b/docs/configuration/workspace-builders.md @@ -0,0 +1,175 @@ +(workspace-builders)= + +# Workspace builders + +```{versionadded} 1.72.0 +``` + +A *workspace builder* is the part of tmuxp that turns a workspace configuration into a +live tmux session — it creates the session, lays out its windows and panes, and runs +their commands. You usually never have to think about it: tmuxp ships with the +built-in +{class}`classic builder `, +and your YAML or JSON workspace files load through it out of the box, just as +they always have. **Everything on this page is optional; leave a setting out to +fall back to the default.** + +:::{mermaid} +:caption: How a workspace file becomes a live tmux session. +:alt: tmuxp load reading workspace config through a workspace builder to attach a session +:name: tmuxp-workspace-builder-flow +:responsive: fit + +--- +config: + flowchart: + wrappingWidth: 500 +--- +flowchart TD + load["tmuxp load <workspace-file>"]:::cmd -->|reads workspace config| builder["Workspace Builder"] + builder -->|"creates session, windows, panes"| attach["Attach tmux session"] +::: + +Workspaces with special needs can reach for a builder's options to fine-tune how a +session loads. The classic builder, for instance, can wait for a pane's shell prompt +before sending its layout and commands — by default only when that shell is zsh (the +`pane_readiness` option). Waiting makes a session a little slower to load, but +guarantees the workspace is fully prepped before you attach. + +You can also send a workspace through a different or custom builder instead of +the classic one, and tune its options the same way. For the braver cases, you +can subclass the +{class}`classic builder ` +or write your own in Python on top of +[libtmux](https://libtmux.git-pull.com/) — see +{ref}`custom-workspace-builders` for writing, packaging, testing, and the trust +boundary that comes with running builder code. + +| Key | Type | Default | Purpose | +| --- | --- | --- | --- | +| `workspace_builder` | string | `classic` | Which builder turns the workspace into a session. | +| `workspace_builder_paths` | string or list of strings | _(none)_ | Trusted directories to import a builder from. | +| `workspace_builder_options` | mapping | _(all defaults)_ | Builder-behavior settings, such as `pane_readiness`. | + +(workspace-builder-key)= + +## `workspace_builder` + +This is where you name the builder. Leave it out — or set `classic` — and you get +tmuxp's built-in builder, with nothing imported. When you name something else, tmuxp +works out what you mean from the shape of the value, in this order: + +1. absent or empty → the built-in classic builder (nothing is imported); +2. contains `:` → a `module:attr` object reference; +3. no `.` and no `:` → a builder registered under the `tmuxp.workspace_builders` + entry-point group, selected by name; +4. dotted with no `:` → an entry-point name if one is registered, otherwise a + `module.attr` import path. + +```yaml +session_name: my-session +workspace_builder: classic +windows: + - panes: + - vim +``` + +See {ref}`custom-workspace-builders` for selecting and packaging builders, and +{func}`~tmuxp.workspace.builder.registry.resolve_builder_class` for the resolver. + +(workspace-builder-paths-key)= + +## `workspace_builder_paths` + +When your builder lives outside tmuxp's environment — say, a script sitting in your +config directory — this tells tmuxp where to find it. Give it a single directory or a +list of them. tmuxp expands `~` and environment variables, reads relative entries +against the workspace file's own directory, and expects each one to be a directory +that already exists. The paths join {data}`sys.path` only for the import and +build, not for the rest of your session. + +```yaml +workspace_builder: my_local_builder:CustomBuilder +workspace_builder_paths: + - ~/.config/tmuxp/builders +``` + +```{warning} +A workspace file that names a builder runs that builder's Python code. Only load +workspace files you trust. See the security note in {ref}`custom-workspace-builders`. +``` + +(workspace-builder-options-key)= + +## `workspace_builder_options` + +This holds builder-behavior settings, whichever builder you use. For now there's just +one, `pane_readiness`, which decides whether tmuxp waits for a pane's shell prompt +before it sends that pane's layout and commands — a guard against a zsh prompt-redraw +artifact: + +```yaml +workspace_builder_options: + pane_readiness: auto +``` + +| Value | Behavior | +| --- | --- | +| `auto` _(default)_ | Wait only when the session's shell is zsh. | +| `always` | Always wait for default-shell panes. | +| `never` | Never wait; fastest, but accepts the prompt/layout race for shells that need it. | + +`pane_readiness` also accepts truthy/falsy aliases — `true`/`on`/`yes`/`1` map to +`always`, and `false`/`off`/`no`/`0` map to `never` (full list in +{ref}`custom-workspace-builders`). An unrecognized value fails the load with: + +```text +invalid pane_readiness value: 'sometimes'; expected one of: auto, always/true/on/yes/1, never/false/off/no/0 +``` + +A pane that runs a custom `shell` or `window_shell` never waits, whatever you set here. +See {class}`~tmuxp.workspace.options.PaneReadiness` and +{class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` for the parsing rules. + +## Minimal complete example + +````{tab} YAML +```yaml +session_name: my-session +workspace_builder: classic +workspace_builder_paths: + - ~/.config/tmuxp/builders +workspace_builder_options: + pane_readiness: auto +windows: + - window_name: editor + panes: + - vim +``` +```` + +````{tab} JSON +```json +{ + "session_name": "my-session", + "workspace_builder": "classic", + "workspace_builder_paths": ["~/.config/tmuxp/builders"], + "workspace_builder_options": { + "pane_readiness": "auto" + }, + "windows": [ + { + "window_name": "editor", + "panes": ["vim"] + } + ] +} +``` +```` + +```{seealso} +{ref}`custom-workspace-builders` — narrative guide to selecting, packaging, +writing, and testing builders · +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` · +{class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +``` diff --git a/docs/developing.md b/docs/developing.md deleted file mode 100644 index fdfba36f10..0000000000 --- a/docs/developing.md +++ /dev/null @@ -1,293 +0,0 @@ -(developing)= - -# Developing and Testing - -```{eval-rst} -.. todo:: - link to sliderepl or ipython notebook slides -``` - -Our tests are inside `tests/`. Tests are implemented using -[pytest][pytest]. - -`make test` will create a tmux server on a separate `socket_name` -using `$ tmux -L test_case`. - -[pytest]: http://pytest.org/ - -(install-dev-env)= - -## Install the latest code from git - -### Get the source - -To begin developing, check out the code from github: - -```console - -$ git clone git@github.com:tmux-python/tmuxp.git -$ cd tmuxp - -``` - -### Advanced: Manual virtual - -Now create a virtualenv, if you don't know how to, you can create a -virtualenv with: - -```console - -$ virtualenv .venv - -``` - -Then activate it to your current tty / terminal session with: - -```console - -$ source .venv/bin/activate - -``` - -Good! Now let's run this: - -```console - -$ pip install -e . - -``` - -This has `pip`, a python package manager install the python package -in the current directory. `-e` means `--editable`, which means you can -adjust the code and the installed software will reflect the changes. - -```console - -$ tmuxp - -``` - -### Simple: Using poetry - -The easiest way to configure a dev environment is through [poetry][poetry]. This -automatically will manage virtualenv and python dependencies for tmuxp. -For information on installing poetry visit the [poetry's documentation][poetry's documentation]. - -To begin developing, check out the code from github: - -```console - -$ git clone git@github.com:tmux-python/tmuxp.git -$ cd tmuxp - -``` - -You can create a virtualenv, and install all of the locked -packages as listed in poetry.lock: - -```console - -$ poetry install - -``` - -If you ever need to update packages during your development session, the -following command can be used to update all packages as per poetry settings or -individual package (second command): - -```console - -$ poetry update -$ poetry update requests - -``` - -Then activate it to your current tty / terminal session with: - -```console - -$ poetry shell - -``` - -That is it! You are now ready to code! - -[poetry]: https://python-poetry.org/ -[poetry's documentation]: https://python-poetry.org/docs/ - -## Test Runner - -As you seen above, the `tmuxp` command will now be available to you, -since you are in the virtual environment, your {}`PATH` environment was -updated to include a special version of `python` inside your `.venv` -folder with its own packages. - -```console - -$ make test - -``` - -You probably didn't see anything but tests scroll by. - -If you found a problem or are trying to write a test, you can file an -[issue on github][issue on github]. - -(test-specific-tests)= - -### Test runner options - -Test only a file: - -```console - -$ py.test tests/test_config.py - -``` - -will test the `tests/test_config.py` tests. - -```console - -$ py.test tests/test_config.py::test_export_json - -``` - -tests `test_export_json` inside of `tests/test_config.py`. - -Multiple can be separated by spaces: - -```console - -$ py.test tests/test_{window,pane}.py tests/test_config.py::test_export_json - -``` - -(test-builder-visually)= - -## Visual testing - -You can watch tmux testsuite build sessions visually by keeping a client -open in a separate terminal. - -Create two terminals: - -> > - Terminal 1: `$ tmux -L test_case` -> -> - Terminal 2: `$ cd` into the tmuxp project and into the -> `virtualenv` if you are using one, see details on installing the dev -> version of tmuxp above. Then: -> -> ``` -> $ py.test tests/test_workspacebuilder.py -> ``` - -Terminal 1 should have flickered and built the session before your eyes. -tmuxp hides this building from normal users. - -## Run tests on save - -You can re-run tests automatically on file edit. - -:::{note} - -This requires `entr(1)`. - -::: - -Install [entr][entr]. Packages are available available on most Linux and BSD -variants, including Debian, Ubuntu, FreeBSD, OS X. - -To run all tests upon editing any `.py` file: - -```console - -$ make watch_test - -``` - -You can also re-run a specific test file or any other [py.test usage -argument][py.test usage argument]: - -```console - -$ make watch_test test=tests/test_config.py - -$ make watch_test test='-x tests/test_config.py tests/test_util.py' - -``` - -## Rebuild sphinx docs on save - -Rebuild the documentation when an `.rst` file is edited: - -```console - -$ cd doc -$ make watch - -# If you're not source'd via `poetry shell`, you can use this: -$ make SPHINXBUILD='poetry run sphinx-build' watch - -``` - -(tmuxp-developer-config)= - -## tmuxp developer config - -```{image} _static/tmuxp-dev-screenshot.png -:align: center - -``` - -After you {ref}`install-dev-env`, when inside the tmuxp checkout: - -```console - -$ tmuxp load . - -``` - -this will load the `.tmuxp.yaml` in the root of the project. - -```{literalinclude} ../.tmuxp.yaml -:language: yaml - -``` - -`.tmuxp-before-script.sh` ran by `before_script`: - -```{literalinclude} ../.tmuxp-before-script.sh -:language: console - -``` - -(gh-actions)= - -## Github Actions - -tmuxp uses [github actions][github actions] for continuous integration / automatic unit -testing. - -To view the tmux and python versions tested see the [.github/workflows/tests.yml][.github/workflows/tests.yml]. -Builds are done on `master` and pull requests and can be viewed on -the [gh build site][gh build site]. - -## Testing options - -`RETRY_TIMEOUT_SECONDS` can be toggled if certain workspace builder -tests are being stubborn. - -e.g. `RETRY_TIMEOUT_SECONDS=10 py.test` - -```{literalinclude} ../.github/workflows/tests.yml -:language: yaml - -``` - -[py.test usage argument]: https://pytest.org/latest/usage.html -[entr]: http://entrproject.org/ -[github actions]: https://github.com/features/actions -[gh build site]: https://github.com/tmux-python/tmuxp/actions?query=workflow%3Atests -[.github/workflows/tests.yml]: https://github.com/tmux-python/tmuxp/blob/master/.github/workflows/tests.yml -[issue on github]: https://github.com/tmux-python/tmuxp/issues diff --git a/docs/glossary.md b/docs/glossary.md index c90c9d918a..bac56bd12c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -2,65 +2,63 @@ # Glossary -```{eval-rst} -.. glossary:: +```{glossary} - tmuxp - A tool to manage workspaces with tmux. A pythonic abstraction of - tmux. +tmuxp + A tool to manage workspaces with tmux. A pythonic abstraction of + tmux. - tmux(1) - The tmux binary. Used internally to distinguish tmuxp is only a - layer on top of tmux. +tmux +tmux(1) + The tmux binary. Used internally to distinguish tmuxp is only a + layer on top of tmux. - kaptan - configuration management library, see `kaptan on github`_. +ConfigReader + The {class}`~tmuxp._internal.config_reader.ConfigReader` configuration + management class, for parsing YAML / JSON / etc. files + to and from python data (dictionaries, in the future, potentially + dataclasses) - Server - Tmux runs in the background of your system as a process. +Server + tmux runs in the background of your system as a process. - The server holds multiple :term:`Session`. By default, tmux - automatically starts the server the first time ``$ tmux`` is ran. + A server holds one or more {term}`Session`. tmux starts the server + automatically the first time ``$ tmux`` runs, if it isn't already + running. - A server contains :term:`session`'s. + Advanced: you can run more than one by specifying + ``[-L socket-name]`` or ``[-S socket-path]``. - tmux starts the server automatically if it's not running. +Client + Attaches to a tmux {term}`server`. When you use tmux through CLI, + you are using tmux as a client. - Advanced cases: multiple can be run by specifying - ``[-L socket-name]`` and ``[-S socket-path]``. +Session + Inside a tmux {term}`server`. - Client - Attaches to a tmux :term:`server`. When you use tmux through CLI, - you are using tmux as a client. + The session has 1 or more {term}`Window`. The bottom bar in tmux + show a list of windows. Normally they can be navigated with + ``Ctrl-a [0-9]``, ``Ctrl-a n`` and ``Ctrl-a p``. - Session - Inside a tmux :term:`server`. + Sessions can have a ``session_name``. - The session has 1 or more :term:`Window`. The bottom bar in tmux - show a list of windows. Normally they can be navigated with - ``Ctrl-a [0-9]``, ``Ctrl-a n`` and ``Ctrl-a p``. + Uniquely identified by ``session_id``. - Sessions can have a ``session_name``. +Window + Entity of a {term}`session`. - Uniquely identified by ``session_id``. + Can have 1 or more {term}`pane`. - Window - Entity of a :term:`session`. + Panes can be organized with layouts. - Can have 1 or more :term:`pane`. + Windows can have names. - Panes can be organized with a layouts. +Pane + Linked to a {term}`Window`. - Windows can have names. + a pseudoterminal. - Pane - Linked to a :term:`Window`. - - a pseudoterminal. - - Target - A target, cited in the manual as ``[-t target]`` can be a session, - window or pane. - -.. _kaptan on github: https://github.com/emre/kaptan +Target + A target, cited in the manual as ``[-t target]`` can be a session, + window or pane. ``` diff --git a/docs/history.md b/docs/history.md index 9ac69866a3..55bbcbc445 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,5 @@ +(changelog)= + (history)= ```{include} ../CHANGES diff --git a/docs/index.md b/docs/index.md index fcf989dcb8..ddacba1bb4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,40 +1,108 @@ (index)= -```{include} ../README.md -:end-before: GitHub ``` diff --git a/docs/internals/api/_internal/colors.md b/docs/internals/api/_internal/colors.md new file mode 100644 index 0000000000..b3577d9bae --- /dev/null +++ b/docs/internals/api/_internal/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/internals/api/_internal/config_reader.md b/docs/internals/api/_internal/config_reader.md new file mode 100644 index 0000000000..fa932f8643 --- /dev/null +++ b/docs/internals/api/_internal/config_reader.md @@ -0,0 +1,14 @@ +# Config reader - `tmuxp._internal.config_reader` + +:::{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.config_reader + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/_internal/index.md b/docs/internals/api/_internal/index.md new file mode 100644 index 0000000000..391a80b60f --- /dev/null +++ b/docs/internals/api/_internal/index.md @@ -0,0 +1,16 @@ +(api-internal)= + +# Internal Modules + +:::{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). +::: + +```{toctree} +colors +config_reader +private_path +types +``` diff --git a/docs/internals/api/_internal/private_path.md b/docs/internals/api/_internal/private_path.md new file mode 100644 index 0000000000..d329e15169 --- /dev/null +++ b/docs/internals/api/_internal/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/internals/api/_internal/types.md b/docs/internals/api/_internal/types.md new file mode 100644 index 0000000000..f41f12b6d1 --- /dev/null +++ b/docs/internals/api/_internal/types.md @@ -0,0 +1,8 @@ +# Typings - `tmuxp._internal.types` + +```{eval-rst} +.. automodule:: tmuxp._internal.types + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/convert.md b/docs/internals/api/cli/convert.md new file mode 100644 index 0000000000..4c9b87f34f --- /dev/null +++ b/docs/internals/api/cli/convert.md @@ -0,0 +1,8 @@ +# tmuxp convert - `tmuxp.cli.convert` + +```{eval-rst} +.. automodule:: tmuxp.cli.convert + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/debug_info.md b/docs/internals/api/cli/debug_info.md new file mode 100644 index 0000000000..56a50052e6 --- /dev/null +++ b/docs/internals/api/cli/debug_info.md @@ -0,0 +1,8 @@ +# tmuxp debug-info - `tmuxp.cli.debug_info` + +```{eval-rst} +.. automodule:: tmuxp.cli.debug_info + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/edit.md b/docs/internals/api/cli/edit.md new file mode 100644 index 0000000000..704e8144c3 --- /dev/null +++ b/docs/internals/api/cli/edit.md @@ -0,0 +1,8 @@ +# tmuxp edit - `tmuxp.cli.edit` + +```{eval-rst} +.. automodule:: tmuxp.cli.edit + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/formatter.md b/docs/internals/api/cli/formatter.md new file mode 100644 index 0000000000..7aa37596c5 --- /dev/null +++ b/docs/internals/api/cli/formatter.md @@ -0,0 +1,8 @@ +# tmuxp formatter - `tmuxp.cli._formatter` + +```{eval-rst} +.. automodule:: tmuxp.cli._formatter + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/freeze.md b/docs/internals/api/cli/freeze.md new file mode 100644 index 0000000000..70c4d06845 --- /dev/null +++ b/docs/internals/api/cli/freeze.md @@ -0,0 +1,8 @@ +# tmuxp freeze - `tmuxp.cli.freeze` + +```{eval-rst} +.. automodule:: tmuxp.cli.freeze + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/import_config.md b/docs/internals/api/cli/import_config.md new file mode 100644 index 0000000000..3c75e88ead --- /dev/null +++ b/docs/internals/api/cli/import_config.md @@ -0,0 +1,8 @@ +# tmuxp import - `tmuxp.cli.import_config` + +```{eval-rst} +.. automodule:: tmuxp.cli.import_config + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/index.md b/docs/internals/api/cli/index.md new file mode 100644 index 0000000000..851f620586 --- /dev/null +++ b/docs/internals/api/cli/index.md @@ -0,0 +1,33 @@ +(api_cli)= + +# CLI + +:::{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). +::: + +```{toctree} +convert +debug_info +edit +freeze +formatter +import_config +load +ls +progress +search +shell +utils +``` + +## `tmuxp.cli` + +```{eval-rst} +.. automodule:: tmuxp.cli + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/load.md b/docs/internals/api/cli/load.md new file mode 100644 index 0000000000..bae0f6a899 --- /dev/null +++ b/docs/internals/api/cli/load.md @@ -0,0 +1,8 @@ +# tmuxp load - `tmuxp.cli.load` + +```{eval-rst} +.. automodule:: tmuxp.cli.load + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/ls.md b/docs/internals/api/cli/ls.md new file mode 100644 index 0000000000..34d27718a6 --- /dev/null +++ b/docs/internals/api/cli/ls.md @@ -0,0 +1,8 @@ +# tmuxp ls - `tmuxp.cli.ls` + +```{eval-rst} +.. automodule:: tmuxp.cli.ls + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/progress.md b/docs/internals/api/cli/progress.md new file mode 100644 index 0000000000..3b092349cf --- /dev/null +++ b/docs/internals/api/cli/progress.md @@ -0,0 +1,8 @@ +# tmuxp progress - `tmuxp.cli._progress` + +```{eval-rst} +.. automodule:: tmuxp.cli._progress + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/search.md b/docs/internals/api/cli/search.md new file mode 100644 index 0000000000..bb9747e9d5 --- /dev/null +++ b/docs/internals/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/internals/api/cli/shell.md b/docs/internals/api/cli/shell.md new file mode 100644 index 0000000000..c6f05d0874 --- /dev/null +++ b/docs/internals/api/cli/shell.md @@ -0,0 +1,8 @@ +# tmuxp shell - `tmuxp.cli.shell` + +```{eval-rst} +.. automodule:: tmuxp.cli.shell + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/utils.md b/docs/internals/api/cli/utils.md new file mode 100644 index 0000000000..7a110a570c --- /dev/null +++ b/docs/internals/api/cli/utils.md @@ -0,0 +1,9 @@ +# CLI utilities - `tmuxp.cli.utils` + +```{eval-rst} +.. automodule:: tmuxp.cli.utils + :members: + :exclude-members: ColorMode + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/exc.md b/docs/internals/api/exc.md new file mode 100644 index 0000000000..70b68a2466 --- /dev/null +++ b/docs/internals/api/exc.md @@ -0,0 +1,8 @@ +# Exceptions - `tmuxp.exc` + +```{eval-rst} +.. automodule:: tmuxp.exc + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/index.md b/docs/internals/api/index.md new file mode 100644 index 0000000000..287bd0ba7f --- /dev/null +++ b/docs/internals/api/index.md @@ -0,0 +1,86 @@ +(api)= + +# API Reference + +This is the internal Python API — the modules tmuxp's CLI is built from, +documented for contributors and plugin authors. Everyday use goes through the +{ref}`CLI `; to drive tmux from Python directly, reach for +[libtmux](https://libtmux.git-pull.com/) instead. + +:::{seealso} +See {ref}`libtmux's API ` and {ref}`Quickstart ` to see how you can control +tmux via python API calls. +::: + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Workspace +:link: workspace/index +:link-type: doc +Finding, loading, building, and freezing sessions. +::: + +:::{grid-item-card} CLI modules +:link: cli/index +:link-type: doc +The {mod}`argparse` commands behind each subcommand. +::: + +:::{grid-item-card} Plugin API +:link: plugin +:link-type: doc +The {class}`~tmuxp.plugin.TmuxpPlugin` base class and its hooks. +::: + +:::{grid-item-card} Internal helpers +:link: _internal/index +:link-type: doc +Config reader, colors, and private path helpers. +::: + +:::{grid-item-card} Exceptions +:link: exc +:link-type: doc +The {exc}`~tmuxp.exc.TmuxpException` hierarchy. +::: + +:::{grid-item-card} Logging +:link: log +:link-type: doc +Loggers and user-facing echo helpers. +::: + +:::{grid-item-card} Shell +:link: shell +:link-type: doc +Internals of the interactive shell launcher. +::: + +:::{grid-item-card} Utilities +:link: util +:link-type: doc +Assorted helpers used across tmuxp. +::: + +:::{grid-item-card} Types +:link: types +:link-type: doc +Shared type definitions. +::: + +:::: + +```{toctree} +:hidden: + +_internal/index +cli/index +workspace/index +exc +log +plugin +shell +util +types +``` diff --git a/docs/internals/api/log.md b/docs/internals/api/log.md new file mode 100644 index 0000000000..2e000328c4 --- /dev/null +++ b/docs/internals/api/log.md @@ -0,0 +1,8 @@ +# Logging - `tmuxp.log` + +```{eval-rst} +.. automodule:: tmuxp.log + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/plugin.md b/docs/internals/api/plugin.md new file mode 100644 index 0000000000..fd7ce9781c --- /dev/null +++ b/docs/internals/api/plugin.md @@ -0,0 +1,8 @@ +# Plugin - `tmuxp.plugin` + +```{eval-rst} +.. automodule:: tmuxp.plugin + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/shell.md b/docs/internals/api/shell.md new file mode 100644 index 0000000000..286974a67e --- /dev/null +++ b/docs/internals/api/shell.md @@ -0,0 +1,8 @@ +# Shell - `tmuxp.shell` + +```{eval-rst} +.. automodule:: tmuxp.shell + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/types.md b/docs/internals/api/types.md new file mode 100644 index 0000000000..adac7ee4d9 --- /dev/null +++ b/docs/internals/api/types.md @@ -0,0 +1,8 @@ +# Typings - `tmuxp.types` + +```{eval-rst} +.. automodule:: tmuxp.types + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/util.md b/docs/internals/api/util.md new file mode 100644 index 0000000000..80e067e169 --- /dev/null +++ b/docs/internals/api/util.md @@ -0,0 +1,8 @@ +# Utilities - `tmuxp.util` + +```{eval-rst} +.. automodule:: tmuxp.util + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/classic.md b/docs/internals/api/workspace/builder/classic.md new file mode 100644 index 0000000000..856ebc63d4 --- /dev/null +++ b/docs/internals/api/workspace/builder/classic.md @@ -0,0 +1,8 @@ +# Classic builder - `tmuxp.workspace.builder.classic` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.classic + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/index.md b/docs/internals/api/workspace/builder/index.md new file mode 100644 index 0000000000..114a511fa9 --- /dev/null +++ b/docs/internals/api/workspace/builder/index.md @@ -0,0 +1,44 @@ +# Builder - `tmuxp.workspace.builder` + +```{eval-rst} +.. py:module:: tmuxp.workspace.builder +``` + +`tmuxp.workspace.builder` is a package. The classic, default builder lives in +{mod}`tmuxp.workspace.builder.classic`; the public contract and the selection +machinery live alongside it. + +{class}`WorkspaceBuilder ` +remains importable from {mod}`tmuxp.workspace.builder` as a backwards-compatible +alias of {class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Classic builder +:link: classic +:link-type: doc +The built-in, default builder — `tmuxp.workspace.builder.classic`. +::: + +:::{grid-item-card} Builder protocol +:link: protocol +:link-type: doc +The contract a builder must satisfy — `tmuxp.workspace.builder.protocol`. +::: + +:::{grid-item-card} Builder registry +:link: registry +:link-type: doc +Builder selection and trusted import paths — `tmuxp.workspace.builder.registry`. +::: + +:::: + +```{toctree} +:hidden: + +classic +protocol +registry +``` diff --git a/docs/internals/api/workspace/builder/protocol.md b/docs/internals/api/workspace/builder/protocol.md new file mode 100644 index 0000000000..1c980b33b3 --- /dev/null +++ b/docs/internals/api/workspace/builder/protocol.md @@ -0,0 +1,8 @@ +# Builder protocol - `tmuxp.workspace.builder.protocol` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.protocol + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/registry.md b/docs/internals/api/workspace/builder/registry.md new file mode 100644 index 0000000000..3a9fa4f7f3 --- /dev/null +++ b/docs/internals/api/workspace/builder/registry.md @@ -0,0 +1,8 @@ +# Builder registry - `tmuxp.workspace.builder.registry` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.registry + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/constants.md b/docs/internals/api/workspace/constants.md new file mode 100644 index 0000000000..df8cf583f2 --- /dev/null +++ b/docs/internals/api/workspace/constants.md @@ -0,0 +1,8 @@ +# Constants - `tmuxp.workspace.constants` + +```{eval-rst} +.. automodule:: tmuxp.workspace.constants + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/finders.md b/docs/internals/api/workspace/finders.md new file mode 100644 index 0000000000..d48318c361 --- /dev/null +++ b/docs/internals/api/workspace/finders.md @@ -0,0 +1,8 @@ +# Finders - `tmuxp.workspace.finders` + +```{eval-rst} +.. automodule:: tmuxp.workspace.finders + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/freezer.md b/docs/internals/api/workspace/freezer.md new file mode 100644 index 0000000000..ec9afd873a --- /dev/null +++ b/docs/internals/api/workspace/freezer.md @@ -0,0 +1,8 @@ +# Freezer - `tmuxp.workspace.freezer` + +```{eval-rst} +.. automodule:: tmuxp.workspace.freezer + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/importers.md b/docs/internals/api/workspace/importers.md new file mode 100644 index 0000000000..b3b9cd00db --- /dev/null +++ b/docs/internals/api/workspace/importers.md @@ -0,0 +1,8 @@ +# Importers - `tmuxp.workspace.importers` + +```{eval-rst} +.. automodule:: tmuxp.workspace.importers + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/index.md b/docs/internals/api/workspace/index.md new file mode 100644 index 0000000000..73c844cf27 --- /dev/null +++ b/docs/internals/api/workspace/index.md @@ -0,0 +1,20 @@ +(workspace)= + +# Workspace + +:::{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). +::: + +```{toctree} +builder/index +constants +finders +freezer +importers +loader +options +validation +``` diff --git a/docs/internals/api/workspace/loader.md b/docs/internals/api/workspace/loader.md new file mode 100644 index 0000000000..f422644ab0 --- /dev/null +++ b/docs/internals/api/workspace/loader.md @@ -0,0 +1,8 @@ +# Loader - `tmuxp.workspace.loader` + +```{eval-rst} +.. automodule:: tmuxp.workspace.loader + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/options.md b/docs/internals/api/workspace/options.md new file mode 100644 index 0000000000..e492ce6728 --- /dev/null +++ b/docs/internals/api/workspace/options.md @@ -0,0 +1,8 @@ +# Options - `tmuxp.workspace.options` + +```{eval-rst} +.. automodule:: tmuxp.workspace.options + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/validation.md b/docs/internals/api/workspace/validation.md new file mode 100644 index 0000000000..77f7655e80 --- /dev/null +++ b/docs/internals/api/workspace/validation.md @@ -0,0 +1,8 @@ +# Validation - `tmuxp.workspace.validation` + +```{eval-rst} +.. automodule:: tmuxp.workspace.validation + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md new file mode 100644 index 0000000000..0156f25eb5 --- /dev/null +++ b/docs/internals/architecture.md @@ -0,0 +1,54 @@ +# Architecture + +This page traces how a tmuxp command travels from the CLI down to +[libtmux](https://libtmux.git-pull.com/), the library that does the actual tmux +work. Each subcommand takes its own short path through one or two workspace +modules: + +:::{mermaid} +:caption: How each tmuxp subcommand reaches libtmux. +:alt: tmuxp CLI subcommands reaching workspace modules and libtmux +:name: tmuxp-command-architecture +:responsive: fit + +flowchart TD + cli["tmuxp CLI (argparse)"] + cli --> load["load"]:::cmd + load --> loader["workspace.loader"]:::cmd + loader --> builder["workspace.builder"]:::cmd + builder --> libtmux["libtmux"]:::cmd + cli --> freeze["freeze"]:::cmd + freeze --> freezer["workspace.freezer"]:::cmd + freezer --> libtmux + cli --> convert["convert"]:::cmd + convert --> reader["_internal.config_reader"]:::cmd + cli --> shell["shell"]:::cmd + shell --> interactive["libtmux (interactive)"] + cli --> search["ls / search"]:::cmd + search --> finders["workspace.finders"]:::cmd +::: + +## Key Components + +### CLI Layer (`tmuxp.cli`) + +The CLI uses Python's {mod}`argparse` with a custom formatter ({mod}`tmuxp.cli._formatter`). +Each subcommand lives in its own module under {mod}`tmuxp.cli`. + +The entry point is {func}`tmuxp.cli.cli`, registered as a console script in `pyproject.toml`. + +### Workspace Layer (`tmuxp.workspace`) + +The workspace layer handles configuration lifecycle: + +1. **Finding**: {mod}`tmuxp.workspace.finders` locates config files +2. **Loading**: {mod}`tmuxp.workspace.loader` reads and validates configs +3. **Building**: {mod}`tmuxp.workspace.builder` creates tmux sessions via libtmux +4. **Freezing**: {mod}`tmuxp.workspace.freezer` exports running sessions + +### Library Layer (libtmux) + +tmuxp delegates all tmux operations to [libtmux](https://libtmux.git-pull.com/). +The {class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` creates +libtmux {class}`~libtmux.Server`, {class}`~libtmux.Session`, {class}`~libtmux.Window`, +and {class}`~libtmux.Pane` objects to construct the requested workspace. diff --git a/docs/internals/index.md b/docs/internals/index.md new file mode 100644 index 0000000000..7c63fa86d0 --- /dev/null +++ b/docs/internals/index.md @@ -0,0 +1,36 @@ +(internals)= + +# Internals + +```{warning} +Everything in this section is **internal implementation detail**. There is +no stability guarantee. Interfaces may change or be removed without notice +between any release. + +If you are building an application with tmuxp, use the [CLI](../cli/index.md) +or refer to the {ref}`libtmux API `. +``` + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Architecture +:link: architecture +:link-type: doc +How the CLI dispatches to the workspace builder and libtmux. +::: + +:::{grid-item-card} Python API +:link: api/index +:link-type: doc +Internal module reference for contributors and plugin authors. +::: + +:::: + +```{toctree} +:hidden: + +architecture +api/index +``` diff --git a/docs/justfile b/docs/justfile new file mode 100644 index 0000000000..afeec6b509 --- /dev/null +++ b/docs/justfile @@ -0,0 +1,210 @@ +# justfile for tmuxp documentation +# https://just.systems/ + +set shell := ["bash", "-uc"] + +# Configuration +http_port := "8031" +builddir := "_build" +sphinxopts := "" +sphinxbuild := "uv run sphinx-build" +sourcedir := "." + +# Environment to disable colors in Sphinx + argparse output +export PYTHON_COLORS := "0" +export NO_COLOR := "1" + +# 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 dirhtml {{ 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 -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} + +# Design mode: watch static files and disable incremental builds +[group: 'dev'] +design: + uv run sphinx-autobuild -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} --watch "." -a diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000000..7bd3f4664f --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,9 @@ +(migration)= + +```{currentmodule} libtmux + +``` + +```{include} ../MIGRATION + +``` diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000000..c86f31b7a0 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,8 @@ +{ + "name": "tmuxp-docs", + "private": true, + "description": "Build-time tooling for the tmuxp docs (mermaid diagram rendering).", + "devDependencies": { + "@mermaid-js/mermaid-cli": "11.15.0" + } +} diff --git a/docs/plugins/index.md b/docs/plugins/index.md deleted file mode 100644 index f9225a21a7..0000000000 --- a/docs/plugins/index.md +++ /dev/null @@ -1,149 +0,0 @@ -(plugins)= - -# Plugins - -The plugin system allows users to customize and extend different aspects of -tmuxp without the need to change tmuxp itself. - -## Using a Plugin - -To use a plugin, install it in your local python environment and add it to -your tmuxp configuration file. - -### Example Configurations - -````{tab} YAML - -```{literalinclude} ../../examples/plugin-system.yaml -:language: yaml - -``` - -```` - -````{tab} JSON - -```{literalinclude} ../../examples/plugin-system.json -:language: json - -``` - -```` - -[poetry]: https://python-poetry.org/ - -## Developing a Plugin - -tmuxp expects all plugins to be class within a python submodule named -`plugin` that is within a python module that is installed in the local -python environment. A plugin interface is provided by tmuxp to inherit. - -[poetry][poetry] is the chosen python package manager for tmuxp. It is highly -suggested to use it when developing plugins; however, `pip` will work -just as well. Only one of the configuration files is needed for the packaging -tool that the package developer desides to use. - -```console - -python_module -├── tmuxp_plugin_my_plugin_module -│   ├── __init__.py -│   └── plugin.py -├── pyproject.toml # Poetry's module configuration file -└── setup.py # pip's module configuration file - -``` - -When publishing plugins to pypi, tmuxp advocates for standardized naming: -`tmuxp-plugin-{your-plugin-name}` to allow for easier searching. To create a -module configuration file with poetry, run `poetry init` in the module -directory. The resulting file looks something like this: - -```toml - -[tool.poetry] -name = "tmuxp-plugin-my-tmuxp-plugin" -version = "0.0.2" -description = "An example tmuxp plugin." -authors = ["Author Name .com>"] - -[tool.poetry.dependencies] -python = "^3.6" -tmuxp = "^1.6.0" - -[tool.poetry.dev-dependencies] - -[build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" - -``` - -The {}`plugin.py` file could contain something like the following: - -```python - -from tmuxp.plugin import TmuxpPlugin -import datetime - -class MyTmuxpPlugin(TmuxpPlugin): - def __init__(self): - """ - Initialize my custom plugin. - """ - # Optional version dependency configuration. See Plugin API docs - # for all supported config parameters - config = { - 'tmuxp_min_version' = '1.6.2' - } - - TmuxpPlugin.__init__( - self, - plugin_name='tmuxp-plugin-my-tmuxp-plugin', - **config - ) - - def before_workspace_builder(self, session): - session.rename_session('my-new-session-name') - - def reattach(self, session): - now = datetime.datetime.now().strftime('%Y-%m-%d') - session.rename_session('session_{}'.format(now)) - -``` - -Once this plugin is installed in the local python environment, it can be used -in a configuration file like the following: - -```yaml -session_name: plugin example -plugins: - - my_plugin_module.plugin.MyTmuxpPlugin -# ... the rest of your config -``` - -## Plugin API - -```{eval-rst} -.. automethod:: tmuxp.plugin.TmuxpPlugin.__init__ -``` - -```{eval-rst} -.. automethod:: tmuxp.plugin.TmuxpPlugin.before_workspace_builder -``` - -```{eval-rst} -.. automethod:: tmuxp.plugin.TmuxpPlugin.on_window_create -``` - -```{eval-rst} -.. automethod:: tmuxp.plugin.TmuxpPlugin.after_window_finished -``` - -```{eval-rst} -.. automethod:: tmuxp.plugin.TmuxpPlugin.before_script -``` - -```{eval-rst} -.. automethod:: tmuxp.plugin.TmuxpPlugin.reattach -``` diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 0000000000..fa55bb633c --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,2281 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@mermaid-js/mermaid-cli': + specifier: 11.15.0 + version: 11.15.0(puppeteer@24.43.1)(tailwindcss@4.3.1) + +packages: + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fortawesome/fontawesome-free@7.3.0': + resolution: {integrity: sha512-Yw4Qa43P6e4Xwh0TwEUkHQNRJInWaqIBo73VJmns3j2AIlZPAvUcR4yGIxCPqPRCgyJ5KIVIalF/I1GxhZ/Kgw==} + engines: {node: '>=6'} + + '@headlessui/react@2.2.10': + resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + '@headlessui/tailwindcss@0.2.2': + resolution: {integrity: sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw==} + engines: {node: '>=10'} + peerDependencies: + tailwindcss: ^3.0 || ^4.0 + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + + '@internationalized/string@3.2.9': + resolution: {integrity: sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==} + + '@mermaid-js/layout-elk@0.2.2': + resolution: {integrity: sha512-vnH3gtqfhyBiRVKNpT8iDENTw18q/OF0GF/SfYfHN43KZpu+6eZDEOMHTfNYAkpmUWJNgtRQFIS6BTc7vH/DYQ==} + peerDependencies: + mermaid: ^11.0.2 + + '@mermaid-js/layout-tidy-tree@0.2.2': + resolution: {integrity: sha512-8RmjDXjKJBxqTS1mICStm8zWRM45fSzs0SOrkp28+KsOGS2YEMFMVTwwRU8CsC6M1L+pDYZVjf1m9AC1c9Wndg==} + peerDependencies: + mermaid: ^11.0.2 + + '@mermaid-js/mermaid-cli@11.15.0': + resolution: {integrity: sha512-rmz9ELKtmKQvRcYJGI2e509FK9yCBvmEVfHeRSYkleGqo6qqh8LFooxRPCqq04uVx3JHMp9g/vmM85gi/QFFlQ==} + engines: {node: ^18.19 || >=20.0} + hasBin: true + peerDependencies: + puppeteer: ^23 || ^24 + + '@mermaid-js/mermaid-zenuml@0.2.3': + resolution: {integrity: sha512-RGBtgL6fc+5Y2Jm9odOH9HRJ80BP4l6atBYnAK5bBzEowF0PU3UtvZRRcbFxImPGPuLIzqZq31ur8lVO0AoF3Q==} + peerDependencies: + mermaid: ^10 || ^11 + + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + + '@puppeteer/browsers@2.13.2': + resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==} + engines: {node: '>=18'} + hasBin: true + + '@react-aria/focus@3.22.1': + resolution: {integrity: sha512-CPxtkyrBi/HYY5P3lE/57sQ6qfa0lN8E55TOm89H0kNGv0lKt+/0zP7lWERzBjRr5IxBVrQX4gFEowBN52LPaA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/interactions@3.28.1': + resolution: {integrity: sha512-Bqb+HrD5I5MHS2SKBhISYqo2SW8Y2dfzgF/Y1lIJq7xqLxheo9vzxPGEHhz+XzkgGfoqEJx8A6a3C7uiqS3HWA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-types/shared@3.36.0': + resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tanstack/react-virtual@3.14.4': + resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.2': + resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==} + + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@zenuml/core@3.50.1': + resolution: {integrity: sha512-Q18BXvIfhRuGw0JfO4e+pltG04Phhv2E7n35EWTNSZKKH/HEkyE4Sh7KERb9dqaOfjOdRdKvLG0YekjK3fcDXw==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + playwright-core: 1.57.0 + peerDependenciesMeta: + playwright-core: + optional: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + antlr4@4.11.0: + resolution: {integrity: sha512-GUGlpE2JUjAN+G8G5vY+nOoeyNhHsXoIJwP1XF1oRw89vifA1K46T6SEkwLwr7drihN7I/lf0DIjKc4OZvBX8w==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.2: + resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.2: + resolution: {integrity: sha512-h530JsrkYi8518ZfR57GHaLoI5YzXkGGEV0Y+mf4KYPBn4OnNajiznwkDq7FgE+Vnmyss9Utnzi44y7sowiAXA==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.1: + resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chromium-bidi@14.0.0: + resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==} + peerDependencies: + devtools-protocol: '*' + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + devtools-protocol@0.0.1608973: + resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==} + + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + jotai@2.20.1: + resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + puppeteer-core@24.43.1: + resolution: {integrity: sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==} + engines: {node: '>=18'} + + puppeteer@24.43.1: + resolution: {integrity: sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==} + engines: {node: '>=18'} + hasBin: true + + react-aria@3.50.0: + resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-stately@3.48.0: + resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.1: + resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + + tar-fs@3.1.3: + resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typed-query-selector@2.12.2: + resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + webdriver-bidi-protocol@0.4.1: + resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/react@0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tabbable: 6.5.0 + + '@floating-ui/react@0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tabbable: 6.5.0 + + '@floating-ui/utils@0.2.11': {} + + '@fortawesome/fontawesome-free@7.3.0': {} + + '@headlessui/react@2.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-aria/focus': 3.22.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-aria/interactions': 3.28.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': 3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + '@headlessui/tailwindcss@0.2.2(tailwindcss@4.3.1)': + dependencies: + tailwindcss: 4.3.1 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/string@3.2.9': + dependencies: + '@swc/helpers': 0.5.23 + + '@mermaid-js/layout-elk@0.2.2(mermaid@11.16.0)': + dependencies: + d3: 7.9.0 + elkjs: 0.9.3 + mermaid: 11.16.0 + + '@mermaid-js/layout-tidy-tree@0.2.2(mermaid@11.16.0)': + dependencies: + d3: 7.9.0 + mermaid: 11.16.0 + optional: true + + '@mermaid-js/mermaid-cli@11.15.0(puppeteer@24.43.1)(tailwindcss@4.3.1)': + dependencies: + '@fortawesome/fontawesome-free': 7.3.0 + '@mermaid-js/layout-elk': 0.2.2(mermaid@11.16.0) + '@mermaid-js/mermaid-zenuml': 0.2.3(mermaid@11.16.0)(tailwindcss@4.3.1) + chalk: 5.6.2 + commander: 13.1.0 + import-meta-resolve: 4.2.0 + katex: 0.16.47 + mermaid: 11.16.0 + p-limit: 6.2.0 + puppeteer: 24.43.1 + optionalDependencies: + '@mermaid-js/layout-tidy-tree': 0.2.2(mermaid@11.16.0) + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + - playwright-core + - tailwindcss + + '@mermaid-js/mermaid-zenuml@0.2.3(mermaid@11.16.0)(tailwindcss@4.3.1)': + dependencies: + '@zenuml/core': 3.50.1(tailwindcss@4.3.1) + mermaid: 11.16.0 + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + - playwright-core + - tailwindcss + + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + + '@puppeteer/browsers@2.13.2': + dependencies: + debug: 4.4.3 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.8.5 + tar-fs: 3.1.3 + yargs: 17.7.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@react-aria/focus@3.22.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@swc/helpers': 0.5.23 + react: 19.2.7 + react-aria: 3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) + + '@react-aria/interactions@3.28.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + react: 19.2.7 + react-aria: 3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) + + '@react-types/shared@3.36.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tanstack/react-virtual@3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/virtual-core@3.17.2': {} + + '@tootallnate/quickjs-emscripten@0.23.0': {} + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/geojson@7946.0.16': {} + + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + optional: true + + '@types/trusted-types@2.0.7': + optional: true + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 26.0.1 + optional: true + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@zenuml/core@3.50.1(tailwindcss@4.3.1)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@headlessui/react': 2.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@headlessui/tailwindcss': 0.2.2(tailwindcss@4.3.1) + antlr4: 4.11.0 + class-variance-authority: 0.7.1 + clsx: 2.1.1 + color-string: 2.1.4 + dompurify: 3.4.11 + highlight.js: 11.11.1 + html-to-image: 1.11.13 + jotai: 2.20.1(react@19.2.7) + marked: 4.3.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tailwind-merge: 3.6.0 + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + - tailwindcss + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + antlr4@4.11.0: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + + b4a@1.8.1: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.2: + dependencies: + bare-events: 2.9.1 + bare-path: 3.0.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.2: {} + + bare-path@3.0.1: + dependencies: + bare-os: 3.9.2 + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.5: + dependencies: + bare-path: 3.0.1 + + basic-ftp@5.3.1: {} + + buffer-crc32@0.2.13: {} + + callsites@3.1.0: {} + + chalk@5.6.2: {} + + chromium-bidi@14.0.0(devtools-protocol@0.0.1608973): + dependencies: + devtools-protocol: 0.0.1608973 + mitt: 3.0.1 + zod: 3.25.76 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + commander@13.1.0: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + cosmiconfig@9.0.2: + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + parse-json: 5.2.0 + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + data-uri-to-buffer@6.0.2: {} + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + devtools-protocol@0.0.1608973: {} + + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + elkjs@0.9.3: {} + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-toolkit@1.49.0: {} + + escalade@3.2.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-fifo@1.3.2: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + get-caller-file@2.0.5: {} + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + hachure-fill@0.5.2: {} + + highlight.js@11.11.1: {} + + html-to-image@1.11.13: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + ip-address@10.2.0: {} + + is-arrayish@0.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + + jotai@2.20.1(react@19.2.7): + optionalDependencies: + react: 19.2.7 + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@2.3.1: {} + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + lines-and-columns@1.2.4: {} + + lodash-es@4.18.1: {} + + lru-cache@7.18.3: {} + + marked@16.4.2: {} + + marked@4.3.0: {} + + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.11 + es-toolkit: 1.49.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + + mitt@3.0.1: {} + + ms@2.1.3: {} + + netmask@2.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.2 + + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-data-parser@0.1.0: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + progress@2.0.3: {} + + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + puppeteer-core@24.43.1: + dependencies: + '@puppeteer/browsers': 2.13.2 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) + debug: 4.4.3 + devtools-protocol: 0.0.1608973 + typed-query-selector: 2.12.2 + webdriver-bidi-protocol: 0.4.1 + ws: 8.21.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + puppeteer@24.43.1: + dependencies: + '@puppeteer/browsers': 2.13.2 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) + cosmiconfig: 9.0.2 + devtools-protocol: 0.0.1608973 + puppeteer-core: 24.43.1 + typed-query-selector: 2.12.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - typescript + - utf-8-validate + + react-aria@3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-stately: 3.48.0(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-stately@3.48.0(react@19.2.7): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + + react@19.2.7: {} + + require-directory@2.1.1: {} + + resolve-from@4.0.0: {} + + robust-predicates@3.0.3: {} + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + + source-map@0.6.1: + optional: true + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stylis@4.4.0: {} + + tabbable@6.5.0: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.1: {} + + tar-fs@3.1.3: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.2 + bare-path: 3.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.2 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + tinyexec@1.2.4: {} + + ts-dedent@2.3.0: {} + + tslib@2.8.1: {} + + typed-query-selector@2.12.2: {} + + undici-types@8.3.0: + optional: true + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + uuid@14.0.1: {} + + webdriver-bidi-protocol@0.4.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@1.2.2: {} + + zod@3.25.76: {} diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml new file mode 100644 index 0000000000..a66f92190a --- /dev/null +++ b/docs/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +# Allow @mermaid-js/mermaid-cli's puppeteer dependency to run its install +# script, which fetches the headless Chrome used to render diagrams. +allowBuilds: + puppeteer: true diff --git a/docs/project/code-style.md b/docs/project/code-style.md new file mode 100644 index 0000000000..8ce7748dbe --- /dev/null +++ b/docs/project/code-style.md @@ -0,0 +1,35 @@ +# Code Style + +## Formatting + +tmuxp uses [ruff](https://github.com/astral-sh/ruff) for both linting and formatting. + +```console +$ uv run ruff format . +``` + +```console +$ uv run ruff check . --fix --show-fixes +``` + +## Type Checking + +Strict [mypy](https://mypy-lang.org/) is enforced. + +```console +$ uv run mypy +``` + +## Docstrings + +All public functions and methods use +[NumPy-style docstrings](https://numpydoc.readthedocs.io/en/latest/format.html). + +## Imports + +- Standard library: namespace imports (`import pathlib`, not `from pathlib import Path`) + - Exception: `from dataclasses import dataclass, field` for + {func}`~dataclasses.dataclass` and {func}`~dataclasses.field` +- Typing: `import typing as t`, access via {data}`t.Optional `, + {class}`t.NamedTuple `, etc. +- All files: `from __future__ import annotations` diff --git a/docs/project/contributing.md b/docs/project/contributing.md new file mode 100644 index 0000000000..49214355bf --- /dev/null +++ b/docs/project/contributing.md @@ -0,0 +1,297 @@ +(developing)= + +# Developing and Testing + +The tests live in `tests/`, written with [pytest]. They run against a real tmux +server on a separate socket (`$ tmux -L test_case`), so they never disturb your +own sessions. + +[pytest]: http://pytest.org/ + +(install-dev-env)= + +## Install the latest code from git + +### Get the source + +Check out the code from GitHub: + +```console +$ git clone git@github.com:tmux-python/tmuxp.git +``` + +```console +$ cd tmuxp +``` + +### Bootstrap + +The easiest way to set up a dev environment is with [uv], which manages the +virtualenv and Python dependencies for you. (See [uv's documentation] to install +uv itself.) + +Create the virtualenv and install everything locked in `uv.lock`: + +```console +$ uv sync --all-extras --dev +``` + +To refresh those packages later: + +```console +$ uv sync --all-extras --dev --upgrade +``` + +Then prefix any Python command with `uv run`: + +```console +$ uv run [command] +``` + +That's it — you're ready to code. + +[uv]: https://github.com/astral-sh/uv +[uv's documentation]: https://docs.astral.sh/uv + +### Advanced: manual virtualenv + +Prefer to manage the virtualenv yourself? Create one: + +```console +$ virtualenv .venv +``` + +Activate it in your current shell: + +```console +$ source .venv/bin/activate +``` + +Install tmuxp in editable mode, so your edits take effect immediately: + +```console +$ pip install -e . +``` + +With a uv-managed project, add the checkout as an editable dev dependency +instead: + +```console +$ uv add --dev --editable . +``` + +Prefer a one-off, pipx-style run while you hack? Call tmuxp through [uvx]: + +```console +$ uvx tmuxp +``` + +[uvx]: https://docs.astral.sh/uv/guides/tools/ + +## Test runner + +[pytest] runs the tests. Inside the virtualenv, the `tmuxp` command and a +project-local `python` are already on your `PATH`. + +### Rerun on file change + +Watch files and re-run tests on every save, via [pytest-watcher]: + +```console +$ just start +``` + +[pytest-watcher]: https://github.com/olzhasar/pytest-watcher + +### Manual + +```console +$ uv run py.test +``` + +Or: + +```console +$ just test +``` + +### pytest options + +Pass extra arguments through `PYTEST_ADDOPTS`. See the [pytest usage docs] for +everything it accepts. + +[pytest usage docs]: https://docs.pytest.org/ + +Verbose: + +```console +$ env PYTEST_ADDOPTS="--verbose" just start +``` + +Pick a file: + +```console +$ env PYTEST_ADDOPTS="tests/workspace/test_builder.py" just start +``` + +Drop into a single test and stop on the first error: + +```console +$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py::test_automatic_rename_option" \ + just start +``` + +Drop into `pdb` on the first error: + +```console +$ env PYTEST_ADDOPTS="-x -s --pdb" just start +``` + +With [ipython] installed: + +```console +$ env PYTEST_ADDOPTS="--pdbcls=IPython.terminal.debugger:TerminalPdb" just start +``` + +[ipython]: https://ipython.org/ + +(test-specific-tests)= + +### Manual invocation + +Test a single file: + +```console +$ py.test tests/test_config.py +``` + +A single test inside it: + +```console +$ py.test tests/test_config.py::test_export_json +``` + +Several at once, space-separated: + +```console +$ py.test tests/test_{window,pane}.py tests/test_config.py::test_export_json +``` + +(test-builder-visually)= + +### Visual testing + +You can watch the suite build sessions in real time by keeping a client open in +a second terminal. + +Terminal 1 — start a server on the test socket: + +```console +$ tmux -L test_case +``` + +Terminal 2 — from the tmuxp checkout (and your virtualenv, if you use one), run +the builder tests: + +```console +$ py.test tests/workspace/test_builder.py +``` + +Terminal 1 flickers as sessions build before your eyes — the building tmuxp +normally hides from users. + +### Testing options + +Set `RETRY_TIMEOUT_SECONDS` if certain workspace-builder tests are stubborn on +your machine, e.g. `RETRY_TIMEOUT_SECONDS=10 py.test`. CI runs the same suite: + +```{literalinclude} ../../.github/workflows/tests.yml +:language: yaml +``` + +## Documentation + +Rebuild the docs whenever a source file changes: + +```console +$ just watch-docs +``` + +(tmuxp-developer-config)= + +## tmuxp developer config + +```{image} /_static/tmuxp-dev-screenshot.png +:width: 1030 +:height: 605 +:align: center +:loading: lazy +``` + +After you {ref}`install-dev-env`, load the project's own workspace from the +checkout root: + +```console +$ tmuxp load . +``` + +This loads the `.tmuxp.yaml` at the project root: + +```{literalinclude} ../../.tmuxp.yaml +:language: yaml +``` + +## Formatting + +### Linting + +The project uses [ruff] for linting, import sorting, and formatting. + +Lint: + +```console +$ just ruff +``` + +Autofix what ruff can: + +```console +$ uv run ruff check . --fix --show-fixes +``` + +#### Formatting + +[ruff format] handles formatting: + +```console +$ just ruff-format +``` + +### Type checking + +[mypy] does static type checking: + +```console +$ just mypy +``` + +Re-check on change: + +```console +$ just watch-mypy +``` + +(gh-actions)= + +## Continuous integration + +tmuxp uses [GitHub Actions] for continuous integration. To see the tmux and +Python versions under test, read [.github/workflows/tests.yml]. Builds run on +`master` and on pull requests, and are visible on the [build site]. + +[ruff]: https://ruff.rs +[ruff format]: https://docs.astral.sh/ruff/formatter/ +[mypy]: http://mypy-lang.org/ +[GitHub Actions]: https://github.com/features/actions +[build site]: https://github.com/tmux-python/tmuxp/actions?query=workflow%3Atests +[.github/workflows/tests.yml]: https://github.com/tmux-python/tmuxp/blob/master/.github/workflows/tests.yml diff --git a/docs/project/index.md b/docs/project/index.md new file mode 100644 index 0000000000..f3360dc2e2 --- /dev/null +++ b/docs/project/index.md @@ -0,0 +1,38 @@ +(project)= + +# Project + +Information for contributors and maintainers. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Contributing +:link: contributing +:link-type: doc +Development setup, running tests, submitting PRs. +::: + +:::{grid-item-card} Code Style +:link: code-style +:link-type: doc +[Ruff](https://ruff.rs), [mypy](https://mypy-lang.org/), +[NumPy-style docstrings](https://numpydoc.readthedocs.io/en/latest/format.html), +import conventions. +::: + +:::{grid-item-card} Releasing +:link: releasing +:link-type: doc +Release checklist and version policy. +::: + +:::: + +```{toctree} +:hidden: + +contributing +code-style +releasing +``` diff --git a/docs/project/releasing.md b/docs/project/releasing.md new file mode 100644 index 0000000000..9460f03027 --- /dev/null +++ b/docs/project/releasing.md @@ -0,0 +1,57 @@ +# Releasing + +## Release process + +You release tmuxp by tagging a version: pushing a `v` tag triggers a CI +workflow that builds the package and publishes it to [PyPI] via +[OIDC trusted publishing]. + +1. Update `CHANGES` with the release notes. +2. Bump the version in `src/tmuxp/__about__.py`. +3. Commit the bump: + + ```console + $ git commit -m "Tag v" + ``` + +4. Tag it: + + ```console + $ git tag v + ``` + +5. Push the commit and the tag: + + ```console + $ git push && git push --tags + ``` + +6. CI builds and publishes to [PyPI] automatically. + +## Changelog format + +`CHANGES` is rendered as the changelog page. Each release is a Markdown section +headed by its version and date: + +```text +## tmuxp () + +### Breaking changes + +- Description of the break, with a migration path (#issue) + +### What's new + +- Description of the feature (#issue) + +### Fixes + +- Description of the fix (#issue) +``` + +Subheadings appear in a fixed order when present: `### Breaking changes`, +`### Dependencies`, `### What's new`, `### Fixes`, `### Documentation`, +`### Development`. + +[PyPI]: https://pypi.org/project/tmuxp/ +[OIDC trusted publishing]: https://docs.pypi.org/trusted-publishers/ diff --git a/docs/quickstart.md b/docs/quickstart.md index 0549e84e47..f296445e43 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -2,23 +2,52 @@ # Quickstart +tmuxp launches a whole tmux workspace — its windows, panes, and the commands +inside them — from a single YAML or JSON file. Install it, write one file, and +{ref}`tmuxp load ` builds the session and drops you into it. This page +takes you from nothing to a running session. + ## Installation -Assure you have at least tmux **>= 1.8** and python **>= 2.6**. +Ensure you have at least tmux **>= 3.2** and python **>= 3.10**. ```console $ pip install --user tmuxp ``` -You can upgrade to the latest release with: +If you manage dependencies with [uv] inside a project environment, add tmuxp to +your lockfile instead: + +```console +$ uv add tmuxp +``` + +To run tmuxp without installing it globally — the way you'd use [pipx] — invoke +it through [uvx]: + +```console +$ uvx tmuxp +``` + +Upgrade to the latest release with: ```console $ pip install --user --upgrade tmuxp ``` +Within a uv-managed project, upgrade by refreshing the lockfile and syncing: + +```console +$ uv lock --upgrade-package tmuxp +``` + +```console +$ uv sync +``` + Then install {ref}`completion`. -If you are a Homebrew user you can install it with: +Homebrew users can install it with: ```console $ brew install tmuxp @@ -28,40 +57,53 @@ $ brew install tmuxp ### Developmental releases -New versions of tmuxp are published to PyPI as alpha, beta, or release candidates. -In their versions you will see notfication like `a1`, `b1`, and `rc1`, respectively. -`1.10.0b4` would mean the 4th beta release of `1.10.0` before general availability. +New versions of tmuxp are published to [PyPI] as alpha, beta, or release +candidates. Their version carries an `a1`, `b1`, or `rc1` suffix — `1.10.0b4` is +the fourth beta of `1.10.0`, before general availability. -- [pip]\: +Install the latest pre-release with the tool you use: - ```console - $ pip install --user --upgrade --pre tmuxp - ``` +```console +$ pip install --user --upgrade --pre tmuxp +``` -- [pipx]\: +```console +$ uv add tmuxp --prerelease allow +``` - ```console - $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force - ``` +```console +$ uvx --from 'tmuxp' --prerelease allow tmuxp +``` - Then use `tmuxp@next load [session]`. +```console +$ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force +``` -via trunk (can break easily): +After the pipx install, load with `tmuxp@next load [session]`. -- [pip]\: +Or track trunk directly (it can break): - ```console - $ pip install --user -e git+https://github.com/tmux-python/tmuxp.git#egg=tmuxp - ``` +```console +$ pip install --user -e git+https://github.com/tmux-python/tmuxp.git#egg=tmuxp +``` -- [pipx]\: +```console +$ uv add "tmuxp @ git+https://github.com/tmux-python/tmuxp.git@master" +``` - ```console - $ pipx install --suffix=@master 'tmuxp @ git+https://github.com/tmux-python/tmuxp.git@master' --force - ``` +```console +$ uvx --from "tmuxp @ git+https://github.com/tmux-python/tmuxp.git@master" tmuxp +``` + +```console +$ pipx install --suffix=@master 'tmuxp @ git+https://github.com/tmux-python/tmuxp.git@master' --force +``` [pip]: https://pip.pypa.io/en/stable/ [pipx]: https://pypa.github.io/pipx/docs/ +[PyPI]: https://pypi.org/project/tmuxp/ +[uv]: https://docs.astral.sh/uv/getting-started/features/#python-versions +[uvx]: https://docs.astral.sh/uv/guides/tools/ ## Commands @@ -73,60 +115,48 @@ via trunk (can break easily): tmuxp launches workspaces / sessions from JSON and YAML files. -Configuration files can be stored in `$HOME/.tmuxp` or in project -directories as `.tmuxp.py`, `.tmuxp.json` or `.tmuxp.yaml`. - -Every configuration is required to have: +Workspace files live in `$HOME/.tmuxp`, or in a project directory as +`.tmuxp.yaml`, `.tmuxp.yml`, or `.tmuxp.json`. Every workspace file needs: -1. `session_name` -2. list of `windows` -3. list of `panes` for every window in `windows` +1. a `session_name` +2. a list of `windows` +3. a list of `panes` for every window Create a file, `~/.tmuxp/example.yaml`: ```{literalinclude} ../examples/2-pane-vertical.yaml :language: yaml - ``` ```console - $ tmuxp load example.yaml - ``` -This creates your tmuxp session. +This builds and attaches your session. -Load multiple tmux sessions at once: +Load several at once: ```console - $ tmuxp load example.yaml anothersession.yaml - ``` -tmuxp will offer to `switch-client` for you if you're already in a -session. You can also load a configuration, and append the windows to -the current active session. +If you're already inside a session, tmuxp offers to `switch-client` for you, or +to append the new windows to the session you're in. -You can also have a custom tmuxp config directory by setting the -`TMUXP_CONFIGDIR` in your environment variables. +You can point tmuxp at a different config directory with the `TMUXP_CONFIGDIR` +environment variable: ```console - $ TMUXP_CONFIGDIR=$HOME/.tmuxpmoo tmuxp load cpython - ``` -Or in your `~/.bashrc` / `~/.zshrc` you can set: +Or set it in your `~/.bashrc` / `~/.zshrc`: ```console - -export TMUXP_CONFIGDIR=$HOME/.yourconfigdir/tmuxp - +$ export TMUXP_CONFIGDIR=$HOME/.yourconfigdir/tmuxp ``` -You can also [Import][import] configs [teamocil][teamocil] and [tmuxinator][tmuxinator]. +You can also [import][import] configs from [teamocil] and [tmuxinator]. ## Pythonics @@ -136,20 +166,20 @@ You can also [Import][import] configs [teamocil][teamocil] and [tmuxinator][tmux ::: -ORM - [Object Relational Mapper][object relational mapper] - -AL - [Abstraction Layer][abstraction layer] - -### python abstraction layer +Under the hood, tmuxp drives tmux through +[libtmux](https://libtmux.git-pull.com/) — an +[object-relational mapper][object relational mapper] and +[abstraction layer] over `tmux(1)`'s commands. Each config concept maps to a +libtmux call: -| {ref}`tmuxp python api ` | {term}`tmux(1)` equivalent | +| {ref}`libtmux Python API ` | {term}`tmux(1)` equivalent | | ------------------------------------- | -------------------------- | | {meth}`libtmux.Server.new_session` | `$ tmux new-session` | -| {meth}`libtmux.Server.list_sessions` | `$ tmux list-sessions` | -| {meth}`libtmux.Session.list_windows` | `$ tmux list-windows` | +| {attr}`libtmux.Server.sessions` | `$ tmux list-sessions` | +| {attr}`libtmux.Session.windows` | `$ tmux list-windows` | | {meth}`libtmux.Session.new_window` | `$ tmux new-window` | -| {meth}`libtmux.Window.list_panes` | `$ tmux list-panes` | -| {meth}`libtmux.Window.split_window` | `$ tmux split-window` | +| {attr}`libtmux.Window.panes` | `$ tmux list-panes` | +| {meth}`libtmux.Window.split` | `$ tmux split-window` | | {meth}`libtmux.Pane.send_keys` | `$ tmux send-keys` | [import]: http://tmuxp.git-pull.com/commands/#import diff --git a/docs/redirects.txt b/docs/redirects.txt index 31f280ec09..53b041de9d 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -1,4 +1,40 @@ -"cli.md" "commands/index.md" +# "cli.md" → commands/index.md → cli/index.md: not needed with dirhtml (same output path) +# "api.md" → api/index.md: not needed with dirhtml (same output path as api/index.md redirect) "examples.md" "configuration/examples.md" "plugin_system.md" "plugins/index.md" "commands/index.md" "cli/index.md" +"api/index.md" "internals/api/index.md" +"api/exc.md" "internals/api/exc.md" +"api/log.md" "internals/api/log.md" +"api/plugin.md" "internals/api/plugin.md" +"api/shell.md" "internals/api/shell.md" +"api/types.md" "internals/api/types.md" +"api/util.md" "internals/api/util.md" +"api/cli/index.md" "internals/api/cli/index.md" +"api/cli/convert.md" "internals/api/cli/convert.md" +"api/cli/debug_info.md" "internals/api/cli/debug_info.md" +"api/cli/edit.md" "internals/api/cli/edit.md" +"api/cli/freeze.md" "internals/api/cli/freeze.md" +"api/cli/import_config.md" "internals/api/cli/import_config.md" +"api/cli/load.md" "internals/api/cli/load.md" +"api/cli/ls.md" "internals/api/cli/ls.md" +"api/cli/progress.md" "internals/api/cli/progress.md" +"api/cli/search.md" "internals/api/cli/search.md" +"api/cli/shell.md" "internals/api/cli/shell.md" +"api/cli/utils.md" "internals/api/cli/utils.md" +"api/workspace/index.md" "internals/api/workspace/index.md" +"api/workspace/builder.md" "internals/api/workspace/builder/index.md" +"api/workspace/constants.md" "internals/api/workspace/constants.md" +"api/workspace/finders.md" "internals/api/workspace/finders.md" +"api/workspace/freezer.md" "internals/api/workspace/freezer.md" +"api/workspace/importers.md" "internals/api/workspace/importers.md" +"api/workspace/loader.md" "internals/api/workspace/loader.md" +"api/workspace/validation.md" "internals/api/workspace/validation.md" +"api/internals/index.md" "internals/api/_internal/index.md" +"api/internals/colors.md" "internals/api/_internal/colors.md" +"api/internals/config_reader.md" "internals/api/_internal/config_reader.md" +"api/internals/private_path.md" "internals/api/_internal/private_path.md" +"api/internals/types.md" "internals/api/_internal/types.md" +"plugins/index.md" "topics/plugins.md" +"developing.md" "project/contributing.md" +"about.md" "topics/index.md" diff --git a/docs/topics/custom-workspace-builders.md b/docs/topics/custom-workspace-builders.md new file mode 100644 index 0000000000..4fbb4422b6 --- /dev/null +++ b/docs/topics/custom-workspace-builders.md @@ -0,0 +1,200 @@ +(custom-workspace-builders)= + +# Custom workspace builders + +A *workspace builder* turns an expanded workspace {class}`dict` into a live tmux +session. tmuxp ships one builder, +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`, and uses it +by default. You can point tmuxp at a different builder, and packagers can +distribute builders that you select by name. + +This is an advanced, opt-in feature. Existing workspace files keep using the +classic builder with no changes. + +```{seealso} +If you only want to set these keys in a workspace file, see +{ref}`workspace-builders` in the configuration reference. +``` + +## How a workspace is built + +1. {ref}`tmuxp load ` reads the YAML/JSON file and expands it + (shorthand, environment variables, trickle-down defaults). +2. tmuxp resolves which builder to use from the + {ref}`workspace_builder ` config key (default: the + classic builder). +3. tmuxp constructs the builder with the expanded workspace and a + {class}`libtmux.Server`, then calls + {meth}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol.build`. +4. The builder creates the session, windows, and panes, honoring plugin hooks + and progress callbacks. + +## Selecting a builder + +### By dotted path + +Point `workspace_builder` at an importable class. Both a +`module:attr` object reference and a dotted `module.attr` path are accepted: + +```yaml +session_name: my-session +workspace_builder: my_tmuxp_builders.builders:CustomBuilder +windows: + - window_name: editor + panes: + - vim +``` + +### By entry-point name + +Packaged builders register under the `tmuxp.workspace_builders` entry-point +group, letting users select them by a short name instead of an internal module +path: + +```yaml +workspace_builder: classic +``` + +The built-in classic builder is registered this way. A distribution registers +its own builder in `pyproject.toml`: + +```toml +[project.entry-points."tmuxp.workspace_builders"] +mybuilder = "my_tmuxp_builders.builders:CustomBuilder" +``` + +### Trusted import paths + +When a builder lives outside tmuxp's runtime environment (for example, a script +in your config directory), list trusted directories in +{ref}`workspace_builder_paths `. tmuxp expands `~` +and environment variables, +resolves relative entries against the workspace file's directory, requires each +entry to be an existing directory, and temporarily prepends them to {data}`sys.path` +for the import and build: + +```yaml +workspace_builder: my_local_builder:CustomBuilder +workspace_builder_paths: + - ~/.config/tmuxp/builders +``` + +:::{warning} +A workspace file that names a builder runs that builder's Python code. Only load +workspace files you trust. tmuxp deliberately does **not** use +{func}`site.addsitedir` for these paths — that would execute `.pth` startup files +and is broader than making a module importable. +::: + +## Writing a builder + +The simplest custom builder subclasses the classic builder and overrides what it +needs: + +```python +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class CustomBuilder(ClassicWorkspaceBuilder): + """A builder that renames the session after building.""" + + def build(self, session=None, append=False): + super().build(session=session, append=append) + self.session.rename_session(f"{self.session.name}-custom") +``` + +A builder written from scratch must satisfy +{class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol`. The contract +covers what `tmuxp load` drives: + +- **Constructor** accepting `session_config`, `server`, and the optional + `plugins` list and `on_progress` / `on_before_script` / `on_script_output` / + `on_build_event` callbacks. +- **{meth}`build(session=None, append=False) `** + — create or populate the session; the `append` path adds windows to an + existing session. +- **{attr}`session `** + — the populated {class}`libtmux.Session`. +- **{meth}`session_exists() `** + and + **{meth}`find_current_attached_session() `** + — used by the CLI for attach/append decisions. +- **`plugins`** — the list of plugin instances; honor the plugin lifecycle hooks + ({meth}`~tmuxp.plugin.TmuxpPlugin.before_workspace_builder`, + {meth}`~tmuxp.plugin.TmuxpPlugin.on_window_create`, + {meth}`~tmuxp.plugin.TmuxpPlugin.after_window_finished`). +- The **`on_*` callbacks** — call them at the documented milestones so the CLI's + progress display and `before_script` output stay accurate. + +The contract is synchronous today. It is shaped so an async builder can be added +later as an additive extension without changing this surface. + +## Pane readiness + +tmuxp waits for a pane's shell prompt before dispatching layout and commands, +which avoids a zsh prompt-redraw artifact. That wait is only needed for zsh, so +it is configurable independently of which builder you use, through the +{ref}`workspace_builder_options ` catalog: + +```yaml +workspace_builder_options: + pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) +``` + +- **`auto`** (default) — wait only when the session's interactive shell is zsh. +- **`always`** (or `true`/`on`/`yes`/`1`) — always wait for default-shell panes. +- **`never`** (or `false`/`off`/`no`/`0`) — never wait; fastest, but accepts the + prompt/layout race for shells that need it. + +Panes with a custom `shell` or `window_shell` never wait, regardless of policy — +those run a command in place of an interactive shell, so there is no prompt to +wait for. + +See {class}`~tmuxp.workspace.options.PaneReadiness` and +{class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` for the parsing rules. + +## Testing a builder + +Resolve, construct, and build against a real tmux server fixture: + +```python +from tmuxp.workspace import loader +from tmuxp.workspace.builder import registry + + +def test_custom_builder(server): + config = loader.expand( + { + "session_name": "demo", + "workspace_builder": "my_tmuxp_builders.builders:CustomBuilder", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + builder_cls = registry.resolve_builder_class(config) + builder = builder_cls(session_config=config, server=server) + builder.build() + assert builder.session.name == "demo" + builder.session.kill() +``` + +For builders that live in a trusted directory, build the {data}`sys.path` sandbox with +{func}`~tmuxp.workspace.builder.registry.resolve_builder_paths` and +{func}`~tmuxp.workspace.builder.registry.prepended_sys_path`. + +## Choosing an approach + +- **Classic builder** — the default. Use it for any workspace that depends on + strict, pane-by-pane side effects (`start_directory`, `shell`, `window_shell`, + pane environment). +- **Readiness tuning** — set `pane_readiness` to trade prompt-safety for speed + without swapping builders. +- **A custom builder** — when you need behavior the classic builder doesn't + provide. Keep dependency-sensitive setup in `before_script` or + `shell_command_before` if your builder relaxes ordering guarantees. + +## Reference + +- {class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` +- {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +- {func}`~tmuxp.workspace.builder.registry.resolve_builder_class` +- {class}`~tmuxp.workspace.options.PaneReadiness` diff --git a/docs/topics/index.md b/docs/topics/index.md new file mode 100644 index 0000000000..52c8cacb81 --- /dev/null +++ b/docs/topics/index.md @@ -0,0 +1,59 @@ +(topics)= + +# Topics + +Conceptual guides and workflow documentation. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Workflows +:link: workflows +:link-type: doc +CI integration, scripting, and automation patterns. +::: + +:::{grid-item-card} Plugins +:link: plugins +:link-type: doc +Plugin system for custom behavior. +::: + +:::{grid-item-card} Custom workspace builders +:link: custom-workspace-builders +:link-type: doc +Select or ship a custom builder; tune pane readiness. +::: + +:::{grid-item-card} Library vs CLI +:link: library-vs-cli +:link-type: doc +When to use tmuxp CLI vs [libtmux](https://libtmux.git-pull.com/) directly. +::: + +:::{grid-item-card} Troubleshooting +:link: troubleshooting +:link-type: doc +Common shell, PATH, and tmux issues. +::: + +:::: + +## Compared to other session managers + +tmuxp, [tmuxinator](https://github.com/aziz/tmuxinator), and +[teamocil](https://github.com/remiprev/teamocil) all load tmux sessions +from config files. Key differences: tmuxp is Python (not Ruby), builds +sessions through [libtmux](https://libtmux.git-pull.com/)'s ORM layer +instead of raw shell commands, supports JSON and YAML, and can +[freeze](../cli/freeze.md) running sessions back to config. + +```{toctree} +:hidden: + +workflows +plugins +custom-workspace-builders +library-vs-cli +troubleshooting +``` diff --git a/docs/topics/library-vs-cli.md b/docs/topics/library-vs-cli.md new file mode 100644 index 0000000000..d0da75531d --- /dev/null +++ b/docs/topics/library-vs-cli.md @@ -0,0 +1,67 @@ +# Library vs CLI + +tmuxp is a CLI tool. [libtmux](https://libtmux.git-pull.com/) is the Python library it's built on. Both control tmux, but they serve different needs. + +## When to Use the CLI + +Use `tmuxp` when: + +- You want **declarative workspace configs** — define your layout in YAML, load it with one command +- You're setting up **daily development environments** — same windows, same panes, every time +- You need **CI/CD tmux sessions** — run {ref}`tmuxp load -d ` in a + script +- You prefer **configuration over code** — no Python needed + +```console +$ tmuxp load my-workspace.yaml +``` + +New to the format? Start with {ref}`quickstart`, then browse ready-to-load files +in {ref}`examples`. + +## When to Use libtmux + +Use libtmux directly when: + +- You need **dynamic logic** — conditionals, loops, branching based on state +- You want to **read pane output** — capture what's on screen and react to it +- You're **testing** tmux interactions — libtmux provides + [pytest](https://docs.pytest.org/) fixtures +- You need **multi-server orchestration** — manage multiple tmux servers programmatically +- The CLI's config format **can't express** what you need + +```python +import libtmux + +server = libtmux.Server() +session = server.new_session("my-project") +window = session.new_window("editor") +pane = window.split() +pane.send_keys("vim .") +``` + +## Concept Mapping + +How tmuxp config keys map to libtmux API calls: + +| tmuxp YAML | libtmux equivalent | +|------------|-------------------| +| `session_name: foo` | {meth}`server.new_session(session_name="foo") ` | +| `windows:` | {meth}`session.new_window(...) ` | +| `panes:` | {meth}`window.split(...) ` | +| `shell_command:` | {meth}`pane.send_keys(...) ` | +| `layout: main-vertical` | {meth}`window.select_layout("main-vertical") ` | +| `start_directory: ~/project` | {meth}`session.new_window(start_directory="~/project") ` | +| `before_script:` | Run via {mod}`subprocess` before building | + +## What the CLI Can't Express + +tmuxp configs are static declarations. They can't: + +- **Branch on conditions** — "only create this pane if a file exists" +- **Read pane output** — "wait until the server is ready, then open the browser" +- **React to state** — "if this session already has 3 windows, add a 4th" +- **Orchestrate across servers** — "connect to both local and remote tmux" +- **Build layouts dynamically** — "create N panes based on a list of services" + +For these, use libtmux directly. See the {ref}`libtmux quickstart `. diff --git a/docs/topics/plugins.md b/docs/topics/plugins.md new file mode 100644 index 0000000000..d664cb633f --- /dev/null +++ b/docs/topics/plugins.md @@ -0,0 +1,172 @@ +(plugins)= + +# Plugins + +Plugins let you customize and extend how tmuxp builds a session — renaming it, +reacting when you reattach, running setup at specific moments — without forking +tmuxp or hand-editing your workspace files. This is an advanced, opt-in feature: +most users never write or install one, and a workspace loads exactly the same +with no plugins at all. Reach for a plugin when you want behavior the workspace +format can't express and you're comfortable writing a little Python. + +## Using a plugin + +Install the plugin into the same Python environment as tmuxp, then name it in +your workspace file under `plugins`: + +````{tab} YAML + +```{literalinclude} ../../examples/plugin-system.yaml +:language: yaml + +``` + +```` + +````{tab} JSON + +```{literalinclude} ../../examples/plugin-system.json +:language: json + +``` + +```` + +## When your hooks fire + +A plugin is a class whose methods tmuxp calls at set points while it builds and +attaches the session. You override only the hooks you care about; the rest do +nothing. {ref}`tmuxp load ` drives the lifecycle in a fixed order: + +:::{mermaid} +:caption: When each plugin hook fires. +:alt: tmuxp plugin hook order during tmuxp load +:name: tmuxp-plugin-hook-order +:responsive: fit + +flowchart TD + load["tmuxp load"]:::cmd --> bwb["before_workspace_builder"]:::cmd + bwb --> oc["on_window_create"]:::cmd + oc --> panes["create panes, run commands"] + panes --> awf["after_window_finished"]:::cmd + awf -->|more windows| oc + awf -->|all windows built| bs["before_script"]:::cmd + bs --> reattach["reattach"]:::cmd +::: + +{meth}`~tmuxp.plugin.TmuxpPlugin.before_workspace_builder` runs first, once +the session exists but before any windows. +{meth}`~tmuxp.plugin.TmuxpPlugin.on_window_create` and +{meth}`~tmuxp.plugin.TmuxpPlugin.after_window_finished` bracket each window's +panes. Two of the names can mislead: +{meth}`~tmuxp.plugin.TmuxpPlugin.before_script` runs _after_ the whole session +is built — it augments, rather than replaces, the workspace's own +`before_script` — and {meth}`~tmuxp.plugin.TmuxpPlugin.reattach` fires only +when tmuxp re-attaches you to a session that already exists. + +## Developing a plugin + +tmuxp expects a plugin to be a class in a Python submodule named `plugin`, inside +a module installed in the same environment as tmuxp. You inherit from the +interface tmuxp provides, {class}`~tmuxp.plugin.TmuxpPlugin`. + +[uv] is tmuxp's package manager of choice, and what these examples use; `pip` +works just as well. You need only one project file, for whichever packaging tool +you choose. + +```tree +python_module +├── tmuxp_plugin_my_plugin_module +│ ├── __init__.py +│ └── plugin.py +└── pyproject.toml # Python project configuration file +``` + +When publishing to [PyPI], tmuxp suggests the naming convention +`tmuxp-plugin-{your-plugin-name}` so others can find it. A minimal +`pyproject.toml` looks like this: + +```toml +[project] +name = "tmuxp-plugin-my-tmuxp-plugin" +version = "0.0.2" +description = "An example tmuxp plugin." +authors = [{ name = "Author Name", email = "author.name@example.com" }] +requires-python = ">=3.10" +dependencies = [ + "tmuxp>=1.7.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +The `plugin.py` file holds the class: + +```python +import datetime + +from tmuxp.plugin import TmuxpPlugin + + +class MyTmuxpPlugin(TmuxpPlugin): + def __init__(self): + """Initialize my custom plugin.""" + # Optional version-dependency configuration. See the Plugin API + # docs for every supported parameter. + config = { + 'tmuxp_min_version': '1.6.2', + } + + TmuxpPlugin.__init__( + self, + plugin_name='tmuxp-plugin-my-tmuxp-plugin', + **config, + ) + + def before_workspace_builder(self, session): + session.rename_session('my-new-session-name') + + def reattach(self, session): + now = datetime.datetime.now().strftime('%Y-%m-%d') + session.rename_session('session_{}'.format(now)) +``` + +Once it's installed in the same environment, name it in a workspace file: + +```yaml +session_name: plugin example +plugins: + - my_plugin_module.plugin.MyTmuxpPlugin +# ... the rest of your workspace +``` + +## Plugin API + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.__init__ +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.before_workspace_builder +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.on_window_create +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.after_window_finished +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.before_script +``` + +```{eval-rst} +.. automethod:: tmuxp.plugin.TmuxpPlugin.reattach +``` + +[uv]: https://github.com/astral-sh/uv +[PyPI]: https://pypi.org/ diff --git a/docs/topics/troubleshooting.md b/docs/topics/troubleshooting.md new file mode 100644 index 0000000000..60f231a8f4 --- /dev/null +++ b/docs/topics/troubleshooting.md @@ -0,0 +1,41 @@ +# Troubleshooting + +## tmuxp command not found + +Ensure tmuxp is installed and on your `PATH`: + +```console +$ which tmuxp +``` + +If installed with `pip install --user`, ensure `~/.local/bin` is in your `PATH`. + +## tmux server not found + +tmuxp requires a running tmux server or will start one automatically. +Ensure tmux is installed: + +```console +$ tmux -V +``` + +Minimum required version: tmux 3.2. + +## Configuration errors + +Use {ref}`tmuxp debug-info ` to collect system information for +bug reports: + +```console +$ tmuxp debug-info +``` + +## Session already exists + +If a session with the same name already exists, tmuxp will prompt you. +Use {ref}`tmuxp load -d ` to load in detached mode alongside existing +sessions. + +## Shell completion not working + +See {ref}`completion` for setup instructions for bash, zsh, and tcsh. diff --git a/docs/topics/workflows.md b/docs/topics/workflows.md new file mode 100644 index 0000000000..2f8108d7cc --- /dev/null +++ b/docs/topics/workflows.md @@ -0,0 +1,58 @@ +# Workflows + +tmuxp is small enough to drop into a script, a CI job, or your daily startup +routine. This page collects a few patterns — running headless, branching on exit +codes, and turning a session you arranged by hand into a reusable file. None of +it is special machinery; it's the same {ref}`tmuxp load `, +{ref}`tmuxp freeze `, and {ref}`exit codes ` you +already have. + +## CI integration + +You can build a tmux session inside a CI pipeline for integration testing. Load +it detached so nothing waits on a terminal: + +```console +$ tmuxp load -d my-workspace.yaml +``` + +The `-d` flag loads the session in the background, which is what you want in a +headless environment. + +## Scripting + +tmuxp returns meaningful exit codes, so a script can tell success from failure +and branch on it. See {ref}`cli-exit-codes` for the full list. + +## Automating development environments + +You don't have to write a workspace file from scratch. Arrange a session the way +you like it, freeze it to capture the layout, then edit and replay it anywhere: + +:::{mermaid} +:caption: Capture a session once, replay it anywhere. +:alt: manual tmux session captured with tmuxp freeze and replayed with tmuxp load +:name: tmuxp-freeze-load-workflow +:responsive: fit + +flowchart TD + arrange["arrange tmux by hand"] --> freeze["tmuxp freeze"]:::cmd + freeze --> yaml["workspace.yaml"]:::cmd + yaml --> edit["edit + commit"] + edit --> load["tmuxp load"]:::cmd + load --> arrange +::: + +1. Arrange your ideal tmux layout by hand. +2. Freeze it: `tmuxp freeze my-session`. +3. Edit the generated YAML to add commands. +4. Load it on any machine: `tmuxp load my-workspace.yaml`. + +## User-level configuration + +You can store workspace files in any of these, then load them by name from +anywhere: + +- `~/.tmuxp/` (legacy) +- `~/.config/tmuxp/` (XDG default) +- a project-local `.tmuxp.yaml` or `.tmuxp/` directory diff --git a/examples/2-pane-synchronized.yaml b/examples/2-pane-synchronized.yaml index fbb65b7937..50f77473d6 100644 --- a/examples/2-pane-synchronized.yaml +++ b/examples/2-pane-synchronized.yaml @@ -2,7 +2,7 @@ session_name: 2-pane-synchronized windows: - window_name: Two synchronized panes panes: - - ssh server1 - - ssh server2 + - ssh server1 + - ssh server2 options_after: synchronize-panes: on diff --git a/examples/3-pane.yaml b/examples/3-pane.yaml index c298ebba66..76e6110344 100644 --- a/examples/3-pane.yaml +++ b/examples/3-pane.yaml @@ -1,12 +1,12 @@ session_name: 3-panes windows: -- window_name: dev window - layout: main-vertical - shell_command_before: - - cd ~/ - panes: - - shell_command: - - cd /var/log - - ls -al | grep \.log - - echo hello - - echo hello + - window_name: dev window + layout: main-vertical + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello diff --git a/examples/4-pane.yaml b/examples/4-pane.yaml index 04242fc5e1..d42b89955c 100644 --- a/examples/4-pane.yaml +++ b/examples/4-pane.yaml @@ -1,13 +1,13 @@ session_name: 4-pane-split windows: -- window_name: dev window - layout: tiled - shell_command_before: - - cd ~/ - panes: - - shell_command: - - cd /var/log - - ls -al | grep \.log - - echo hello - - echo hello - - echo hello + - window_name: dev window + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep \.log + - echo hello + - echo hello + - echo hello diff --git a/examples/blank-panes.yaml b/examples/blank-panes.yaml index 64a930de7f..9828ac1874 100644 --- a/examples/blank-panes.yaml +++ b/examples/blank-panes.yaml @@ -4,24 +4,24 @@ windows: # All these are equivalent - window_name: Blank pane test panes: - - - - pane - - blank + - + - pane + - blank - window_name: More blank panes panes: - - null - - shell_command: - - shell_command: - - + - null + - shell_command: + - shell_command: + - # an empty string will be treated as a carriage return - window_name: Empty string (return) panes: - - '' - - shell_command: '' - - shell_command: - - '' + - "" + - shell_command: "" + - shell_command: + - "" # a pane can have other options but still be blank - window_name: Blank with options panes: - - focus: true - - start_directory: /tmp + - focus: true + - start_directory: /tmp diff --git a/examples/env-variables.yaml b/examples/env-variables.yaml index d2c584be90..e448f5a0cc 100644 --- a/examples/env-variables.yaml +++ b/examples/env-variables.yaml @@ -3,15 +3,15 @@ shell_command_before: "echo ${PWD}" before_script: "${MY_ENV_VAR}/test3.sh" session_name: session - ${USER} (${MY_ENV_VAR}) windows: -- window_name: editor - panes: - - shell_command: - - tail -F /var/log/syslog - start_directory: /var/log -- window_name: logging for ${USER} - options: - automatic-rename: true - panes: - - shell_command: - - htop - - ls $PWD + - window_name: editor + panes: + - shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + - window_name: logging for ${USER} + options: + automatic-rename: true + panes: + - shell_command: + - htop + - ls $PWD diff --git a/examples/focus-window-and-panes.yaml b/examples/focus-window-and-panes.yaml index 0d2d762721..de4805660d 100644 --- a/examples/focus-window-and-panes.yaml +++ b/examples/focus-window-and-panes.yaml @@ -3,18 +3,18 @@ windows: - window_name: attached window focus: true panes: - - shell_command: - - echo hello - - echo 'this pane should be selected on load' - focus: true - - shell_command: - - cd /var/log - - echo hello + - shell_command: + - echo hello + - echo 'this pane should be selected on load' + focus: true + - shell_command: + - cd /var/log + - echo hello - window_name: second window shell_command_before: cd /var/log panes: - - pane - - shell_command: - - echo 'this pane should be focused, when window switched to first time' - focus: true - - pane + - pane + - shell_command: + - echo 'this pane should be focused, when window switched to first time' + focus: true + - pane diff --git a/examples/main-pane-height-percentage.json b/examples/main-pane-height-percentage.json new file mode 100644 index 0000000000..32b593d0ef --- /dev/null +++ b/examples/main-pane-height-percentage.json @@ -0,0 +1,31 @@ +{ + "windows": [ + { + "panes": [ + { + "shell_command": [ + "top" + ], + "start_directory": "~" + }, + { + "shell_command": [ + "echo \"hey\"" + ] + }, + { + "shell_command": [ + "echo \"moo\"" + ] + } + ], + "layout": "main-horizontal", + "options": { + "main-pane-height": "67%" + }, + "window_name": "editor" + } + ], + "session_name": "main pane height", + "start_directory": "~" +} diff --git a/examples/main-pane-height-percentage.yaml b/examples/main-pane-height-percentage.yaml new file mode 100644 index 0000000000..061aad4ab6 --- /dev/null +++ b/examples/main-pane-height-percentage.yaml @@ -0,0 +1,15 @@ +session_name: main-pane-height +start_directory: "~" +windows: + - layout: main-horizontal + options: + main-pane-height: 67% + panes: + - shell_command: + - top + start_directory: "~" + - shell_command: + - echo "hey" + - shell_command: + - echo "moo" + window_name: my window name diff --git a/examples/main-pane-height.yaml b/examples/main-pane-height.yaml index 3dbcc479f9..fe9a23f0e4 100644 --- a/examples/main-pane-height.yaml +++ b/examples/main-pane-height.yaml @@ -1,15 +1,15 @@ session_name: main-pane-height -start_directory: '~' +start_directory: "~" windows: -- layout: main-horizontal - options: - main-pane-height: 30 - panes: - - shell_command: - - top - start_directory: '~' - - shell_command: - - echo "hey" - - shell_command: - - echo "moo" - window_name: my window name + - layout: main-horizontal + options: + main-pane-height: 30 + panes: + - shell_command: + - top + start_directory: "~" + - shell_command: + - echo "hey" + - shell_command: + - echo "moo" + window_name: my window name diff --git a/examples/minimal.yaml b/examples/minimal.yaml index 0efa773049..e4f86e47a1 100644 --- a/examples/minimal.yaml +++ b/examples/minimal.yaml @@ -1,4 +1,4 @@ session_name: My tmux session windows: - panes: - - + - diff --git a/examples/options.yaml b/examples/options.yaml index 7de1cc5f1f..b66189820d 100644 --- a/examples/options.yaml +++ b/examples/options.yaml @@ -1,19 +1,19 @@ session_name: test window options -start_directory: '~' +start_directory: "~" global_options: default-shell: /bin/sh default-command: /bin/sh options: - main-pane-height: ${MAIN_PANE_HEIGHT} # works with env variables + main-pane-height: ${MAIN_PANE_HEIGHT} # works with env variables windows: -- layout: main-horizontal - options: - automatic-rename: on - panes: - - shell_command: - - man echo - start_directory: '~' - - shell_command: - - echo "hey" - - shell_command: - - echo "moo" + - layout: main-horizontal + options: + automatic-rename: on + panes: + - shell_command: + - man echo + start_directory: "~" + - shell_command: + - echo "hey" + - shell_command: + - echo "moo" diff --git a/examples/plugin-system.yaml b/examples/plugin-system.yaml index 13fa6278ce..15581de715 100644 --- a/examples/plugin-system.yaml +++ b/examples/plugin-system.yaml @@ -1,13 +1,13 @@ session_name: plugin-system plugins: -- 'tmuxp_plugin_extended_build.plugin.PluginExtendedBuild' + - "tmuxp_plugin_extended_build.plugin.PluginExtendedBuild" windows: -- window_name: editor - layout: tiled - shell_command_before: - - cd ~/ - panes: - - shell_command: - - cd /var/log - - ls -al | grep *.log - - echo "hello world" \ No newline at end of file + - window_name: editor + layout: tiled + shell_command_before: + - cd ~/ + panes: + - shell_command: + - cd /var/log + - ls -al | grep *.log + - echo "hello world" diff --git a/examples/session-environment.json b/examples/session-environment.json index d4c5c17cf9..3c31e12ec3 100644 --- a/examples/session-environment.json +++ b/examples/session-environment.json @@ -2,15 +2,32 @@ "environment": { "EDITOR": "/usr/bin/vim", "DJANGO_SETTINGS_MODULE": "my_app.settings.local", - "SERVER_PORT": "8009", - }, + "SERVER_PORT": "8009" + }, "windows": [ { "panes": [ - "./manage.py runserver 0.0.0.0:${SERVER_PORT}" - ], + "./manage.py runserver 0.0.0.0:${SERVER_PORT}" + ], "window_name": "Django project" - }, - ], + }, + { + "environment": { + "DJANGO_SETTINGS_MODULE": "my_app.settings.local", + "SERVER_PORT": "8010" + }, + "panes": [ + "./manage.py runserver 0.0.0.0:${SERVER_PORT}", + { + "environment": { + "DJANGO_SETTINGS_MODULE": "my_app.settings.local-testing", + "SERVER_PORT": "8011" + }, + "shell_command": "./manage.py runserver 0.0.0.0:${SERVER_PORT}" + } + ], + "window_name": "Another Django project" + } + ], "session_name": "Environment variables test" -} +} \ No newline at end of file diff --git a/examples/session-environment.yaml b/examples/session-environment.yaml index 09573331d5..a1091e7a18 100644 --- a/examples/session-environment.yaml +++ b/examples/session-environment.yaml @@ -6,4 +6,14 @@ environment: windows: - window_name: Django project panes: - - ./manage.py runserver 0.0.0.0:${SERVER_PORT} + - ./manage.py runserver 0.0.0.0:${SERVER_PORT} + - window_name: Another Django project + environment: + DJANGO_SETTINGS_MODULE: my_app.settings.local + SERVER_PORT: "8010" + panes: + - ./manage.py runserver 0.0.0.0:${SERVER_PORT} + - environment: + DJANGO_SETTINGS_MODULE: my_app.settings.local-testing + SERVER_PORT: "8011" + shell_command: ./manage.py runserver 0.0.0.0:${SERVER_PORT} diff --git a/examples/shorthands.yaml b/examples/shorthands.yaml index 5fb88100fb..cd4afaf39a 100644 --- a/examples/shorthands.yaml +++ b/examples/shorthands.yaml @@ -2,8 +2,8 @@ session_name: shorthands windows: - window_name: long form panes: - - shell_command: - - echo 'did you know' - - echo 'you can inline' - - shell_command: echo 'single commands' - - echo 'for panes' + - shell_command: + - echo 'did you know' + - echo 'you can inline' + - shell_command: echo 'single commands' + - echo 'for panes' diff --git a/examples/skip-send-pane-level.yaml b/examples/skip-send-pane-level.yaml index 03628f7691..2e988e8924 100644 --- a/examples/skip-send-pane-level.yaml +++ b/examples/skip-send-pane-level.yaml @@ -1,9 +1,9 @@ session_name: Skip command execution (pane-level) windows: -- panes: - - shell_command: echo "___$((1 + 3))___" - enter: false - - shell_command: - - echo "___$((1 + 3))___"\; - - echo "___$((1 + 3))___" - enter: false + - panes: + - shell_command: echo "___$((1 + 3))___" + enter: false + - shell_command: + - echo "___$((1 + 3))___"\; + - echo "___$((1 + 3))___" + enter: false diff --git a/examples/skip-send.yaml b/examples/skip-send.yaml index 96d1100348..150aa64f0e 100644 --- a/examples/skip-send.yaml +++ b/examples/skip-send.yaml @@ -1,9 +1,9 @@ session_name: Skip command execution (command-level) windows: -- panes: - - shell_command: - # You can see this - - echo "___$((11 + 1))___" - # This is skipped - - cmd: echo "___$((1 + 3))___" - enter: false + - panes: + - shell_command: + # You can see this + - echo "___$((11 + 1))___" + # This is skipped + - cmd: echo "___$((1 + 3))___" + enter: false diff --git a/examples/sleep-pane-level.yaml b/examples/sleep-pane-level.yaml index eb64e0fd7e..00a1ef2151 100644 --- a/examples/sleep-pane-level.yaml +++ b/examples/sleep-pane-level.yaml @@ -1,13 +1,11 @@ session_name: Pause / skip command execution (pane-level) windows: -- panes: - - - # Wait 2 seconds before sending all commands in this pane - sleep_before: 2 - shell_command: - - echo "___$((11 + 1))___" - - cmd: echo "___$((1 + 3))___" - - cmd: echo "___$((1 + 3))___" - - cmd: echo "Stuff rendering here!" - - cmd: echo "2 seconds later" - + - panes: + - # Wait 2 seconds before sending all commands in this pane + sleep_before: 2 + shell_command: + - echo "___$((11 + 1))___" + - cmd: echo "___$((1 + 3))___" + - cmd: echo "___$((1 + 3))___" + - cmd: echo "Stuff rendering here!" + - cmd: echo "2 seconds later" diff --git a/examples/sleep-virtualenv.yaml b/examples/sleep-virtualenv.yaml index 5370fa34c7..e226b6e3f2 100644 --- a/examples/sleep-virtualenv.yaml +++ b/examples/sleep-virtualenv.yaml @@ -1,11 +1,11 @@ session_name: virtualenv shell_command_before: -# - cmd: source $(poetry env info --path)/bin/activate -# - cmd: source `pipenv --venv`/bin/activate -- cmd: source .venv/bin/activate - sleep_before: 1 - sleep_after: 1 + # - cmd: source $(poetry env info --path)/bin/activate + # - cmd: source `pipenv --venv`/bin/activate + - cmd: source .venv/bin/activate + sleep_before: 1 + sleep_after: 1 windows: -- panes: - - shell_command: - - ./manage.py runserver + - panes: + - shell_command: + - ./manage.py runserver diff --git a/examples/sleep.yaml b/examples/sleep.yaml index ae90586b7a..cf1e7f7ebe 100644 --- a/examples/sleep.yaml +++ b/examples/sleep.yaml @@ -1,16 +1,16 @@ session_name: Pause / skip command execution (command-level) windows: -- panes: - - shell_command: - # Executes immediately - - echo "___$((11 + 1))___" - # Delays before sending 2 seconds - - cmd: echo "___$((1 + 3))___" - sleep_before: 2 - # Executes immediately - - cmd: echo "___$((1 + 3))___" - # Pauses 2 seconds after - - cmd: echo "Stuff rendering here!" - sleep_after: 2 - # Executes after earlier commands (after 2 sec) - - cmd: echo "2 seconds later" + - panes: + - shell_command: + # Executes immediately + - echo "___$((11 + 1))___" + # Delays before sending 2 seconds + - cmd: echo "___$((1 + 3))___" + sleep_before: 2 + # Executes immediately + - cmd: echo "___$((1 + 3))___" + # Pauses 2 seconds after + - cmd: echo "Stuff rendering here!" + sleep_after: 2 + # Executes after earlier commands (after 2 sec) + - cmd: echo "2 seconds later" diff --git a/examples/start-directory.json b/examples/start-directory.json index 68bb55b53c..2bc00033d5 100644 --- a/examples/start-directory.json +++ b/examples/start-directory.json @@ -51,9 +51,9 @@ { "shell_command": [ "echo '\\033c", - "./ is relative to config file location", - "../ will be parent of config file", - "./test will be \\\"test\\\" dir inside dir of config file'" + "./ is relative to workspace file location", + "../ will be parent of workspace file", + "./test will be \\\"test\\\" dir inside dir of workspace file'" ] }, { diff --git a/examples/start-directory.yaml b/examples/start-directory.yaml index 5c7051234c..dcecf67b87 100644 --- a/examples/start-directory.yaml +++ b/examples/start-directory.yaml @@ -4,37 +4,38 @@ windows: - window_name: should be /var/ panes: - shell_command: - - echo "\033c - - it trickles down from session-level" + - echo "\033c + - it trickles down from session-level" - echo hello - window_name: should be /var/log start_directory: log panes: - shell_command: - - echo '\033c - - window start_directory concatenates to session start_directory - - if it is not absolute' + - echo '\033c + - window start_directory concatenates to session start_directory + - if it is not absolute' - echo hello - window_name: should be ~ - start_directory: '~' + start_directory: "~" panes: - - shell_command: - - 'echo \\033c ~ has precedence. note: remember to quote ~ in YAML' - - echo hello + - shell_command: + - 'echo \\033c ~ has precedence. note: remember to quote ~ in YAML' + - echo hello - window_name: should be /bin start_directory: /bin panes: - - echo '\033c absolute paths also have precedence.' - - echo hello - - window_name: should be config's dir + - echo '\033c absolute paths also have precedence.' + - echo hello + - window_name: should be workspace file's dir + start_directory: ./ panes: - shell_command: - - echo '\033c - - ./ is relative to config file location - - ../ will be parent of config file - - ./test will be \"test\" dir inside dir of config file' + - echo '\033c + - ./ is relative to workspace file location + - ../ will be parent of workspace file + - ./test will be \"test\" dir inside dir of workspace file' - shell_command: - - echo '\033c - - This way you can load up workspaces from projects and maintain - - relative paths.' + - echo '\033c + - This way you can load up workspaces from projects and maintain + - relative paths.' diff --git a/examples/suppress-history.yaml b/examples/suppress-history.yaml index aa534d5a8a..6e12823f7c 100644 --- a/examples/suppress-history.yaml +++ b/examples/suppress-history.yaml @@ -1,29 +1,29 @@ session_name: suppress suppress_history: false windows: -- window_name: appended - focus: true - suppress_history: false - panes: - - echo "window in the history!" + - window_name: appended + focus: true + suppress_history: false + panes: + - echo "window in the history!" -- window_name: suppressed - suppress_history: true - panes: - - echo "window not in the history!" + - window_name: suppressed + suppress_history: true + panes: + - echo "window not in the history!" -- window_name: default - panes: - - echo "session in the history!" + - window_name: default + panes: + - echo "session in the history!" -- window_name: mixed - suppress_history: false - panes: - - shell_command: - - echo "command in the history!" + - window_name: mixed suppress_history: false - - shell_command: - - echo "command not in the history!" - suppress_history: true - - shell_command: - - echo "window in the history!" \ No newline at end of file + panes: + - shell_command: + - echo "command in the history!" + suppress_history: false + - shell_command: + - echo "command not in the history!" + suppress_history: true + - shell_command: + - echo "window in the history!" 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/poetry.lock b/poetry.lock deleted file mode 100644 index eea7516a30..0000000000 --- a/poetry.lock +++ /dev/null @@ -1,1449 +0,0 @@ -[[package]] -name = "aafigure" -version = "0.6" -description = "ASCII art to image converter" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "alabaster" -version = "0.7.12" -description = "A configurable sidebar-enabled Sphinx theme" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "atomicwrites" -version = "1.4.0" -description = "Atomic file writes." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "attrs" -version = "21.4.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] -docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] - -[[package]] -name = "babel" -version = "2.9.1" -description = "Internationalization utilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -pytz = ">=2015.7" - -[[package]] -name = "beautifulsoup4" -version = "4.10.0" -description = "Screen-scraping library" -category = "dev" -optional = false -python-versions = ">3.0.0" - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -html5lib = ["html5lib"] -lxml = ["lxml"] - -[[package]] -name = "black" -version = "22.3.0" -description = "The uncompromising code formatter." -category = "dev" -optional = false -python-versions = ">=3.6.2" - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} -typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "certifi" -version = "2021.10.8" -description = "Python package for providing Mozilla's CA Bundle." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "charset-normalizer" -version = "2.0.12" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "dev" -optional = false -python-versions = ">=3.5.0" - -[package.extras] -unicode_backport = ["unicodedata2"] - -[[package]] -name = "click" -version = "8.0.4" -description = "Composable command line interface toolkit" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - -[[package]] -name = "codecov" -version = "2.1.12" -description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -coverage = "*" -requests = ">=2.7.9" - -[[package]] -name = "colorama" -version = "0.4.4" -description = "Cross-platform colored terminal text." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "coverage" -version = "6.3.2" -description = "Code coverage measurement for Python" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "extra == \"toml\""} - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "docutils" -version = "0.17.1" -description = "Docutils -- Python Documentation Utilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "flake8" -version = "4.0.1" -description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""} -mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.8.0,<2.9.0" -pyflakes = ">=2.4.0,<2.5.0" - -[[package]] -name = "furo" -version = "2022.3.4" -description = "A clean customisable Sphinx documentation theme." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -beautifulsoup4 = "*" -pygments = ">=2.7,<3.0" -sphinx = ">=4.0,<5.0" - -[[package]] -name = "idna" -version = "3.3" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "dev" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "imagesize" -version = "1.3.0" -description = "Getting image size from png/jpeg/jpeg2000/gif file" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "importlib-metadata" -version = "4.2.0" -description = "Read metadata from Python packages" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] - -[[package]] -name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "isort" -version = "5.10.1" -description = "A Python utility / library to sort Python imports." -category = "dev" -optional = false -python-versions = ">=3.6.1,<4.0" - -[package.extras] -pipfile_deprecated_finder = ["pipreqs", "requirementslib"] -requirements_deprecated_finder = ["pipreqs", "pip-api"] -colors = ["colorama (>=0.4.3,<0.5.0)"] -plugins = ["setuptools"] - -[[package]] -name = "jinja2" -version = "3.1.1" -description = "A very fast and expressive template engine." -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "kaptan" -version = "0.5.12" -description = "Configuration manager" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -PyYAML = ">=3.13,<6" - -[[package]] -name = "libtmux" -version = "0.11.0" -description = "scripting library / orm for tmux" -category = "main" -optional = false -python-versions = ">=3.7,<4.0" - -[[package]] -name = "livereload" -version = "2.6.3" -description = "Python LiveReload is an awesome tool for web developers" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -six = "*" -tornado = {version = "*", markers = "python_version > \"2.7\""} - -[[package]] -name = "markdown-it-py" -version = "2.0.1" -description = "Python port of markdown-it. Markdown parsing, done right!" -category = "dev" -optional = false -python-versions = "~=3.6" - -[package.dependencies] -attrs = ">=19,<22" -mdurl = ">=0.1,<1.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark (>=3.2,<4.0)"] -code_style = ["pre-commit (==2.6)"] -compare = ["commonmark (>=0.9.1,<0.10.0)", "markdown (>=3.2.2,<3.3.0)", "mistletoe-ebp (>=0.10.0,<0.11.0)", "mistune (>=0.8.4,<0.9.0)", "panflute (>=1.12,<2.0)"] -linkify = ["linkify-it-py (>=1.0,<2.0)"] -plugins = ["mdit-py-plugins"] -rtd = ["myst-nb (==0.13.0a1)", "pyyaml", "sphinx (>=2,<4)", "sphinx-copybutton", "sphinx-panels (>=0.4.0,<0.5.0)", "sphinx-book-theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "2.1.1" -description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "mccabe" -version = "0.6.1" -description = "McCabe checker, plugin for flake8" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "mdit-py-plugins" -version = "0.3.0" -description = "Collection of plugins for markdown-it-py" -category = "dev" -optional = false -python-versions = "~=3.6" - -[package.dependencies] -markdown-it-py = ">=1.0.0,<3.0.0" - -[package.extras] -code_style = ["pre-commit (==2.6)"] -rtd = ["myst-parser (>=0.14.0,<0.15.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] -testing = ["coverage", "pytest (>=3.6,<4)", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "mdurl" -version = "0.1.0" -description = "Markdown URL utilities" -category = "dev" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "mypy-extensions" -version = "0.4.3" -description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "myst-parser" -version = "0.17.0" -description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -docutils = ">=0.15,<0.18" -jinja2 = "*" -markdown-it-py = ">=1.0.0,<3.0.0" -mdit-py-plugins = ">=0.3.0,<0.4.0" -pyyaml = "*" -sphinx = ">=3.1,<5" -typing-extensions = "*" - -[package.extras] -code_style = ["pre-commit (>=2.12,<3.0)"] -linkify = ["linkify-it-py (>=1.0,<2.0)"] -rtd = ["ipython", "sphinx-book-theme (>=0.1.0,<0.2.0)", "sphinx-panels (>=0.5.2,<0.6.0)", "sphinxcontrib-bibtex (>=2.1,<3.0)", "sphinxext-rediraffe (>=0.2,<1.0)", "sphinxcontrib.mermaid (>=0.6.3,<0.7.0)", "sphinxext-opengraph (>=0.4.2,<0.5.0)"] -testing = ["beautifulsoup4", "coverage", "docutils (>=0.17.0,<0.18.0)", "pytest (>=6,<7)", "pytest-cov", "pytest-regressions", "pytest-param-files (>=0.3.4,<0.4.0)"] - -[[package]] -name = "packaging" -version = "21.3" -description = "Core utilities for Python packages" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" - -[[package]] -name = "pathspec" -version = "0.9.0" -description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - -[[package]] -name = "pillow" -version = "9.1.0" -description = "Python Imaging Library (Fork)" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.extras] -docs = ["olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinx-rtd-theme (>=1.0)", "sphinxext-opengraph"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] - -[[package]] -name = "platformdirs" -version = "2.5.1" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.extras] -docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] -test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] - -[[package]] -name = "pluggy" -version = "1.0.0" -description = "plugin and hook calling mechanisms for python" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "pycodestyle" -version = "2.8.0" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "pyflakes" -version = "2.4.0" -description = "passive checker of Python programs" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "pygments" -version = "2.11.2" -description = "Pygments is a syntax highlighting package written in Python." -category = "dev" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "pyparsing" -version = "3.0.7" -description = "Python parsing module" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pytest" -version = "7.1.1" -description = "pytest: simple powerful testing with Python" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} -attrs = ">=19.2.0" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -tomli = ">=1.0.0" - -[package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] - -[[package]] -name = "pytest-cov" -version = "3.0.0" -description = "Pytest plugin for measuring coverage." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pytest-mock" -version = "3.7.0" -description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -pytest = ">=5.0" - -[package.extras] -dev = ["pre-commit", "tox", "pytest-asyncio"] - -[[package]] -name = "pytest-rerunfailures" -version = "10.2" -description = "pytest plugin to re-run tests to eliminate flaky failures" -category = "dev" -optional = false -python-versions = ">= 3.6" - -[package.dependencies] -pytest = ">=5.3" - -[[package]] -name = "pytest-watcher" -version = "0.2.3" -description = "Continiously runs pytest on changes in *.py files" -category = "dev" -optional = false -python-versions = ">=3.6.2,<4.0.0" - -[package.dependencies] -watchdog = ">=2.0.0" - -[[package]] -name = "pytz" -version = "2022.1" -description = "World timezone definitions, modern and historical" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "pyyaml" -version = "5.4.1" -description = "YAML parser and emitter for Python" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" - -[[package]] -name = "requests" -version = "2.27.1" -description = "Python HTTP for Humans." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} -idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} -urllib3 = ">=1.21.1,<1.27" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" - -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "soupsieve" -version = "2.3.1" -description = "A modern CSS selector implementation for Beautiful Soup." -category = "dev" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "sphinx" -version = "4.3.2" -description = "Python documentation generator" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -alabaster = ">=0.7,<0.8" -babel = ">=1.3" -colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.14,<0.18" -imagesize = "*" -Jinja2 = ">=2.3" -packaging = "*" -Pygments = ">=2.0" -requests = ">=2.5.0" -snowballstemmer = ">=1.1" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = ">=2.0.0" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = ">=1.1.5" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.920)", "docutils-stubs", "types-typed-ast", "types-pkg-resources", "types-requests"] -test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] - -[[package]] -name = "sphinx-autobuild" -version = "2021.3.14" -description = "Rebuild Sphinx documentation on changes, with live-reload in the browser." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -colorama = "*" -livereload = "*" -sphinx = "*" - -[package.extras] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "sphinx-autodoc-typehints" -version = "1.17.0" -description = "Type hints (PEP 484) support for the Sphinx autodoc extension" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -Sphinx = ">=4" - -[package.extras] -testing = ["covdefaults (>=2)", "coverage (>=6)", "diff-cover (>=6.4)", "nptyping (>=1)", "pytest (>=6)", "pytest-cov (>=3)", "sphobjinv (>=2)", "typing-extensions (>=3.5)"] -type_comments = ["typed-ast (>=1.4.0)"] - -[[package]] -name = "sphinx-click" -version = "3.1.0" -description = "Sphinx extension that automatically documents click applications" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -click = ">=7.0" -docutils = "*" -sphinx = ">=2.0" - -[[package]] -name = "sphinx-copybutton" -version = "0.5.0" -description = "Add a copy button to each of your code cells." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -sphinx = ">=1.8" - -[package.extras] -code_style = ["pre-commit (==2.12.1)"] -rtd = ["sphinx", "ipython", "myst-nb", "sphinx-book-theme"] - -[[package]] -name = "sphinx-inline-tabs" -version = "2021.4.11b8" -description = "Add inline tabbed content to your Sphinx documentation." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -sphinx = ">=3" - -[package.extras] -doc = ["myst-parser", "furo"] -test = ["pytest", "pytest-cov", "pytest-xdist"] - -[[package]] -name = "sphinx-issues" -version = "3.0.1" -description = "A Sphinx extension for linking to your project's issue tracker" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -sphinx = "*" - -[package.extras] -dev = ["pytest (>=6.2.0)", "flake8 (==3.9.2)", "flake8-bugbear (==20.11.1)", "pre-commit (>=2.7,<3.0)", "tox"] -lint = ["flake8 (==3.9.2)", "flake8-bugbear (==20.11.1)", "pre-commit (>=2.7,<3.0)"] -tests = ["pytest (>=6.2.0)"] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "1.0.2" -description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "1.0.2" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.0.0" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest", "html5lib"] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -description = "A sphinx extension which renders display math in HTML via JavaScript" -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "1.0.3" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "1.1.5" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -name = "sphinxext-opengraph" -version = "0.6.3" -description = "Sphinx Extension to enable OGP support" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -sphinx = ">=2.0" - -[[package]] -name = "sphinxext-rediraffe" -version = "0.2.7" -description = "Sphinx Extension that redirects non-existent pages to working pages." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -sphinx = ">=2.0" - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -category = "dev" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "tornado" -version = "6.1" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -category = "dev" -optional = false -python-versions = ">= 3.5" - -[[package]] -name = "typed-ast" -version = "1.5.2" -description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "typing-extensions" -version = "4.1.1" -description = "Backported and Experimental Type Hints for Python 3.6+" -category = "main" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "urllib3" -version = "1.26.9" -description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" - -[package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] - -[[package]] -name = "watchdog" -version = "2.1.7" -description = "Filesystem events monitoring" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -watchmedo = ["PyYAML (>=3.10)"] - -[[package]] -name = "zipp" -version = "3.7.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" -optional = false -python-versions = ">=3.7" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] - -[extras] -coverage = [] -docs = [] -format = [] -lint = [] -test = [] - -[metadata] -lock-version = "1.1" -python-versions = "^3.7" -content-hash = "2cb759bc5f40eaff068622d4ce864943b845dfadce268d161bd37820d1570767" - -[metadata.files] -aafigure = [ - {file = "aafigure-0.6-py2.py3-none-any.whl", hash = "sha256:c7f9409eee58d8484696a1c9e75b4c6ceab9fd060223007a19dcc6f515a30696"}, - {file = "aafigure-0.6.tar.gz", hash = "sha256:49f2c1fd2b579c1fffbac1386a2670b3f6f475cc7ff6cc04d8b984888c2d9e1e"}, -] -alabaster = [ - {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, - {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, -] -atomicwrites = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, -] -attrs = [ - {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, - {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, -] -babel = [ - {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, - {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, -] -beautifulsoup4 = [ - {file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"}, - {file = "beautifulsoup4-4.10.0.tar.gz", hash = "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"}, -] -black = [ - {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, - {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, - {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, - {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, - {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, - {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, - {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, - {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, - {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, - {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, - {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, - {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, - {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, - {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, - {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, - {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, - {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, - {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, - {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, -] -certifi = [ - {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, - {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, -] -charset-normalizer = [ - {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, - {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, -] -click = [ - {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, - {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, -] -codecov = [ - {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, - {file = "codecov-2.1.12-py3.8.egg", hash = "sha256:782a8e5352f22593cbc5427a35320b99490eb24d9dcfa2155fd99d2b75cfb635"}, - {file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"}, -] -colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, -] -coverage = [ - {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, - {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, - {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, - {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, - {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, - {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, - {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, - {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, - {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, - {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, - {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, - {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, - {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, -] -docutils = [ - {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, - {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, -] -flake8 = [ - {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, - {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, -] -furo = [ - {file = "furo-2022.3.4-py3-none-any.whl", hash = "sha256:6c718293ebf87755f0b9f148b1e697c9e3aabd7af955644d4bcaee5ce75db781"}, - {file = "furo-2022.3.4.tar.gz", hash = "sha256:7660267cc67b2828fd0e17bc07adeb612c47b2eba5a6de07049a1569e6044aa8"}, -] -idna = [ - {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, - {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, -] -imagesize = [ - {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, - {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, -] -importlib-metadata = [ - {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, - {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, -] -iniconfig = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, -] -isort = [ - {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, - {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, -] -jinja2 = [ - {file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"}, - {file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"}, -] -kaptan = [ - {file = "kaptan-0.5.12.tar.gz", hash = "sha256:1abd1f56731422fce5af1acc28801677a51e56f5d3c3e8636db761ed143c3dd2"}, -] -libtmux = [ - {file = "libtmux-0.11.0-py3-none-any.whl", hash = "sha256:f2b3fd683cd1bcb416efe311315991e80b6038209b9693b58baff5f0b0ed9a50"}, - {file = "libtmux-0.11.0.tar.gz", hash = "sha256:d82cf391097eb69d784d889d482bb99284b984aa6225276a3dc1af8c1460dd3c"}, -] -livereload = [ - {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, -] -markdown-it-py = [ - {file = "markdown-it-py-2.0.1.tar.gz", hash = "sha256:7b5c153ae1ab2cde00a33938bce68f3ad5d68fbe363f946de7d28555bed4e08a"}, - {file = "markdown_it_py-2.0.1-py3-none-any.whl", hash = "sha256:31974138ca8cafbcb62213f4974b29571b940e78364584729233f59b8dfdb8bd"}, -] -markupsafe = [ - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, - {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, -] -mccabe = [ - {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, - {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, -] -mdit-py-plugins = [ - {file = "mdit-py-plugins-0.3.0.tar.gz", hash = "sha256:ecc24f51eeec6ab7eecc2f9724e8272c2fb191c2e93cf98109120c2cace69750"}, - {file = "mdit_py_plugins-0.3.0-py3-none-any.whl", hash = "sha256:b1279701cee2dbf50e188d3da5f51fee8d78d038cdf99be57c6b9d1aa93b4073"}, -] -mdurl = [ - {file = "mdurl-0.1.0-py3-none-any.whl", hash = "sha256:40654d6dcb8d21501ed13c21cc0bd6fc42ff07ceb8be30029e5ae63ebc2ecfda"}, - {file = "mdurl-0.1.0.tar.gz", hash = "sha256:94873a969008ee48880fb21bad7de0349fef529f3be178969af5817239e9b990"}, -] -mypy-extensions = [ - {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, - {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, -] -myst-parser = [ - {file = "myst-parser-0.17.0.tar.gz", hash = "sha256:d412347a5cacb77ebc03d7f7ffef050cd61957d46f234313d350e84e24972260"}, - {file = "myst_parser-0.17.0-py3-none-any.whl", hash = "sha256:555ec2950aba5ae5dac5c162c7e9a43ad4a7291cfac644d8f5f84da8efa6f356"}, -] -packaging = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, -] -pathspec = [ - {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, - {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, -] -pillow = [ - {file = "Pillow-9.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:af79d3fde1fc2e33561166d62e3b63f0cc3e47b5a3a2e5fea40d4917754734ea"}, - {file = "Pillow-9.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:55dd1cf09a1fd7c7b78425967aacae9b0d70125f7d3ab973fadc7b5abc3de652"}, - {file = "Pillow-9.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66822d01e82506a19407d1afc104c3fcea3b81d5eb11485e593ad6b8492f995a"}, - {file = "Pillow-9.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5eaf3b42df2bcda61c53a742ee2c6e63f777d0e085bbc6b2ab7ed57deb13db7"}, - {file = "Pillow-9.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01ce45deec9df310cbbee11104bae1a2a43308dd9c317f99235b6d3080ddd66e"}, - {file = "Pillow-9.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:aea7ce61328e15943d7b9eaca87e81f7c62ff90f669116f857262e9da4057ba3"}, - {file = "Pillow-9.1.0-cp310-cp310-win32.whl", hash = "sha256:7a053bd4d65a3294b153bdd7724dce864a1d548416a5ef61f6d03bf149205160"}, - {file = "Pillow-9.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:97bda660702a856c2c9e12ec26fc6d187631ddfd896ff685814ab21ef0597033"}, - {file = "Pillow-9.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21dee8466b42912335151d24c1665fcf44dc2ee47e021d233a40c3ca5adae59c"}, - {file = "Pillow-9.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b6d4050b208c8ff886fd3db6690bf04f9a48749d78b41b7a5bf24c236ab0165"}, - {file = "Pillow-9.1.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5cfca31ab4c13552a0f354c87fbd7f162a4fafd25e6b521bba93a57fe6a3700a"}, - {file = "Pillow-9.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed742214068efa95e9844c2d9129e209ed63f61baa4d54dbf4cf8b5e2d30ccf2"}, - {file = "Pillow-9.1.0-cp37-cp37m-win32.whl", hash = "sha256:c9efef876c21788366ea1f50ecb39d5d6f65febe25ad1d4c0b8dff98843ac244"}, - {file = "Pillow-9.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:de344bcf6e2463bb25179d74d6e7989e375f906bcec8cb86edb8b12acbc7dfef"}, - {file = "Pillow-9.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:17869489de2fce6c36690a0c721bd3db176194af5f39249c1ac56d0bb0fcc512"}, - {file = "Pillow-9.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:25023a6209a4d7c42154073144608c9a71d3512b648a2f5d4465182cb93d3477"}, - {file = "Pillow-9.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8782189c796eff29dbb37dd87afa4ad4d40fc90b2742704f94812851b725964b"}, - {file = "Pillow-9.1.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:463acf531f5d0925ca55904fa668bb3461c3ef6bc779e1d6d8a488092bdee378"}, - {file = "Pillow-9.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f42364485bfdab19c1373b5cd62f7c5ab7cc052e19644862ec8f15bb8af289e"}, - {file = "Pillow-9.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3fddcdb619ba04491e8f771636583a7cc5a5051cd193ff1aa1ee8616d2a692c5"}, - {file = "Pillow-9.1.0-cp38-cp38-win32.whl", hash = "sha256:4fe29a070de394e449fd88ebe1624d1e2d7ddeed4c12e0b31624561b58948d9a"}, - {file = "Pillow-9.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:c24f718f9dd73bb2b31a6201e6db5ea4a61fdd1d1c200f43ee585fc6dcd21b34"}, - {file = "Pillow-9.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fb89397013cf302f282f0fc998bb7abf11d49dcff72c8ecb320f76ea6e2c5717"}, - {file = "Pillow-9.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c870193cce4b76713a2b29be5d8327c8ccbe0d4a49bc22968aa1e680930f5581"}, - {file = "Pillow-9.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69e5ddc609230d4408277af135c5b5c8fe7a54b2bdb8ad7c5100b86b3aab04c6"}, - {file = "Pillow-9.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35be4a9f65441d9982240e6966c1eaa1c654c4e5e931eaf580130409e31804d4"}, - {file = "Pillow-9.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82283af99c1c3a5ba1da44c67296d5aad19f11c535b551a5ae55328a317ce331"}, - {file = "Pillow-9.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a325ac71914c5c043fa50441b36606e64a10cd262de12f7a179620f579752ff8"}, - {file = "Pillow-9.1.0-cp39-cp39-win32.whl", hash = "sha256:a598d8830f6ef5501002ae85c7dbfcd9c27cc4efc02a1989369303ba85573e58"}, - {file = "Pillow-9.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0c51cb9edac8a5abd069fd0758ac0a8bfe52c261ee0e330f363548aca6893595"}, - {file = "Pillow-9.1.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a336a4f74baf67e26f3acc4d61c913e378e931817cd1e2ef4dfb79d3e051b481"}, - {file = "Pillow-9.1.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb1b89b11256b5b6cad5e7593f9061ac4624f7651f7a8eb4dfa37caa1dfaa4d0"}, - {file = "Pillow-9.1.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:255c9d69754a4c90b0ee484967fc8818c7ff8311c6dddcc43a4340e10cd1636a"}, - {file = "Pillow-9.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5a3ecc026ea0e14d0ad7cd990ea7f48bfcb3eb4271034657dc9d06933c6629a7"}, - {file = "Pillow-9.1.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5b0ff59785d93b3437c3703e3c64c178aabada51dea2a7f2c5eccf1bcf565a3"}, - {file = "Pillow-9.1.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7110ec1701b0bf8df569a7592a196c9d07c764a0a74f65471ea56816f10e2c8"}, - {file = "Pillow-9.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8d79c6f468215d1a8415aa53d9868a6b40c4682165b8cb62a221b1baa47db458"}, - {file = "Pillow-9.1.0.tar.gz", hash = "sha256:f401ed2bbb155e1ade150ccc63db1a4f6c1909d3d378f7d1235a44e90d75fb97"}, -] -platformdirs = [ - {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, - {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, -] -pluggy = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] -py = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] -pycodestyle = [ - {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, - {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, -] -pyflakes = [ - {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, - {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, -] -pygments = [ - {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, - {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, -] -pyparsing = [ - {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, - {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, -] -pytest = [ - {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, - {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, -] -pytest-cov = [ - {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, - {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, -] -pytest-mock = [ - {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"}, - {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"}, -] -pytest-rerunfailures = [ - {file = "pytest-rerunfailures-10.2.tar.gz", hash = "sha256:9e1e1bad51e07642c5bbab809fc1d4ec8eebcb7de86f90f1a26e6ef9de446697"}, - {file = "pytest_rerunfailures-10.2-py3-none-any.whl", hash = "sha256:d31d8e828dfd39363ad99cd390187bf506c7a433a89f15c3126c7d16ab723fe2"}, -] -pytest-watcher = [ - {file = "pytest-watcher-0.2.3.tar.gz", hash = "sha256:1937dd97e72caafd371d8cea7b3d70c88ff4fe35e6cdecb29c41bbdcbf1dcc2b"}, - {file = "pytest_watcher-0.2.3-py3-none-any.whl", hash = "sha256:af935963399509a5b0e855740ba7227852f1a7fccfbb1cbb79fa19a445af02d2"}, -] -pytz = [ - {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, - {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, -] -pyyaml = [ - {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, - {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, - {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, - {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, - {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, - {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, - {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, - {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, - {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, -] -requests = [ - {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, - {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, -] -six = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] -snowballstemmer = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] -soupsieve = [ - {file = "soupsieve-2.3.1-py3-none-any.whl", hash = "sha256:1a3cca2617c6b38c0343ed661b1fa5de5637f257d4fe22bd9f1338010a1efefb"}, - {file = "soupsieve-2.3.1.tar.gz", hash = "sha256:b8d49b1cd4f037c7082a9683dfa1801aa2597fb11c3a1155b7a5b94829b4f1f9"}, -] -sphinx = [ - {file = "Sphinx-4.3.2-py3-none-any.whl", hash = "sha256:6a11ea5dd0bdb197f9c2abc2e0ce73e01340464feaece525e64036546d24c851"}, - {file = "Sphinx-4.3.2.tar.gz", hash = "sha256:0a8836751a68306b3fe97ecbe44db786f8479c3bf4b80e3a7f5c838657b4698c"}, -] -sphinx-autobuild = [ - {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"}, - {file = "sphinx_autobuild-2021.3.14-py3-none-any.whl", hash = "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac"}, -] -sphinx-autodoc-typehints = [ - {file = "sphinx_autodoc_typehints-1.17.0-py3-none-any.whl", hash = "sha256:081daf53077b4ae1c28347d6d858e13e63aefe3b4aacef79fd717dd60687b470"}, - {file = "sphinx_autodoc_typehints-1.17.0.tar.gz", hash = "sha256:51c7b3f5cb9ccd15d0b52088c62df3094f1abd9612930340365c26def8629a14"}, -] -sphinx-click = [ - {file = "sphinx-click-3.1.0.tar.gz", hash = "sha256:36dbf271b1d2600fb05bd598ddeed0b6b6acf35beaf8bc9d507ba7716b232b0e"}, - {file = "sphinx_click-3.1.0-py3-none-any.whl", hash = "sha256:8fb0b048a577d346d741782e44d041d7e908922858273d99746f305870116121"}, -] -sphinx-copybutton = [ - {file = "sphinx-copybutton-0.5.0.tar.gz", hash = "sha256:a0c059daadd03c27ba750da534a92a63e7a36a7736dcf684f26ee346199787f6"}, - {file = "sphinx_copybutton-0.5.0-py3-none-any.whl", hash = "sha256:9684dec7434bd73f0eea58dda93f9bb879d24bff2d8b187b1f2ec08dfe7b5f48"}, -] -sphinx-inline-tabs = [ - {file = "sphinx_inline_tabs-2021.4.11b8-py3-none-any.whl", hash = "sha256:efd6e7ad576a6bc1c616cbaa9b0e6f6fe2b28a776947069ed8d6037667799808"}, - {file = "sphinx_inline_tabs-2021.4.11b8.tar.gz", hash = "sha256:3c4d7759cbbb7752b7e7acd96ed0ea2c58fcc4ae38891a718544b931a5a4818f"}, -] -sphinx-issues = [ - {file = "sphinx-issues-3.0.1.tar.gz", hash = "sha256:b7c1dc1f4808563c454d11c1112796f8c176cdecfee95f0fd2302ef98e21e3d6"}, - {file = "sphinx_issues-3.0.1-py3-none-any.whl", hash = "sha256:8b25dc0301159375468f563b3699af7a63720fd84caf81c1442036fcd418b20c"}, -] -sphinxcontrib-applehelp = [ - {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, - {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, -] -sphinxcontrib-devhelp = [ - {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, - {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, -] -sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, - {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, -] -sphinxcontrib-jsmath = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] -sphinxcontrib-qthelp = [ - {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, - {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, -] -sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, - {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, -] -sphinxext-opengraph = [ - {file = "sphinxext-opengraph-0.6.3.tar.gz", hash = "sha256:cd89e13cc7a44739f81b64ee57c1c20ef0c05dda5d1d8201d31ec2f34e4c29db"}, - {file = "sphinxext_opengraph-0.6.3-py3-none-any.whl", hash = "sha256:bf76017c105856b07edea6caf4942b6ae9bb168585dccfd6dbdb6e4161f6b03a"}, -] -sphinxext-rediraffe = [ - {file = "sphinxext-rediraffe-0.2.7.tar.gz", hash = "sha256:651dcbfae5ffda9ffd534dfb8025f36120e5efb6ea1a33f5420023862b9f725d"}, - {file = "sphinxext_rediraffe-0.2.7-py3-none-any.whl", hash = "sha256:9e430a52d4403847f4ffb3a8dd6dfc34a9fe43525305131f52ed899743a5fd8c"}, -] -tomli = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] -tornado = [ - {file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"}, - {file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"}, - {file = "tornado-6.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05"}, - {file = "tornado-6.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910"}, - {file = "tornado-6.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b"}, - {file = "tornado-6.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675"}, - {file = "tornado-6.1-cp35-cp35m-win32.whl", hash = "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5"}, - {file = "tornado-6.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68"}, - {file = "tornado-6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb"}, - {file = "tornado-6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c"}, - {file = "tornado-6.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921"}, - {file = "tornado-6.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558"}, - {file = "tornado-6.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c"}, - {file = "tornado-6.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085"}, - {file = "tornado-6.1-cp36-cp36m-win32.whl", hash = "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575"}, - {file = "tornado-6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795"}, - {file = "tornado-6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f"}, - {file = "tornado-6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102"}, - {file = "tornado-6.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4"}, - {file = "tornado-6.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd"}, - {file = "tornado-6.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01"}, - {file = "tornado-6.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d"}, - {file = "tornado-6.1-cp37-cp37m-win32.whl", hash = "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df"}, - {file = "tornado-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37"}, - {file = "tornado-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95"}, - {file = "tornado-6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a"}, - {file = "tornado-6.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"}, - {file = "tornado-6.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288"}, - {file = "tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f"}, - {file = "tornado-6.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6"}, - {file = "tornado-6.1-cp38-cp38-win32.whl", hash = "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326"}, - {file = "tornado-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c"}, - {file = "tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5"}, - {file = "tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe"}, - {file = "tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea"}, - {file = "tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2"}, - {file = "tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0"}, - {file = "tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd"}, - {file = "tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c"}, - {file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"}, - {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"}, -] -typed-ast = [ - {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"}, - {file = "typed_ast-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596"}, - {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985"}, - {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76"}, - {file = "typed_ast-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a"}, - {file = "typed_ast-1.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837"}, - {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78"}, - {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e"}, - {file = "typed_ast-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d"}, - {file = "typed_ast-1.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd"}, - {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88"}, - {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7"}, - {file = "typed_ast-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30"}, - {file = "typed_ast-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4"}, - {file = "typed_ast-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca"}, - {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb"}, - {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b"}, - {file = "typed_ast-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7"}, - {file = "typed_ast-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098"}, - {file = "typed_ast-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344"}, - {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e"}, - {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e"}, - {file = "typed_ast-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5"}, - {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, -] -typing-extensions = [ - {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, - {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, -] -urllib3 = [ - {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, - {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, -] -watchdog = [ - {file = "watchdog-2.1.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:177bae28ca723bc00846466016d34f8c1d6a621383b6caca86745918d55c7383"}, - {file = "watchdog-2.1.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1d1cf7dfd747dec519486a98ef16097e6c480934ef115b16f18adb341df747a4"}, - {file = "watchdog-2.1.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7f14ce6adea2af1bba495acdde0e510aecaeb13b33f7bd2f6324e551b26688ca"}, - {file = "watchdog-2.1.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4d0e98ac2e8dd803a56f4e10438b33a2d40390a72750cff4939b4b274e7906fa"}, - {file = "watchdog-2.1.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:81982c7884aac75017a6ecc72f1a4fedbae04181a8665a34afce9539fc1b3fab"}, - {file = "watchdog-2.1.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0b4a1fe6201c6e5a1926f5767b8664b45f0fcb429b62564a41f490ff1ce1dc7a"}, - {file = "watchdog-2.1.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6e6ae29b72977f2e1ee3d0b760d7ee47896cb53e831cbeede3e64485e5633cc8"}, - {file = "watchdog-2.1.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b9777664848160449e5b4260e0b7bc1ae0f6f4992a8b285db4ec1ef119ffa0e2"}, - {file = "watchdog-2.1.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:19b36d436578eb437e029c6b838e732ed08054956366f6dd11875434a62d2b99"}, - {file = "watchdog-2.1.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b61acffaf5cd5d664af555c0850f9747cc5f2baf71e54bbac164c58398d6ca7b"}, - {file = "watchdog-2.1.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1e877c70245424b06c41ac258023ea4bd0c8e4ff15d7c1368f17cd0ae6e351dd"}, - {file = "watchdog-2.1.7-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d802d65262a560278cf1a65ef7cae4e2bc7ecfe19e5451349e4c67e23c9dc420"}, - {file = "watchdog-2.1.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b3750ee5399e6e9c69eae8b125092b871ee9e2fcbd657a92747aea28f9056a5c"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_aarch64.whl", hash = "sha256:ed6d9aad09a2a948572224663ab00f8975fae242aa540509737bb4507133fa2d"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_armv7l.whl", hash = "sha256:b26e13e8008dcaea6a909e91d39b629a39635d1a8a7239dd35327c74f4388601"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_i686.whl", hash = "sha256:0908bb50f6f7de54d5d31ec3da1654cb7287c6b87bce371954561e6de379d690"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_ppc64.whl", hash = "sha256:bdcbf75580bf4b960fb659bbccd00123d83119619195f42d721e002c1621602f"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:81a5861d0158a7e55fe149335fb2bbfa6f48cbcbd149b52dbe2cd9a544034bbd"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_s390x.whl", hash = "sha256:03b43d583df0f18782a0431b6e9e9965c5b3f7cf8ec36a00b930def67942c385"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ae934e34c11aa8296c18f70bf66ed60e9870fcdb4cc19129a04ca83ab23e7055"}, - {file = "watchdog-2.1.7-py3-none-win32.whl", hash = "sha256:49639865e3db4be032a96695c98ac09eed39bbb43fe876bb217da8f8101689a6"}, - {file = "watchdog-2.1.7-py3-none-win_amd64.whl", hash = "sha256:340b875aecf4b0e6672076a6f05cfce6686935559bb6d34cebedee04126a9566"}, - {file = "watchdog-2.1.7-py3-none-win_ia64.whl", hash = "sha256:351e09b6d9374d5bcb947e6ac47a608ec25b9d70583e9db00b2fcdb97b00b572"}, - {file = "watchdog-2.1.7.tar.gz", hash = "sha256:3fd47815353be9c44eebc94cc28fe26b2b0c5bd889dafc4a5a7cbdf924143480"}, -] -zipp = [ - {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, - {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, -] diff --git a/poetry.toml b/poetry.toml deleted file mode 100644 index ab1033bd37..0000000000 --- a/poetry.toml +++ /dev/null @@ -1,2 +0,0 @@ -[virtualenvs] -in-project = true diff --git a/pyproject.toml b/pyproject.toml index a5be3b5af9..2dee3574d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,12 @@ -[tool.poetry] +[project] name = "tmuxp" -version = "1.11.0b1" -description = "tmux session manager" -license = "MIT" -authors = ["Tony Narlock "] +version = "1.74.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 = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", @@ -12,86 +15,249 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Programming Language :: Python", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Utilities", - "Topic :: System :: Shells" + "Topic :: System :: Shells", ] keywords = ["tmux", "session manager", "terminal", "ncurses"] homepage = "http://github.com/tmux-python/tmuxp/" readme = "README.md" packages = [ - { include = "tmuxp" } + { include = "*", from = "src" }, ] include = [ - { path = "CHANGES", format = "sdist" }, - { path = ".tmuxp.yaml", format = "sdist" }, - { path = ".tmuxp-before-script.sh", format = "sdist" }, - { path = "tests", format = "sdist" }, - { path = "examples", format = "sdist" }, - { path = "docs", format = "sdist" }, + { path = "CHANGES", format = "sdist" }, + { path = ".tmuxp.yaml", format = "sdist" }, + { path = "tests", format = "sdist" }, + { path = "examples", format = "sdist" }, + { path = "docs", format = "sdist" }, + { path = "conftest.py", format = "sdist" }, +] +dependencies = [ + "libtmux~=0.61.0", + "PyYAML>=6.0" ] -[tool.poetry.urls] +[project.urls] "Bug Tracker" = "https://github.com/tmux-python/tmuxp/issues" Documentation = "https://tmuxp.git-pull.com" Repository = "https://github.com/tmux-python/tmuxp" Changes = "https://github.com/tmux-python/tmuxp/blob/master/CHANGES" -[tool.poetry.scripts] +[project.scripts] tmuxp = 'tmuxp:cli.cli' -[tool.poetry.dependencies] -python = "^3.7" -click = ">7,<8.1" -kaptan = ">=0.5.10" -libtmux = "~0.11.0" -colorama = ">=0.3.9" - -[tool.poetry.dev-dependencies] -### Docs ### -sphinx = "*" -aafigure = ">=0.6" -pillow = "*" -furo = "^2022.2.23" -sphinx-autobuild = "^2021.3.14" -sphinx-autodoc-typehints = "~1.17.0" -sphinx-click = "*" -sphinx-issues = "^3.0.0" -sphinx-inline-tabs = { version = "*", python = "^3.7" } -sphinxext-opengraph = "*" -sphinx-copybutton = "^0.5.0" -sphinxext-rediraffe = "*" -myst_parser = "~0.17.0" - -### Testing ### -pytest = "*" -pytest-rerunfailures = "*" -pytest-mock = "*" -pytest-cov = "*" -pytest-watcher = "^0.2.3" - -### Coverage ### -codecov = "*" -coverage = "*" - -### Format ### -black = "*" -isort = "*" - -### Lint ### -flake8 = "*" - -[tool.poetry.extras] -docs = ["sphinx", "sphinx-issues", "sphinx-click", "sphinx-autodoc-typehints", "sphinx-autobuild", "sphinx-copybutton", "sphinxext-opengraph", "sphinx-inline-tabs", "sphinxext-rediraffe", "myst_parser", "furo", "aafigure", "pillow"] -test = ["pytest", "pytest-rerunfailures", "pytest-mock", "pytest-watcher"] -coverage = ["codecov", "coverage", "pytest-cov"] -format = ["black", "isort"] -lint = ["flake8"] +[project.entry-points."tmuxp.workspace_builders"] +classic = "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + +[dependency-groups] +dev = [ + # Docs + "aafigure", # https://launchpad.net/aafigure + "pillow", # https://pillow.readthedocs.io/ + "gp-sphinx==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-autodoc-argparse==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-autodoc-api-style==0.0.1a35", + "sphinx-gp-highlighting==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-gp-mermaid==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "gp-libs>=0.0.19", # https://gp-libs.git-pull.com/ + "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html + # Testing + "gp-libs>=0.0.19", + "pytest", + "pytest-rerunfailures", + "pytest-mock", + "pytest-watcher", + # Coverage + "codecov", + "coverage", + "pytest-cov", + # Lint + "ruff", + "mypy", + "types-docutils", + "types-Pygments", + "types-PyYAML", +] + +docs = [ + "aafigure", # https://launchpad.net/aafigure + "pillow", # https://pillow.readthedocs.io/ + "gp-sphinx==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-autodoc-argparse==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-autodoc-api-style==0.0.1a35", + "sphinx-gp-highlighting==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "sphinx-gp-mermaid==0.0.1a35", # https://gp-sphinx.git-pull.com/ + "gp-libs>=0.0.19", # https://gp-libs.git-pull.com/ + "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html +] +testing = [ + "gp-libs>=0.0.19", + "pytest", + "pytest-rerunfailures", + "pytest-mock", + "pytest-watcher", +] +coverage =[ + "codecov", + "coverage", + "pytest-cov", +] +lint = [ + "ruff", + "mypy", + "types-docutils", + "types-Pygments", + "types-PyYAML", +] [build-system] -requires = ["poetry_core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv.exclude-newer-package] +# git-pull packages release in lockstep with their workspaces, so a +# fresh release blocking on the 3-day cooldown blocks every +# contributor's `uv sync` until it clears. `false` exempts each +# from any `exclude-newer` constraint (global or per-user) without +# committing a date that would age into the lockfile. Mirrors the +# pattern at vcspull/pyproject.toml for libvcs. +gp-libs = false +gp-furo-theme = false +gp-sphinx = false +libtmux = false +sphinx-autodoc-api-style = false +sphinx-autodoc-argparse = false +sphinx-autodoc-docutils = false +sphinx-autodoc-fastmcp = false +sphinx-autodoc-pytest-fixtures = false +sphinx-autodoc-sphinx = false +sphinx-autodoc-typehints-gp = false +sphinx-fonts = false +sphinx-gp-highlighting = false +sphinx-gp-mermaid = false +sphinx-gp-opengraph = false +sphinx-gp-llms = false +sphinx-gp-sitemap = false +sphinx-gp-theme = false +sphinx-ux-autodoc-layout = false +sphinx-ux-badges = false + +[tool.coverage.run] +branch = true +source = [ + "tmuxp", +] +omit = [ + "tests/*", + "*/_vendor/*", + "*/_*", + "pkg/*", + "*/log.py", + "docs/_ext/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "def parse_args", + "from __future__ import annotations", + "if TYPE_CHECKING:", + "if t.TYPE_CHECKING:", +] + +[tool.mypy] +strict = true +python_version = "3.10" +files = [ + "src/", + "tests/", +] +enable_incomplete_feature = [] + +[[tool.mypy.overrides]] +module = [ + "shtab", + "IPython.*", + "ptpython.*", + "prompt_toolkit.*", + "bpython", + "aafigure", +] +ignore_missing_imports = true + +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "A", # flake8-builtins + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "COM", # flake8-commas + "EM", # flake8-errmsg + "Q", # flake8-quotes + "PTH", # flake8-use-pathlib + "SIM", # flake8-simplify + "TRY", # Trycertatops + "PERF", # Perflint + "RUF", # Ruff-specific rules + "D", # pydocstyle + "FA100", # future annotations +] +ignore = [ + "COM812", # missing trailing comma, ruff format conflict +] +extend-safe-fixes = [ + "UP006", + "UP007", +] +pyupgrade.keep-runtime-typing = false + +[tool.ruff.lint.isort] +known-first-party = [ + "tmuxp", +] +combine-as-imports = true +required-imports = [ + "from __future__ import annotations", +] + +[tool.ruff.lint.flake8-builtins] +builtins-allowed-modules = [ + "types", +] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.ruff.lint.per-file-ignores] +"*/__init__.py" = ["F401"] +"src/tmuxp/workspace/finders.py" = ["PTH"] +"src/tmuxp/cli/*.py" = ["PTH"] +"docs/_ext/aafig.py" = ["PTH"] + +[tool.pytest.ini_options] +addopts = "--reruns=0 --tb=short --no-header --showlocals --doctest-modules" +doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE" +testpaths = [ + "src/tmuxp", + "tests", + "docs", +] +filterwarnings = [ + "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", + "ignore:.*invalid escape sequence.*:SyntaxWarning:.*aafigure.*:", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index d77dcccfba..0000000000 --- a/setup.cfg +++ /dev/null @@ -1,22 +0,0 @@ -[flake8] -exclude = .*/,*.egg,tmuxp/_compat.py,tmuxp/__*__.py, -select = E,W,F,N -max-line-length = 88 -# Stuff we ignore thanks to black: https://github.com/ambv/black/issues/429 -extend-ignore = E203 - -[tool:pytest] -addopts = --reruns=0 -filterwarnings = - ignore:distutils Version classes are deprecated. Use packaging.version instead. - -[isort] -profile = black -combine_as_imports= true -default_section = THIRDPARTY -include_trailing_comma = true -multi_line_output = 3 -known_pytest = pytest,py -known_first_party = libtmux,tmuxp -sections = FUTURE,STDLIB,PYTEST,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -line_length = 88 diff --git a/tmuxp/__about__.py b/src/tmuxp/__about__.py similarity index 76% rename from tmuxp/__about__.py rename to src/tmuxp/__about__.py index 1dfdefbd71..6356e4c242 100644 --- a/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -1,6 +1,14 @@ +"""Metadata for tmuxp package.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.11.0b1" +__version__ = "1.74.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/tmuxp/__init__.py b/src/tmuxp/__init__.py similarity index 64% rename from tmuxp/__init__.py rename to src/tmuxp/__init__.py index 145a640bc7..78e2118ea5 100644 --- a/tmuxp/__init__.py +++ b/src/tmuxp/__init__.py @@ -1,14 +1,14 @@ -# flake8: NOQA """tmux session manager. -tmuxp -~~~~~ - :copyright: Copyright 2013- Tony Narlock. :license: MIT, see LICENSE for details - """ -from . import cli, config, util + +from __future__ import annotations + +import logging + +from . import cli, util from .__about__ import ( __author__, __copyright__, @@ -19,3 +19,6 @@ __title__, __version__, ) + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) diff --git a/src/tmuxp/_compat.py b/src/tmuxp/_compat.py new file mode 100644 index 0000000000..51a055f7f5 --- /dev/null +++ b/src/tmuxp/_compat.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import logging +import sys + +logger = logging.getLogger(__name__) + +PY3 = sys.version_info[0] == 3 +PYMINOR = sys.version_info[1] +PYPATCH = sys.version_info[2] + + +def _identity(x: object) -> object: + """Return *x* unchanged — used as a no-op decorator. + + Examples + -------- + >>> from tmuxp._compat import _identity + + Strings pass through unchanged: + + >>> _identity("hello") + 'hello' + + Integers pass through unchanged: + + >>> _identity(42) + 42 + """ + return x + + +if PY3 and PYMINOR >= 7: + breakpoint = breakpoint # noqa: A001 +else: + import pdb + + breakpoint = pdb.set_trace # noqa: A001 + + +implements_to_string = _identity diff --git a/src/tmuxp/_internal/__init__.py b/src/tmuxp/_internal/__init__.py new file mode 100644 index 0000000000..baae40fd2c --- /dev/null +++ b/src/tmuxp/_internal/__init__.py @@ -0,0 +1,7 @@ +"""Internal APIs for tmuxp.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py new file mode 100644 index 0000000000..a9b350016f --- /dev/null +++ b/src/tmuxp/_internal/colors.py @@ -0,0 +1,904 @@ +"""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 logging +import os +import re +import sys +import typing as t + +logger = logging.getLogger(__name__) + +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_rule(self, width: int = 40, char: str = "─") -> str: + """Format a horizontal rule using Unicode box-drawing characters. + + A richer alternative to ``format_separator()`` which uses plain hyphens. + + Parameters + ---------- + width : int + Number of characters. Default is 40. + char : str + Character to repeat. Default is ``"─"`` (U+2500). + + Returns + ------- + str + Muted (blue) rule when colors enabled, plain rule otherwise. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_rule(10) + '──────────' + >>> colors.format_rule(5, char="=") + '=====' + """ + return self.muted(char * width) + + def format_kv(self, key: str, value: str) -> str: + """Format key: value pair with syntax highlighting. + + 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]") +ANSI_SEQ_RE = _ansi_re + + +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 examples:\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} examples:" + 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/config_reader.py b/src/tmuxp/_internal/config_reader.py new file mode 100644 index 0000000000..6da248dea7 --- /dev/null +++ b/src/tmuxp/_internal/config_reader.py @@ -0,0 +1,212 @@ +"""Configuration parser for YAML and JSON files.""" + +from __future__ import annotations + +import json +import logging +import pathlib +import typing as t + +import yaml + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + FormatLiteral = t.Literal["json", "yaml"] + + RawConfigData: TypeAlias = dict[t.Any, t.Any] + + +class ConfigReader: + r"""Parse string data (YAML and JSON) into a dictionary. + + >>> cfg = ConfigReader({ "session_name": "my session" }) + >>> cfg.dump("yaml") + 'session_name: my session\n' + >>> cfg.dump("json") + '{\n "session_name": "my session"\n}' + """ + + def __init__(self, content: RawConfigData) -> None: + self.content = content + + @staticmethod + def _load(fmt: FormatLiteral, content: str) -> dict[str, t.Any]: + """Load raw config data and directly return it. + + >>> ConfigReader._load("json", '{ "session_name": "my session" }') + {'session_name': 'my session'} + + >>> ConfigReader._load("yaml", 'session_name: my session') + {'session_name': 'my session'} + """ + if fmt == "yaml": + return t.cast( + "dict[str, t.Any]", + yaml.load( + content, + Loader=yaml.SafeLoader, + ), + ) + if fmt == "json": + return t.cast("dict[str, t.Any]", json.loads(content)) + msg = f"{fmt} not supported in configuration" + raise NotImplementedError(msg) + + @classmethod + def load(cls, fmt: FormatLiteral, content: str) -> ConfigReader: + """Load raw config data into a ConfigReader instance (to dump later). + + >>> cfg = ConfigReader.load("json", '{ "session_name": "my session" }') + >>> cfg + + >>> cfg.content + {'session_name': 'my session'} + + >>> cfg = ConfigReader.load("yaml", 'session_name: my session') + >>> cfg + + >>> cfg.content + {'session_name': 'my session'} + """ + return cls( + content=cls._load( + fmt=fmt, + content=content, + ), + ) + + @classmethod + def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: + r"""Load data from file path directly to dictionary. + + **YAML file** + + *For demonstration only,* create a YAML file: + + >>> yaml_file = tmp_path / 'my_config.yaml' + >>> yaml_file.write_text('session_name: my session', encoding='utf-8') + 24 + + *Read YAML file*: + + >>> ConfigReader._from_file(yaml_file) + {'session_name': 'my session'} + + **JSON file** + + *For demonstration only,* create a JSON file: + + >>> json_file = tmp_path / 'my_config.json' + >>> json_file.write_text('{"session_name": "my session"}', encoding='utf-8') + 30 + + *Read JSON file*: + + >>> ConfigReader._from_file(json_file) + {'session_name': 'my session'} + """ + assert isinstance(path, pathlib.Path) + logger.debug("loading config", extra={"tmux_config_path": str(path)}) + content = path.open(encoding="utf-8").read() + + if path.suffix in {".yaml", ".yml"}: + fmt: FormatLiteral = "yaml" + elif path.suffix == ".json": + fmt = "json" + else: + msg = f"{path.suffix} not supported in {path}" + raise NotImplementedError(msg) + + return cls._load( + fmt=fmt, + content=content, + ) + + @classmethod + def from_file(cls, path: pathlib.Path) -> ConfigReader: + r"""Load data from file path. + + **YAML file** + + *For demonstration only,* create a YAML file: + + >>> yaml_file = tmp_path / 'my_config.yaml' + >>> yaml_file.write_text('session_name: my session', encoding='utf-8') + 24 + + *Read YAML file*: + + >>> cfg = ConfigReader.from_file(yaml_file) + >>> cfg + + + >>> cfg.content + {'session_name': 'my session'} + + **JSON file** + + *For demonstration only,* create a JSON file: + + >>> json_file = tmp_path / 'my_config.json' + >>> json_file.write_text('{"session_name": "my session"}', encoding='utf-8') + 30 + + *Read JSON file*: + + >>> cfg = ConfigReader.from_file(json_file) + >>> cfg + + + >>> cfg.content + {'session_name': 'my session'} + """ + return cls(content=cls._from_file(path=path)) + + @staticmethod + def _dump( + fmt: FormatLiteral, + content: RawConfigData, + indent: int = 2, + **kwargs: t.Any, + ) -> str: + r"""Dump directly. + + >>> ConfigReader._dump("yaml", { "session_name": "my session" }) + 'session_name: my session\n' + + >>> ConfigReader._dump("json", { "session_name": "my session" }) + '{\n "session_name": "my session"\n}' + """ + if fmt == "yaml": + return yaml.dump( + content, + indent=2, + default_flow_style=False, + Dumper=yaml.SafeDumper, + ) + if fmt == "json": + return json.dumps( + content, + indent=2, + ) + msg = f"{fmt} not supported in config" + raise NotImplementedError(msg) + + def dump(self, fmt: FormatLiteral, indent: int = 2, **kwargs: t.Any) -> str: + r"""Dump via ConfigReader instance. + + >>> cfg = ConfigReader({ "session_name": "my session" }) + >>> cfg.dump("yaml") + 'session_name: my session\n' + >>> cfg.dump("json") + '{\n "session_name": "my session"\n}' + """ + return self._dump( + fmt=fmt, + content=self.content, + indent=indent, + **kwargs, + ) diff --git a/src/tmuxp/_internal/private_path.py b/src/tmuxp/_internal/private_path.py new file mode 100644 index 0000000000..8fa0cec972 --- /dev/null +++ b/src/tmuxp/_internal/private_path.py @@ -0,0 +1,143 @@ +"""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 logging +import os +import pathlib +import typing as t + +logger = logging.getLogger(__name__) + +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/_internal/types.py b/src/tmuxp/_internal/types.py new file mode 100644 index 0000000000..41498cebd0 --- /dev/null +++ b/src/tmuxp/_internal/types.py @@ -0,0 +1,40 @@ +"""Internal, :const:`typing.TYPE_CHECKING` guarded :term:`typings `. + +These are _not_ to be imported at runtime as `typing_extensions` is not +bundled with tmuxp. Usage example: + +>>> import typing as t + +>>> if t.TYPE_CHECKING: +... from tmuxp._internal.types import PluginConfigSchema +... +""" + +from __future__ import annotations + +import logging +import typing as t +from typing import TypedDict + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 11): + from typing import NotRequired + else: + from typing_extensions import NotRequired + + +class PluginConfigSchema(TypedDict): + plugin_name: NotRequired[str] + tmux_min_version: NotRequired[str] + tmux_max_version: NotRequired[str] + tmux_version_incompatible: NotRequired[list[str]] + libtmux_min_version: NotRequired[str] + libtmux_max_version: NotRequired[str] + libtmux_version_incompatible: NotRequired[list[str]] + tmuxp_min_version: NotRequired[str] + tmuxp_max_version: NotRequired[str] + tmuxp_version_incompatible: NotRequired[list[str]] diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py new file mode 100644 index 0000000000..860a9200cb --- /dev/null +++ b/src/tmuxp/cli/__init__.py @@ -0,0 +1,377 @@ +"""CLI utilities for tmuxp.""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +import typing as t + +from libtmux.__about__ import __version__ as libtmux_version +from libtmux.common import has_minimum_version +from libtmux.exc import TmuxCommandNotFound + +from tmuxp import exc +from tmuxp.__about__ import __version__ +from tmuxp.log import setup_logger + +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 ( + 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", + "freeze", + "convert", + "edit", + "import", + "search", + "shell", + "debug-info", + ] + CLIImportSubparserName: TypeAlias = t.Literal["teamocil", "tmuxinator"] + + +def create_parser() -> argparse.ArgumentParser: + """Create CLI :class:`argparse.ArgumentParser` for 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", + action="version", + version=f"%(prog)s {__version__}, libtmux {libtmux_version}", + ) + parser.add_argument( + "--log-level", + action="store", + metavar="log-level", + default="warning", + choices=["debug", "info", "warning", "error", "critical"], + help='log level (debug, info, warning, error, critical) (default "warning")', + ) + 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", + 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", + description=LS_DESCRIPTION, + formatter_class=formatter_class, + ) + create_ls_subparser(ls_parser) + + 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) + + return parser + + +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 + + +ns = CLINamespace() + + +def cli(_args: list[str] | None = None) -> None: + """Manage tmux sessions. + + Pass the "--help" argument to any command to see detailed help. + See detailed documentation and examples at: + http://tmuxp.git-pull.com/ + """ + try: + has_minimum_version() + except TmuxCommandNotFound: + tmuxp_echo("tmux not found. tmuxp requires you install tmux first.") + sys.exit() + except exc.TmuxpException as e: + tmuxp_echo(str(e)) + sys.exit() + + parser = create_parser() + args = parser.parse_args(_args, namespace=ns) + + setup_logger(level=args.log_level.upper()) + + if args.subparser_name is None: + parser.print_help() + return + if args.subparser_name == "load": + command_load( + args=CLILoadNamespace(**vars(args)), + parser=parser, + ) + elif args.subparser_name == "shell": + command_shell( + args=CLIShellNamespace(**vars(args)), + parser=parser, + ) + elif args.subparser_name == "import": + import_subparser_name = getattr(args, "import_subparser_name", None) + if import_subparser_name is None: + parser.print_help() + return + if import_subparser_name == "teamocil": + 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( + 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( + args=CLIFreezeNamespace(**vars(args)), + parser=parser, + ) + elif args.subparser_name == "ls": + 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: + """ + Initialize CLI. + + Parameters + ---------- + str : get_workspace_dir(): Config directory to search + """ + if not os.path.exists(config_dir): + os.makedirs(config_dir) diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py new file mode 100644 index 0000000000..31dab82076 --- /dev/null +++ b/src/tmuxp/cli/_colors.py @@ -0,0 +1,38 @@ +"""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 + +import logging + +from tmuxp._internal.colors import ( + ANSI_SEQ_RE, + ColorMode, + Colors, + UnknownStyleColor, + build_description, + get_color_mode, + strip_ansi, + style, + unstyle, +) + +logger = logging.getLogger(__name__) + +__all__ = [ + "ANSI_SEQ_RE", + "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..2296c37bf5 --- /dev/null +++ b/src/tmuxp/cli/_formatter.py @@ -0,0 +1,377 @@ +"""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 logging +import re +import typing as t + +logger = logging.getLogger(__name__) + +# Options that expect a value (set externally or via --option=value) +OPTIONS_EXPECTING_VALUE = frozenset( + { + "-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 a :class:`~tmuxp._internal.colors.Colors` instance. + + Parameters + ---------- + colors : :class:`~tmuxp._internal.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 : :class:`~tmuxp._internal.colors.Colors` | None + Colors instance for styling. If None, uses + :attr:`~tmuxp._internal.colors.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..b62f1cc05c --- /dev/null +++ b/src/tmuxp/cli/_output.py @@ -0,0 +1,219 @@ +"""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 logging +import sys +import typing as t + +logger = logging.getLogger(__name__) + + +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 emit_object(self, data: dict[str, t.Any]) -> None: + """Emit a single top-level JSON object (not a list of records). + + For commands that produce one structured object rather than a stream of + records. Writes immediately without buffering; does not affect + ``_json_buffer``. + + In JSON mode, writes indented JSON followed by a newline. + In NDJSON mode, writes compact single-line JSON followed by a newline. + In HUMAN mode, does nothing (use ``emit_text`` for human output). + + Parameters + ---------- + data : dict + The object to emit. + + Examples + -------- + >>> import io, sys + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit_object({"status": "ok", "count": 3}) + { + "status": "ok", + "count": 3 + } + >>> formatter._json_buffer # buffer is unaffected + [] + + >>> formatter2 = OutputFormatter(OutputMode.NDJSON) + >>> formatter2.emit_object({"status": "ok", "count": 3}) + {"status": "ok", "count": 3} + + >>> formatter3 = OutputFormatter(OutputMode.HUMAN) + >>> formatter3.emit_object({"status": "ok"}) # no output in HUMAN mode + """ + if self.mode == OutputMode.JSON: + sys.stdout.write(json.dumps(data, indent=2) + "\n") + sys.stdout.flush() + elif self.mode == OutputMode.NDJSON: + sys.stdout.write(json.dumps(data) + "\n") + sys.stdout.flush() + # HUMAN: no-op + + def finalize(self) -> None: + """Finalize output (flush JSON buffer if needed). + + 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/_progress.py b/src/tmuxp/cli/_progress.py new file mode 100644 index 0000000000..b2783f3ff7 --- /dev/null +++ b/src/tmuxp/cli/_progress.py @@ -0,0 +1,1131 @@ +"""Progress indicators for tmuxp CLI. + +This module provides a threaded spinner for long-running operations, +using only standard library and ANSI escape sequences. +""" + +from __future__ import annotations + +import atexit +import collections +import dataclasses +import itertools +import logging +import shutil +import sys +import threading +import time +import typing as t + +from tmuxp._internal.colors import ANSI_SEQ_RE, ColorMode, Colors, strip_ansi + +logger = logging.getLogger(__name__) + + +if t.TYPE_CHECKING: + import types + + +# ANSI Escape Sequences +HIDE_CURSOR = "\033[?25l" +SHOW_CURSOR = "\033[?25h" +ERASE_LINE = "\033[2K" +CURSOR_TO_COL0 = "\r" +CURSOR_UP_1 = "\x1b[1A" +SYNC_START = "\x1b[?2026h" # synchronized output: buffer until SYNC_END +SYNC_END = "\x1b[?2026l" # flush — prevents multi-line flicker + +# Spinner frames (braille pattern) +SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + +BAR_WIDTH = 10 # inner fill character count +DEFAULT_OUTPUT_LINES = 3 # default spinner panel height (lines of script output) + + +def _visible_len(s: str) -> int: + r"""Return visible length of *s*, ignoring ANSI escapes. + + Examples + -------- + >>> _visible_len("hello") + 5 + >>> _visible_len("\033[32mgreen\033[0m") + 5 + >>> _visible_len("") + 0 + """ + return len(strip_ansi(s)) + + +def _truncate_visible(text: str, max_visible: int, suffix: str = "...") -> str: + r"""Truncate *text* to *max_visible* visible characters, preserving ANSI sequences. + + If the visible length of *text* is already within *max_visible*, it is + returned unchanged. Otherwise the text is cut so that exactly + *max_visible* visible characters remain, a ``\x1b[0m`` reset is appended + (to prevent color bleed), followed by *suffix*. + + Parameters + ---------- + text : str + Input string, possibly containing ANSI escape sequences. + max_visible : int + Maximum number of visible (non-ANSI) characters to keep. + suffix : str + Appended after the reset when truncation occurs. Default ``"..."``. + + Returns + ------- + str + Truncated string with ANSI sequences intact. + + Examples + -------- + Plain text truncation: + + >>> _truncate_visible("hello world", 5) + 'hello\x1b[0m...' + + ANSI sequences are preserved whole: + + >>> _truncate_visible("\033[32mgreen\033[0m", 3) + '\x1b[32mgre\x1b[0m...' + + No truncation needed: + + >>> _truncate_visible("short", 10) + 'short' + + Empty string: + + >>> _truncate_visible("", 5) + '' + """ + if max_visible <= 0: + return "" + if _visible_len(text) <= max_visible: + return text + + result: list[str] = [] + visible = 0 + i = 0 + while i < len(text) and visible < max_visible: + m = ANSI_SEQ_RE.match(text, i) + if m: + result.append(m.group()) + i = m.end() + else: + result.append(text[i]) + visible += 1 + i += 1 + return "".join(result) + "\x1b[0m" + suffix + + +SUCCESS_TEMPLATE = "Loaded workspace: {session} ({workspace_path}) {summary}" + +PROGRESS_PRESETS: dict[str, str] = { + "default": "Loading workspace: {session} {bar} {progress} {window}", + "minimal": "Loading workspace: {session} [{window_progress}]", + "window": "Loading workspace: {session} {window_bar} {window_progress_rel}", + "pane": "Loading workspace: {session} {pane_bar} {session_pane_progress}", + "verbose": ( + "Loading workspace: {session} [window {window_index} of {window_total}" + " · pane {pane_index} of {pane_total}] {window}" + ), +} + + +def render_bar(done: int, total: int, width: int = BAR_WIDTH) -> str: + """Render a plain-text ASCII progress bar without color. + + Parameters + ---------- + done : int + Completed units. + total : int + Total units. When ``<= 0``, returns ``""``. + width : int + Inner fill character count; default :data:`BAR_WIDTH`. + + Returns + ------- + str + A bar like ``"█████░░░░░"``. + Returns ``""`` when *total* <= 0 or *width* <= 0. + + Examples + -------- + >>> render_bar(0, 10) + '░░░░░░░░░░' + >>> render_bar(5, 10) + '█████░░░░░' + >>> render_bar(10, 10) + '██████████' + >>> render_bar(0, 0) + '' + >>> render_bar(3, 10, width=5) + '█░░░░' + """ + if total <= 0 or width <= 0: + return "" + filled = min(width, int(done / total * width)) + return "█" * filled + "░" * (width - filled) + + +class _SafeFormatMap(dict): # type: ignore[type-arg] + """dict subclass that returns ``{key}`` for missing keys in format_map.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def resolve_progress_format(fmt: str) -> str: + """Return the format string for *fmt*, resolving preset names. + + If *fmt* is a key in :data:`PROGRESS_PRESETS` the corresponding + format string is returned; otherwise *fmt* is returned as-is. + + Examples + -------- + >>> resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + True + >>> resolve_progress_format("{session} w{window_progress}") + '{session} w{window_progress}' + >>> resolve_progress_format("unknown-preset") + 'unknown-preset' + """ + return PROGRESS_PRESETS.get(fmt, fmt) + + +@dataclasses.dataclass +class _WindowStatus: + """State for a single window in the build tree.""" + + name: str + done: bool = False + pane_num: int | None = None + pane_total: int | None = None + pane_done: int = 0 # panes completed in this window (set on window_done) + + +class BuildTree: + """Tracks session/window/pane build state; renders a structural progress tree. + + **Template Token Lifecycle** + + Each token is first available at the event listed in its column. + ``—`` means the value does not change at that phase. + + .. list-table:: + :header-rows: 1 + + * - Token + - Pre-``session_created`` + - After ``session_created`` + - After ``window_started`` + - After ``pane_creating`` + - After ``window_done`` + * - ``{session}`` + - ``""`` + - session name + - — + - — + - — + * - ``{window}`` + - ``""`` + - ``""`` + - window name + - — + - last window name + * - ``{window_index}`` + - ``0`` + - ``0`` + - N (1-based started count) + - — + - — + * - ``{window_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{window_progress}`` + - ``""`` + - ``""`` + - ``"N/M"`` when > 0 + - — + - — + * - ``{windows_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - increments + * - ``{windows_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{window_progress_rel}`` + - ``""`` + - ``"0/M"`` + - ``"0/M"`` + - — + - ``"N/M"`` + * - ``{pane_index}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - ``0`` + * - ``{pane_total}`` + - ``0`` + - ``0`` + - window's pane total + - — + - window's pane total + * - ``{pane_progress}`` + - ``""`` + - ``""`` + - ``""`` + - ``"N/M"`` + - ``""`` + * - ``{pane_done}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - pane_total + * - ``{pane_remaining}`` + - ``0`` + - ``0`` + - pane_total + - decrements + - ``0`` + * - ``{pane_progress_rel}`` + - ``""`` + - ``""`` + - ``"0/M"`` + - ``"N/M"`` + - ``"M/M"`` + * - ``{progress}`` + - ``""`` + - ``""`` + - ``"N/M win"`` + - ``"N/M win · P/Q pane"`` + - — + * - ``{session_pane_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{session_panes_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - accumulated + * - ``{session_panes_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{session_pane_progress}`` + - ``""`` + - ``"0/T"`` + - — + - — + - ``"N/T"`` + * - ``{overall_percent}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - updates + * - ``{summary}`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``"[N win, M panes]"`` + * - ``{bar}`` (spinner) + - ``[░░…]`` + - ``[░░…]`` + - starts filling + - fractional + - jumps + * - ``{pane_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{window_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{status_icon}`` (spinner) + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + + During ``before_script``: ``{bar}``, ``{pane_bar}``, ``{window_bar}`` show a + marching animation; ``{status_icon}`` = ``⏸``. + + Examples + -------- + Empty tree renders nothing: + + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> tree = BuildTree() + >>> tree.render(colors, 80) + [] + + After session_created event the header appears: + + >>> tree.on_event({"event": "session_created", "name": "my-session"}) + >>> tree.render(colors, 80) + ['Session'] + + After window_started and pane_creating: + + >>> tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + >>> tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- editor, pane (1 of 2)' + + After window_done the window gets a checkmark: + + >>> tree.on_event({"event": "window_done"}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- ✓ editor' + + **Inline status format:** + + >>> tree2 = BuildTree() + >>> tree2.format_inline("Building projects...") + 'Building projects...' + >>> tree2.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai' + >>> tree2.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + >>> tree2.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs' + """ + + def __init__(self, workspace_path: str = "") -> None: + self.workspace_path: str = workspace_path + self.session_name: str | None = None + self.windows: list[_WindowStatus] = [] + self.window_total: int | None = None + self.session_pane_total: int | None = None + self.session_panes_done: int = 0 + self.windows_done: int = 0 + self._before_script_event: threading.Event = threading.Event() + + def on_event(self, event: dict[str, t.Any]) -> None: + """Update tree state from a build event dict. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.on_event({ + ... "event": "session_created", "name": "dev", "window_total": 2, + ... }) + >>> tree.session_name + 'dev' + >>> tree.window_total + 2 + >>> tree.on_event({ + ... "event": "window_started", "name": "editor", "pane_total": 3, + ... }) + >>> len(tree.windows) + 1 + >>> tree.windows[0].name + 'editor' + """ + kind = event["event"] + if kind == "session_created": + self.session_name = event["name"] + self.window_total = event.get("window_total") + self.session_pane_total = event.get("session_pane_total") + elif kind == "before_script_started": + self._before_script_event.set() + elif kind == "before_script_done": + self._before_script_event.clear() + elif kind == "window_started": + self.windows.append( + _WindowStatus(name=event["name"], pane_total=event["pane_total"]) + ) + elif kind == "pane_creating": + if self.windows: + w = self.windows[-1] + w.pane_num = event["pane_num"] + w.pane_total = event["pane_total"] + elif kind == "window_done": + if self.windows: + w = self.windows[-1] + w.done = True + w.pane_num = None + w.pane_done = w.pane_total or 0 + self.session_panes_done += w.pane_done + self.windows_done += 1 + elif kind == "workspace_built": + for w in self.windows: + w.done = True + + def render(self, colors: Colors, width: int) -> list[str]: + """Render the current tree state to a list of display strings. + + Parameters + ---------- + colors : :class:`~tmuxp._internal.colors.Colors` + Colors instance for ANSI styling. + width : int + Terminal width; window lines are truncated to ``width - 1``. + + Returns + ------- + list[str] + Lines to display; empty list if no session has been created yet. + """ + if self.session_name is None: + return [] + lines: list[str] = [colors.heading("Session")] + for w in self.windows: + if w.done: + line = f"- {colors.success('✓')} {colors.highlight(w.name)}" + elif w.pane_num is not None and w.pane_total is not None: + line = ( + f"- {colors.highlight(w.name)}" + f"{colors.muted(f', pane ({w.pane_num} of {w.pane_total})')}" + ) + else: + line = f"- {colors.highlight(w.name)}" + lines.append(_truncate_visible(line, width - 1, suffix="")) + return lines + + def _context(self) -> dict[str, t.Any]: + """Return the current build-state token dict for template rendering. + + Examples + -------- + Zero-state before any events: + + >>> tree = BuildTree(workspace_path="~/.tmuxp/myapp.yaml") + >>> ev = { + ... "event": "session_created", + ... "name": "myapp", + ... "window_total": 5, + ... "session_pane_total": 10, + ... } + >>> tree.on_event(ev) + >>> ctx = tree._context() + >>> ctx["workspace_path"] + '~/.tmuxp/myapp.yaml' + >>> ctx["session"] + 'myapp' + >>> ctx["window_total"] + 5 + >>> ctx["window_index"] + 0 + >>> ctx["progress"] + '' + >>> ctx["windows_done"] + 0 + >>> ctx["windows_remaining"] + 5 + >>> ctx["window_progress_rel"] + '0/5' + >>> ctx["session_pane_total"] + 10 + >>> ctx["session_panes_remaining"] + 10 + >>> ctx["session_pane_progress"] + '0/10' + >>> ctx["summary"] + '' + + After windows complete, summary shows counts: + + >>> tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + >>> tree.on_event({"event": "window_done"}) + >>> tree.on_event({"event": "window_started", "name": "w2", "pane_total": 5}) + >>> tree.on_event({"event": "window_done"}) + >>> tree._context()["summary"] + '[2 win, 8 panes]' + """ + w = self.windows[-1] if self.windows else None + window_idx = len(self.windows) + win_tot = self.window_total or 0 + pane_idx = (w.pane_num or 0) if w else 0 + pane_tot = (w.pane_total or 0) if w else 0 + + win_progress = f"{window_idx}/{win_tot}" if win_tot and window_idx > 0 else "" + pane_progress = f"{pane_idx}/{pane_tot}" if pane_tot and pane_idx > 0 else "" + progress_parts = [ + f"{win_progress} win" if win_progress else "", + f"{pane_progress} pane" if pane_progress else "", + ] + progress = " · ".join(p for p in progress_parts if p) + + win_done = self.windows_done + win_progress_rel = f"{win_done}/{win_tot}" if win_tot else "" + + pane_done_cur = ( + (w.pane_num or 0) if w and not w.done else (w.pane_done if w else 0) + ) + pane_remaining = max(0, pane_tot - pane_done_cur) + pane_progress_rel = f"{pane_done_cur}/{pane_tot}" if pane_tot else "" + + spt = self.session_pane_total or 0 + session_pane_progress = f"{self.session_panes_done}/{spt}" if spt else "" + overall_percent = int(self.session_panes_done / spt * 100) if spt else 0 + + summary_parts: list[str] = [] + if self.windows_done: + summary_parts.append(f"{self.windows_done} win") + if self.session_panes_done: + summary_parts.append(f"{self.session_panes_done} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + + return { + "workspace_path": self.workspace_path, + "session": self.session_name or "", + "window": w.name if w else "", + "window_index": window_idx, + "window_total": win_tot, + "window_progress": win_progress, + "pane_index": pane_idx, + "pane_total": pane_tot, + "pane_progress": pane_progress, + "progress": progress, + "windows_done": win_done, + "windows_remaining": max(0, win_tot - win_done), + "window_progress_rel": win_progress_rel, + "pane_done": pane_done_cur, + "pane_remaining": pane_remaining, + "pane_progress_rel": pane_progress_rel, + "session_pane_total": spt, + "session_panes_done": self.session_panes_done, + "session_panes_remaining": max(0, spt - self.session_panes_done), + "session_pane_progress": session_pane_progress, + "overall_percent": overall_percent, + "summary": summary, + } + + def format_template( + self, + fmt: str, + extra: dict[str, t.Any] | None = None, + ) -> str: + """Render *fmt* with the current build state. + + Returns ``""`` before ``session_created`` fires so callers can + fall back to a pre-build message. Unknown ``{tokens}`` are left + as-is (not dropped silently). + + The optional *extra* dict is merged on top of :meth:`_context` so + callers (e.g. :class:`Spinner`) can inject ANSI-colored tokens like + ``{bar}`` without adding color concerns to :class:`BuildTree`. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.format_template("{session} [{progress}] {window}") + '' + >>> ev = {"event": "session_created", "name": "cihai", "window_total": 3} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [] ' + >>> ev = {"event": "window_started", "name": "editor", "pane_total": 4} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win] editor' + >>> tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win · 2/4 pane] editor' + >>> tree.format_template("minimal: {session} [{window_progress}]") + 'minimal: cihai [1/3]' + >>> tree.format_template("{session} {unknown_token}") + 'cihai {unknown_token}' + >>> tree.format_template("{session}", extra={"custom": "value"}) + 'cihai' + """ + if self.session_name is None: + return "" + ctx: dict[str, t.Any] = self._context() + if extra: + ctx = {**ctx, **extra} + return fmt.format_map(_SafeFormatMap(ctx)) + + def format_inline(self, base: str) -> str: + """Return base message with current build state appended inline. + + Parameters + ---------- + base : str + The original spinner message to start from. + + Returns + ------- + str + ``base`` alone if no session has been created yet; otherwise + ``"base session_name [W of N windows, P of M panes] window_name"``, + omitting the bracket section when there is no current window, and + omitting individual parts when their totals are not known. + """ + if self.session_name is None: + return base + parts = [base, self.session_name] + if self.windows: + w = self.windows[-1] + window_idx = len(self.windows) + bracket_parts: list[str] = [] + if self.window_total is not None: + bracket_parts.append(f"{window_idx} of {self.window_total} windows") + if w.pane_num is not None and w.pane_total is not None: + bracket_parts.append(f"{w.pane_num} of {w.pane_total} panes") + if bracket_parts: + parts.append(f"[{', '.join(bracket_parts)}]") + parts.append(w.name) + return " ".join(parts) + + +class Spinner: + """A threaded spinner for CLI progress. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> with Spinner("Build...", color_mode=ColorMode.NEVER, stream=stream) as spinner: + ... spinner.add_output_line("Session created: test") + ... spinner.update_message("Creating window: editor") + """ + + def __init__( + self, + message: str = "Loading...", + color_mode: ColorMode = ColorMode.AUTO, + stream: t.TextIO = sys.stderr, + interval: float = 0.1, + output_lines: int = DEFAULT_OUTPUT_LINES, + progress_format: str | None = None, + workspace_path: str = "", + ) -> None: + """Initialize spinner. + + Parameters + ---------- + message : str + Text displayed next to the spinner animation. + color_mode : :class:`~tmuxp._internal.colors.ColorMode` + ANSI color mode for styled output. + stream : t.TextIO + Output stream (default ``sys.stderr``). + interval : float + Seconds between animation frames. + output_lines : int + Max lines in the scrolling output panel. ``0`` hides the panel, + ``-1`` means unlimited. + progress_format : str | None + Format string for progress output. Tokens are documented in + :class:`BuildTree`. ``None`` uses the built-in default. + workspace_path : str + Absolute path to the workspace config file, shown in success + output. + """ + self.message = message + self._base_message = message + self.colors = Colors(color_mode) + self.stream = stream + self.interval = interval + + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._enabled = self._should_enable() + self._panel_hidden = output_lines == 0 + if output_lines < 0: + self._output_lines: collections.deque[str] = ( + collections.deque() + ) # unlimited + elif output_lines == 0: + self._output_lines = collections.deque(maxlen=1) # drop, never render + else: + self._output_lines = collections.deque(maxlen=output_lines) + self._prev_height: int = 0 + self._build_tree: BuildTree = BuildTree(workspace_path=workspace_path) + self._progress_format: str | None = ( + resolve_progress_format(progress_format) + if progress_format is not None + else None + ) + + def _should_enable(self) -> bool: + """Check if spinner should be enabled (TTY check).""" + return self.stream.isatty() + + def _restore_cursor(self) -> None: + """Unconditionally restore cursor — called by atexit on abnormal exit.""" + self.stream.write(SHOW_CURSOR) + self.stream.flush() + + def _spin(self) -> None: + """Spin in background thread.""" + frames = itertools.cycle(SPINNER_FRAMES) + march_pos = 0 # marching bar position counter (local to _spin) + + self.stream.write(HIDE_CURSOR) + self.stream.flush() + + try: + while not self._stop_event.is_set(): + frame = next(frames) + term_width = shutil.get_terminal_size(fallback=(80, 24)).columns + if self._panel_hidden: + panel: list[str] = [] + else: + term_height = shutil.get_terminal_size( + fallback=(80, 24), + ).lines + raw_panel = list(self._output_lines) + max_panel = term_height - 2 + if len(raw_panel) > max_panel: + raw_panel = raw_panel[-max_panel:] + panel = [ + _truncate_visible(line, term_width - 1, suffix="") + for line in raw_panel + ] + new_height = len(panel) + 1 # panel lines + spinner line + + parts: list[str] = [] + + # Erase previous render (cursor is at end of previous spinner line) + if self._prev_height > 0: + parts.append(f"{CURSOR_TO_COL0}{ERASE_LINE}") + parts.extend( + f"{CURSOR_UP_1}{ERASE_LINE}" + for _ in range(self._prev_height - 1) + ) + + # Write panel lines (tree lines already constrained by render()) + parts.extend(f"{output_line}\n" for output_line in panel) + + # Determine final spinner message + if ( + self._progress_format is not None + and self._build_tree._before_script_event.is_set() + ): + # Marching bar: sweep a 2-cell highlight across the bar + p = march_pos % max(1, BAR_WIDTH - 1) + march_bar = ( + self.colors.muted("░" * p) + + self.colors.warning("░░") + + self.colors.muted("░" * max(0, BAR_WIDTH - p - 2)) + ) + tree = self._build_tree + extra: dict[str, t.Any] = { + "session": self.colors.highlight( + tree.session_name or "", + ), + "bar": march_bar, + "pane_bar": march_bar, + "window_bar": march_bar, + "status_icon": self.colors.warning("⏸"), + } + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + msg = rendered if rendered else self._base_message + march_pos += 1 + else: + msg = self.message + march_pos = 0 # reset when not in before_script + + # Write spinner line (no trailing newline — cursor stays here) + spinner_text = f"{self.colors.info(frame)} {msg}" + if _visible_len(spinner_text) > term_width - 1: + spinner_text = _truncate_visible(spinner_text, term_width - 4) + parts.append(f"{CURSOR_TO_COL0}{spinner_text}") + + # Wrap entire frame in synchronized output to prevent flicker. + # Terminals that don't support it safely ignore the sequences. + self.stream.write(SYNC_START + "".join(parts) + SYNC_END) + self.stream.flush() + self._prev_height = new_height + time.sleep(self.interval) + finally: + # Erase the whole block and show cursor + if self._prev_height > 0: + self.stream.write(f"{CURSOR_TO_COL0}{ERASE_LINE}") + for _ in range(self._prev_height - 1): + self.stream.write(f"{CURSOR_UP_1}{ERASE_LINE}") + self.stream.write(SHOW_CURSOR) + self.stream.flush() + self._prev_height = 0 + + def add_output_line(self, line: str) -> None: + r"""Append a line to the live output panel (thread-safe via GIL). + + When the spinner is disabled (non-TTY), writes directly to the stream + so output is not silently swallowed. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.add_output_line("hello world") + >>> stream.getvalue() + 'hello world\n' + """ + stripped = line.rstrip("\n\r") + if stripped: + if self._enabled: + self._output_lines.append(stripped) + else: + self.stream.write(stripped + "\n") + self.stream.flush() + + def update_message(self, message: str) -> None: + """Update the message displayed next to the spinner. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("initial", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.message + 'initial' + >>> spinner.update_message("updated") + >>> spinner.message + 'updated' + """ + self.message = message + + def _build_extra(self) -> dict[str, t.Any]: + """Return spinner-owned template tokens (colored bar, status_icon). + + These are separated from :meth:`BuildTree._context` to keep ANSI/color + concerns out of :class:`BuildTree`, which is also used in tests without + colors. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner._build_tree.on_event( + ... { + ... "event": "session_created", + ... "name": "s", + ... "window_total": 4, + ... "session_pane_total": 8, + ... } + ... ) + >>> extra = spinner._build_extra() + >>> extra["bar"] + '░░░░░░░░░░' + >>> extra["status_icon"] + '' + """ + tree = self._build_tree + win_tot = tree.window_total or 0 + spt = tree.session_pane_total or 0 + + # Composite fraction: (windows_done + pane_frac) / window_total + if win_tot > 0: + cw = tree.windows[-1] if tree.windows else None + pane_frac = 0.0 + if cw and not cw.done and cw.pane_total: + pane_frac = (cw.pane_num or 0) / cw.pane_total + composite_done = tree.windows_done + pane_frac + composite_bar = render_bar(int(composite_done * 100), win_tot * 100) + else: + composite_bar = render_bar(0, 0) + + pane_bar = render_bar(tree.session_panes_done, spt) + window_bar = render_bar(tree.windows_done, win_tot) + + def _color_bar(plain: str) -> str: + if not plain: + return plain + filled = plain.count("█") + empty = plain.count("░") + return self.colors.success("█" * filled) + self.colors.muted("░" * empty) + + return { + "session": self.colors.highlight(tree.session_name or ""), + "bar": _color_bar(composite_bar), + "pane_bar": _color_bar(pane_bar), + "window_bar": _color_bar(window_bar), + "status_icon": "", + } + + def on_build_event(self, event: dict[str, t.Any]) -> None: + """Forward build event to BuildTree and update spinner message inline. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("Loading", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.on_build_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 3, + ... }) + >>> spinner._build_tree.session_name + 'myapp' + """ + self._build_tree.on_event(event) + if self._progress_format is not None: + extra = self._build_extra() + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + # Only switch to template output once a window has started so that + # the session_created → window_started gap doesn't show empty brackets. + self.message = ( + rendered + if (rendered and self._build_tree.windows) + else self._base_message + ) + else: + self.message = self._build_tree.format_inline(self._base_message) + + def start(self) -> None: + """Start the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + """ + if not self._enabled: + return + + atexit.register(self._restore_cursor) + self._stop_event.clear() + self._thread = threading.Thread(target=self._spin, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + >>> spinner._thread is None + True + """ + if self._thread and self._thread.is_alive(): + self._stop_event.set() + self._thread.join() + self._thread = None + atexit.unregister(self._restore_cursor) + + def format_success(self) -> str: + """Render the success template with current build state. + + Uses :data:`SUCCESS_TEMPLATE` with colored ``{session}`` + (``highlight()``), ``{workspace_path}`` (``info()``), and + ``{summary}`` (``muted()``) from :meth:`BuildTree._context`. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream, + ... workspace_path="~/.tmuxp/myapp.yaml") + >>> spinner._build_tree.on_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 4, + ... }) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w1", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w2", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner.format_success() + 'Loaded workspace: myapp (~/.tmuxp/myapp.yaml) [2 win, 4 panes]' + """ + tree = self._build_tree + ctx = tree._context() + extra: dict[str, t.Any] = { + "session": self.colors.highlight(tree.session_name or ""), + "workspace_path": self.colors.info(ctx.get("workspace_path", "")), + "summary": self.colors.muted(ctx.get("summary", "")) + if ctx.get("summary") + else "", + } + return SUCCESS_TEMPLATE.format_map(_SafeFormatMap({**ctx, **extra})) + + def success(self, text: str | None = None) -> None: + """Stop the spinner and print a success line. + + Parameters + ---------- + text : str | None + The success message to display after the checkmark. + When ``None``, uses :meth:`format_success` if a progress format + is configured, otherwise falls back to ``_base_message``. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.success("done") + >>> "✓ done" in stream.getvalue() + True + + With no args and no progress format, falls back to base message: + + >>> stream2 = io.StringIO() + >>> spinner2 = Spinner("Loading...", color_mode=ColorMode.NEVER, stream=stream2) + >>> spinner2.success() + >>> "✓ Loading..." in stream2.getvalue() + True + """ + self.stop() + if text is None and self._progress_format is not None: + text = self.format_success() + elif text is None: + text = self._base_message + checkmark = self.colors.success("\u2713") + msg = f"{checkmark} {text}" + self.stream.write(f"{msg}\n") + self.stream.flush() + + def __enter__(self) -> Spinner: + self.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> t.Literal[False]: + self.stop() + return False diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py new file mode 100644 index 0000000000..a92cfebbfa --- /dev/null +++ b/src/tmuxp/cli/convert.py @@ -0,0 +1,140 @@ +"""CLI for ``tmuxp convert`` subcommand.""" + +from __future__ import annotations + +import locale +import logging +import os +import pathlib +import typing as t + +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, tmuxp_echo + +logger = logging.getLogger(__name__) + +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( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``convert`` subcommand.""" + workspace_file = parser.add_argument( + dest="workspace_file", + type=str, + metavar="workspace-file", + help="checks tmuxp and current directory for workspace files.", + ) + try: + import shtab + + workspace_file.complete = shtab.FILE # type: ignore + except ImportError: + pass + + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + return parser + + +class ConvertUnknownFileType(exc.TmuxpException): + """Raise if tmuxp convert encounters an unknown filetype.""" + + def __init__(self, ext: str, *args: object, **kwargs: object) -> None: + return super().__init__( + f"Unknown filetype: {ext} (valid: [.json, .yaml, .yml])", + ) + + +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(), + ) + + if isinstance(workspace_file, str): + workspace_file = pathlib.Path(workspace_file) + + _, ext = os.path.splitext(workspace_file) + ext = ext.lower() + to_filetype: AllowedFileTypes + if ext == ".json": + to_filetype = "yaml" + elif ext in {".yaml", ".yml"}: + to_filetype = "json" + else: + raise ConvertUnknownFileType(ext) + + configparser = ConfigReader.from_file(workspace_file) + newfile = workspace_file.parent / (str(workspace_file.stem) + f".{to_filetype}") + + new_workspace = configparser.dump( + fmt=to_filetype, + indent=2, + **{"default_flow_style": False} if to_filetype == "yaml" else {}, + ) + + if ( + not answer_yes + 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 + + if answer_yes: + pathlib.Path(newfile).write_text( + new_workspace, + encoding=locale.getpreferredencoding(False), + ) + tmuxp_echo( + colors.success("New workspace file saved to ") + + colors.info(str(PrivatePath(newfile))) + + ".", + ) diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py new file mode 100644 index 0000000000..4e1bf0c5d5 --- /dev/null +++ b/src/tmuxp/cli/debug_info.py @@ -0,0 +1,265 @@ +"""CLI for ``tmuxp debug-info`` subcommand.""" + +from __future__ import annotations + +import argparse +import logging +import os +import pathlib +import platform +import shutil +import sys +import typing as t + +from libtmux.__about__ import __version__ as libtmux_version +from libtmux.common import get_version_str, 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 ._output import OutputFormatter, OutputMode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +DEBUG_INFO_DESCRIPTION = build_description( + """ + Print diagnostic information for debugging and issue reports. + """, + ( + ( + None, + [ + "tmuxp debug-info", + ], + ), + ( + "Machine-readable output examples", + [ + "tmuxp debug-info --json", + ], + ), + ), +) + +if t.TYPE_CHECKING: + 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 _private(path: pathlib.Path | str | None) -> str: + """Privacy-mask a path by collapsing home directory to ~. + + Parameters + ---------- + path : pathlib.Path | str | None + Path to mask. + + Returns + ------- + str + Path with home directory replaced by ~. + + 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": get_version_str(), + "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 : :class:`~tmuxp._internal.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 = [ + 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"]), + ] + + 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.""" + # 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 + OutputFormatter(OutputMode.JSON).emit_object(data) + else: + tmuxp_echo(_format_human_output(data, colors)) diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py new file mode 100644 index 0000000000..7308f2eba7 --- /dev/null +++ b/src/tmuxp/cli/edit.py @@ -0,0 +1,73 @@ +"""CLI for ``tmuxp edit`` subcommand.""" + +from __future__ import annotations + +import logging +import os +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 +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +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( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``edit`` subcommand.""" + parser.add_argument( + dest="workspace_file", + metavar="workspace-file", + type=str, + help="checks current tmuxp and current directory for workspace files.", + ) + return parser + + +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") + tmuxp_echo( + 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 new file mode 100644 index 0000000000..fa26569ca7 --- /dev/null +++ b/src/tmuxp/cli/freeze.py @@ -0,0 +1,263 @@ +"""CLI for ``tmuxp freeze`` subcommand.""" + +from __future__ import annotations + +import argparse +import locale +import logging +import os +import pathlib +import sys +import typing as t + +from libtmux.server import Server + +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, tmuxp_echo + +logger = logging.getLogger(__name__) + +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 TypeGuard + + 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 + workspace_format: CLIOutputFormatLiteral | None + save_to: str | None + answer_yes: bool | None + quiet: bool | None + force: bool | None + + +def create_freeze_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``freeze`` subcommand.""" + parser.add_argument( + dest="session_name", + metavar="session-name", + nargs="?", + action="store", + ) + parser.add_argument( + "-S", + dest="socket_path", + metavar="socket-path", + help="pass-through for tmux -S", + ) + parser.add_argument( + "-L", + dest="socket_name", + metavar="socket-name", + help="pass-through for tmux -L", + ) + parser.add_argument( + "-f", + "--workspace-format", + choices=["yaml", "json"], + help="format to save in", + ) + parser.add_argument( + "-o", + "--save-to", + metavar="output-path", + type=pathlib.Path, + help="file to save to", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + parser.add_argument( + "--quiet", + "-q", + dest="quiet", + action="store_true", + help="don't prompt for confirmation", + ) + parser.add_argument( + "--force", + dest="force", + action="store_true", + help="overwrite the workspace file", + ) + + return parser + + +def command_freeze( + args: CLIFreezeNamespace, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp freeze``, snapshot a tmux session into a tmuxp workspace. + + 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: + if args.session_name: + session = server.sessions.get(session_name=args.session_name, default=None) + else: + session = util.get_session(server) + + if not session: + raise exc.SessionNotFound + except TmuxpException as e: + tmuxp_echo(colors.error(str(e))) + return + + frozen_workspace = freezer.freeze(session) + workspace = freezer.inline(frozen_workspace) + configparser = ConfigReader(workspace) + + if not args.quiet: + tmuxp_echo( + 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: + tmuxp_echo( + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info("") + + ".", + ) + sys.exit() + + dest = args.save_to + while not dest: + save_to = os.path.abspath( + os.path.join( + get_workspace_dir(), + "{}.{}".format( + frozen_workspace.get("session_name"), + args.workspace_format or "yaml", + ), + ), + ) + dest_prompt = prompt( + f"Save to: {PrivatePath(save_to)}", + default=save_to, + color_mode=color_mode, + ) + if not args.force and os.path.exists(dest_prompt): + tmuxp_echo( + colors.warning(f"{PrivatePath(dest_prompt)} exists.") + + " " + + colors.muted("Pick a new filename."), + ) + continue + + dest = dest_prompt + dest = os.path.abspath(os.path.relpath(os.path.expanduser(dest))) + workspace_format = args.workspace_format + + valid_workspace_formats: list[CLIOutputFormatLiteral] = ["json", "yaml"] + + def is_valid_ext(stem: str | None) -> TypeGuard[CLIOutputFormatLiteral]: + return stem in valid_workspace_formats + + if not is_valid_ext(workspace_format): + + def extract_workspace_format( + val: str, + ) -> CLIOutputFormatLiteral | None: + suffix = pathlib.Path(val).suffix + if isinstance(suffix, str): + suffix = suffix.lower().lstrip(".") + if is_valid_ext(suffix): + return suffix + return None + + workspace_format = extract_workspace_format(dest) + if not is_valid_ext(workspace_format): + workspace_format_ = prompt_choices( + "Couldn't ascertain one of [{}] from file name. Convert to".format( + ", ".join(valid_workspace_formats), + ), + choices=t.cast("list[str]", valid_workspace_formats), + default="yaml", + color_mode=color_mode, + ) + assert is_valid_ext(workspace_format_) + workspace_format = workspace_format_ + + if workspace_format == "yaml": + workspace = configparser.dump( + fmt="yaml", + indent=2, + default_flow_style=False, + safe=True, + ) + elif workspace_format == "json": + workspace = configparser.dump(fmt="json", indent=2) + + 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) + pathlib.Path(dest).write_text( + workspace, + encoding=locale.getpreferredencoding(False), + ) + logger.info("workspace saved", extra={"tmux_config_path": str(dest)}) + + if not args.quiet: + tmuxp_echo( + colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", + ) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py new file mode 100644 index 0000000000..84446adddd --- /dev/null +++ b/src/tmuxp/cli/import_config.py @@ -0,0 +1,282 @@ +"""CLI for ``tmuxp import`` subcommand.""" + +from __future__ import annotations + +import locale +import logging +import os +import pathlib +import sys +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 + +logger = logging.getLogger(__name__) + +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: + """Return tmuxinator configuration directory. + + Checks for ``TMUXINATOR_CONFIG`` environmental variable. + + Returns + ------- + pathlib.Path : + absolute path to tmuxinator config directory + + See Also + -------- + :func:`tmuxp.workspace.importers.import_tmuxinator` + """ + if "TMUXINATOR_CONFIG" in os.environ: + return pathlib.Path(os.environ["TMUXINATOR_CONFIG"]).expanduser() + + return pathlib.Path("~/.tmuxinator/").expanduser() + + +def get_teamocil_dir() -> pathlib.Path: + """Return teamocil configuration directory. + + Returns + ------- + pathlib.Path : + absolute path to teamocil config directory + + See Also + -------- + :func:`tmuxp.workspace.importers.import_teamocil` + """ + return pathlib.Path("~/.teamocil/").expanduser() + + +def _resolve_path_no_overwrite(workspace_file: str) -> str: + path = pathlib.Path(workspace_file).resolve() + if path.exists(): + msg = f"{path} exists. Pick a new filename." + raise ValueError(msg) + return str(path) + + +def command_import( + workspace_file: str, + print_list: str, + parser: argparse.ArgumentParser, +) -> None: + """Import a teamocil/tmuxinator config.""" + + +def create_import_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``import`` subparser.""" + importsubparser = parser.add_subparsers( + title="commands", + description="valid commands", + help="additional help", + ) + + import_teamocil = importsubparser.add_parser( + "teamocil", + help="convert and import a teamocil config", + ) + + import_teamocilgroup = import_teamocil.add_mutually_exclusive_group(required=True) + teamocil_workspace_file = import_teamocilgroup.add_argument( + dest="workspace_file", + type=str, + nargs="?", + metavar="workspace-file", + help="checks current ~/.teamocil and current directory for yaml files", + ) + import_teamocil.set_defaults( + callback=command_import_teamocil, + import_subparser_name="teamocil", + ) + + import_tmuxinator = importsubparser.add_parser( + "tmuxinator", + help="convert and import a tmuxinator config", + ) + + import_tmuxinatorgroup = import_tmuxinator.add_mutually_exclusive_group( + required=True, + ) + tmuxinator_workspace_file = import_tmuxinatorgroup.add_argument( + dest="workspace_file", + type=str, + nargs="?", + metavar="workspace-file", + help="checks current ~/.tmuxinator and current directory for yaml files", + ) + + import_tmuxinator.set_defaults( + callback=command_import_tmuxinator, + import_subparser_name="tmuxinator", + ) + + try: + import shtab + + teamocil_workspace_file.complete = shtab.FILE # type: ignore + tmuxinator_workspace_file.complete = shtab.FILE # type: ignore + except ImportError: + pass + + return parser + + +class ImportConfigFn(t.Protocol): + """Typing for import configuration callback function.""" + + def __call__(self, workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Execute tmuxp import function.""" + ... + + +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)) + + workspace_file_format = prompt_choices( + "Convert to", + choices=["yaml", "json"], + default="yaml", + color_mode=colors.mode, + ) + + if workspace_file_format == "yaml": + new_config = cfg_reader.dump("yaml", indent=2, default_flow_style=False) + elif workspace_file_format == "json": + new_config = cfg_reader.dump("json", indent=2) + else: + sys.exit(colors.error("Unknown config format.")) + + tmuxp_echo( + 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 [{PrivatePath(os.getcwd())}]", + value_proc=_resolve_path_no_overwrite, + color_mode=colors.mode, + ) + + # dest = dest_prompt + if prompt_yes_no( + f"Save to {PrivatePath(dest_path)}?", + color_mode=colors.mode, + ): + dest = dest_path + + pathlib.Path(dest).write_text( + new_config, + encoding=locale.getpreferredencoding(False), + ) + + logger.info( + "workspace saved", + extra={"tmux_config_path": str(dest)}, + ) + tmuxp_echo( + colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", + ) + else: + tmuxp_echo( + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info(""), + ) + sys.exit() + + +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, 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, colors=colors) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py new file mode 100644 index 0000000000..a9faf89bce --- /dev/null +++ b/src/tmuxp/cli/load.py @@ -0,0 +1,930 @@ +"""CLI for ``tmuxp load`` subcommand.""" + +from __future__ import annotations + +import argparse +import contextlib +import importlib +import logging +import os +import pathlib +import shutil +import sys +import typing as t + +from libtmux.server import Server + +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 ( + WorkspaceBuilderProtocol, + prepended_sys_path, + resolve_builder_class, + resolve_builder_paths, +) +from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir + +from ._colors import ColorMode, Colors, build_description, get_color_mode +from ._progress import ( + DEFAULT_OUTPUT_LINES, + SUCCESS_TEMPLATE, + Spinner, + _SafeFormatMap, + resolve_progress_format, +) +from .utils import prompt_choices, prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) + + +@contextlib.contextmanager +def _silence_stream_handlers(logger_name: str = "tmuxp") -> t.Iterator[None]: + """Temporarily raise StreamHandler level to WARNING while spinner is active. + + INFO/DEBUG log records are diagnostics for aggregators, not user-facing output; + the spinner is the user-facing progress channel. Restores original levels on exit. + """ + _log = logging.getLogger(logger_name) + saved: list[tuple[logging.StreamHandler[t.Any], int]] = [ + (h, h.level) + for h in _log.handlers + if isinstance(h, logging.StreamHandler) + and not isinstance(h, logging.FileHandler) + ] + for h, _ in saved: + h.setLevel(logging.WARNING) + try: + yield + finally: + for h, level in saved: + h.setLevel(level) + + +LOAD_DESCRIPTION = build_description( + """ + Load tmuxp workspace file(s) and create or attach to a tmux session. + """, + ( + ( + 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 + + from libtmux.session import Session + from typing_extensions import NotRequired, TypedDict + + 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.""" + + detached: NotRequired[bool] + new_session_name: NotRequired[str | None] + + +class CLILoadNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp load command.""" + + workspace_files: list[str] + socket_name: str | None + socket_path: str | None + 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 + log_level: str + progress_format: str | None + panel_lines: int | None + no_progress: bool + + +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 : :class:`~tmuxp._internal.colors.Colors` | None + Colors instance for output formatting. If None, uses + :attr:`~tmuxp._internal.colors.ColorMode.AUTO`. + + 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"]: + try: + module_name = plugin.split(".") + module_name = ".".join(module_name[:-1]) + plugin_name = plugin.split(".")[-1] + except AttributeError as error: + logger.debug("plugin load failed", exc_info=True) + tmuxp_echo( + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), + ) + sys.exit(1) + + try: + plugin = getattr(importlib.import_module(module_name), plugin_name) + plugins.append(plugin()) + except exc.TmuxpPluginException as error: + if not prompt_yes_no( + f"{colors.warning(str(error))}Skip loading {plugin_name}?", + default=True, + color_mode=colors.mode, + ): + logger.warning( + "plugin version constraint not met, user declined skip", + ) + tmuxp_echo( + colors.warning("[Not Skipping]") + + " Plugin versions constraint not met. Exiting...", + ) + sys.exit(1) + except (ImportError, AttributeError) as error: + logger.debug("plugin import failed", exc_info=True) + tmuxp_echo( + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), + ) + sys.exit(1) + + return plugins + + +def _reattach(builder: WorkspaceBuilderProtocol, colors: Colors | None = None) -> None: + """ + Reattach session (depending on env being inside tmux already or not). + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + colors : :class:`~tmuxp._internal.colors.Colors` | None + Optional Colors instance for styled output. + + Notes + ----- + If ``TMUX`` environmental variable exists in the environment this script is + running, that means we're in a tmux client. So ``tmux switch-client`` will + load the session. + + If not, ``tmux attach-session`` loads the client to the target session. + """ + assert builder.session is not None + for plugin in builder.plugins: + plugin.reattach(builder.session) + proc = builder.session.cmd("display-message", "-p", "'#S'") + for line in proc.stdout: + tmuxp_echo(colors.info(line) if colors else line) + logger.debug( + "reattach display-message output", + extra={"tmux_stdout": [line.strip()]}, + ) + + if "TMUX" in os.environ: + builder.session.switch_client() + + else: + builder.session.attach() + + +def _load_attached( + builder: WorkspaceBuilderProtocol, + detached: bool, + pre_attach_hook: t.Callable[[], None] | None = None, +) -> None: + """ + Load workspace in new session. + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + detached : bool + pre_attach_hook : callable, optional + called after build, before attach/switch_client; use to stop the spinner + so its cleanup sequences don't appear inside the tmux pane. + """ + builder.build() + assert builder.session is not None + + if pre_attach_hook is not None: + pre_attach_hook() + + if "TMUX" in os.environ: # tmuxp ran from inside tmux + # unset TMUX, save it, e.g. '/tmp/tmux-1000/default,30668,0' + tmux_env = os.environ.pop("TMUX") + + builder.session.switch_client() # switch client to new session + + os.environ["TMUX"] = tmux_env # set TMUX back again + elif not detached: + builder.session.attach() + + +def _load_detached( + builder: WorkspaceBuilderProtocol, + colors: Colors | None = None, + pre_output_hook: t.Callable[[], None] | None = None, +) -> None: + """ + Load workspace in new session but don't attach. + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + colors : :class:`~tmuxp._internal.colors.Colors` | None + Optional Colors instance for styled output. + pre_output_hook : Callable | None + Called after build but before printing, e.g. to stop a spinner. + """ + builder.build() + + assert builder.session is not None + + if pre_output_hook is not None: + pre_output_hook() + + msg = "Session created in detached state." + tmuxp_echo(colors.info(msg) if colors else msg) + logger.info("session created in detached state") + + +def _load_append_windows_to_current_session( + builder: WorkspaceBuilderProtocol, +) -> None: + """ + Load workspace as new windows in current session. + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + """ + current_attached_session = builder.find_current_attached_session() + builder.build(current_attached_session, append=True) + assert builder.session is not None + + +def _setup_plugins(builder: WorkspaceBuilderProtocol) -> Session: + """Execute hooks for plugins running after ``before_script``. + + Parameters + ---------- + builder: :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + """ + assert builder.session is not None + for plugin in builder.plugins: + plugin.before_script(builder.session) + + return builder.session + + +def _dispatch_build( + builder: WorkspaceBuilderProtocol, + detached: bool, + append: bool, + answer_yes: bool, + cli_colors: Colors, + pre_attach_hook: t.Callable[[], None] | None = None, + on_error_hook: t.Callable[[], None] | None = None, + pre_prompt_hook: t.Callable[[], None] | None = None, +) -> Session | None: + """Dispatch the build to the correct load path and handle errors. + + Handles the detached/attached/append switching logic and the + ``TmuxpException`` error-recovery prompt. Extracted so the + spinner-enabled and spinner-disabled paths share one implementation. + + Parameters + ---------- + builder : WorkspaceBuilder + Configured workspace builder. + detached : bool + Load session in detached state. + append : bool + Append windows to the current session. + answer_yes : bool + Skip interactive prompts. + cli_colors : :class:`~tmuxp._internal.colors.Colors` + Colors instance for styled output. + pre_attach_hook : callable, optional + Called before attach/switch_client (e.g. stop spinner). + on_error_hook : callable, optional + Called before showing the error-recovery prompt (e.g. stop spinner). + pre_prompt_hook : callable, optional + Called before any interactive prompt (e.g. stop spinner so ANSI + escape sequences don't garble the terminal during user input). + + Returns + ------- + Session | None + The built session, or ``None`` if the user killed it on error. + + Examples + -------- + >>> from tmuxp.cli.load import _dispatch_build + >>> callable(_dispatch_build) + True + """ + try: + if detached: + _load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook) + return _setup_plugins(builder) + + if append: + if "TMUX" in os.environ: # tmuxp ran from inside tmux + _load_append_windows_to_current_session(builder) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + return _setup_plugins(builder) + + # append and answer_yes have no meaning if specified together + if answer_yes: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + return _setup_plugins(builder) + + if "TMUX" in os.environ: # tmuxp ran from inside tmux + if pre_prompt_hook is not None: + pre_prompt_hook() + msg = ( + "Already inside TMUX, switch to session? yes/no\n" + "Or (a)ppend windows in the current active session?\n[y/n/a]" + ) + options = ["y", "n", "a"] + choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) + + if choice == "y": + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + elif choice == "a": + _load_append_windows_to_current_session(builder) + else: + _load_detached(builder, cli_colors) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + except exc.TmuxpException as e: + if on_error_hook is not None: + on_error_hook() + logger.debug("workspace build failed", exc_info=True) + tmuxp_echo(cli_colors.error("[Error]") + f" {e}") + + choice = prompt_choices( + cli_colors.error("Error loading workspace.") + + " (k)ill, (a)ttach, (d)etach?", + choices=["k", "a", "d"], + default="k", + color_mode=cli_colors.mode, + ) + + if choice == "k": + if builder.session is not None: + builder.session.kill() + tmuxp_echo(cli_colors.muted("Session killed.")) + logger.info("session killed by user after build error") + elif choice == "a": + _reattach(builder, cli_colors) + else: + sys.exit() + return None + finally: + builder.on_progress = None + builder.on_before_script = None + builder.on_script_output = None + builder.on_build_event = None + + return _setup_plugins(builder) + + +def load_workspace( + workspace_file: StrPath, + socket_name: str | None = None, + 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, + progress_format: str | None = None, + panel_lines: int | None = None, + no_progress: bool = False, +) -> Session | None: + """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. + + Parameters + ---------- + workspace_file : list of str + paths or session names to workspace files + socket_name : str, optional + ``tmux -L `` + socket_path: str, optional + ``tmux -S `` + new_session_name: str, options + ``tmux new -s `` + colors : int, optional + Force tmux to support 256 or 88 colors. + detached : bool + Force detached state. default False. + answer_yes : bool + Assume yes when given prompt to attach in new session. + Default False. + append : bool + Assume current when given prompt to append windows in same session. + Default False. + cli_colors : :class:`~tmuxp._internal.colors.Colors`, optional + Colors instance for CLI output formatting. If None, uses + :attr:`~tmuxp._internal.colors.ColorMode.AUTO`. + progress_format : str, optional + Spinner format preset name or custom format string with tokens. + panel_lines : int, optional + Number of script-output lines shown in the spinner panel. + Defaults to the :class:`~tmuxp.cli._progress.Spinner` default (3). + Override via ``TMUXP_PROGRESS_LINES`` environment variable. + no_progress : bool + Disable the progress spinner entirely. Default False. + Also disabled when ``TMUXP_PROGRESS=0``. + + Notes + ----- + tmuxp will check and load a workspace file. The file will use + :class:`~tmuxp._internal.config_reader.ConfigReader` to load a JSON/YAML + into a :class:`dict`. Then :func:`~tmuxp.workspace.loader.expand` and + :func:`~tmuxp.workspace.loader.trickle` will be used to expand any + shorthands, template variables, or file paths relative to where the + config/script is executed from. + + :func:`~tmuxp.workspace.loader.expand` accepts the directory of the config + file, so the user's workspace can resolve absolute paths relative to where + the workspace file is. In otherwords, if a workspace file at + */var/moo/hi.yaml* has *./* in its workspaces, we want to be sure any file + path with *./* is relative to */var/moo*, not the user's PWD. + + A :class:`libtmux.Server` object is created. No tmux server is started yet, + just the object. + + The prepared workspace and its server object is passed into an instance + of :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol`. + + A sanity check against :meth:`libtmux.common.which` is ran. It will raise + an exception if tmux isn't found. + + If a tmux session under the same name as ``session_name`` in the tmuxp + workspace exists, tmuxp offers to attach the session. Currently, tmuxp + does not allow appending a workspace / incremental building on top of a + current session (pull requests are welcome!). + + :meth:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol.build` + will build the session in the background via using tmux's detached state + (``-d``). + + After the session (workspace) is built, unless the user decided to load + the session in the background via ``tmuxp -d`` (which is in the spirit + of tmux's ``-d``), we need to prompt the user to attach the session. + + If the user is already inside a tmux client, which we detect via the + ``TMUX`` environment variable bring present, we will prompt the user to + switch their current client to it. + + If they're outside of tmux client - in a plain-old PTY - we will + automatically ``attach``. + + If an exception is raised during the building of the workspace, tmuxp will + prompt to cleanup (``$ tmux kill-session``) the session on the user's + 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) + + logger.info( + "loading workspace", + extra={"tmux_config_path": str(workspace_file)}, + ) + _progress_disabled = no_progress or os.getenv("TMUXP_PROGRESS", "1") == "0" + if _progress_disabled: + tmuxp_echo( + cli_colors.info("[Loading]") + + " " + + cli_colors.highlight(str(PrivatePath(workspace_file))), + ) + + # ConfigReader allows us to open a yaml or json file as a dict + raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} + + # shapes workspaces relative to config / profile file location + expanded_workspace = loader.expand( + raw_workspace, + cwd=os.path.dirname(workspace_file), + ) + + # Overridden session name + if new_session_name: + expanded_workspace["session_name"] = new_session_name + + # propagate workspace inheritance (e.g. session -> window, window -> pane) + expanded_workspace = loader.trickle(expanded_workspace) + + t = Server( # create tmux server object + socket_name=socket_name, + socket_path=socket_path, + config_file=tmux_config_file, + colors=colors, + ) + + shutil.which("tmux") # raise exception if tmux not found + + # Builder resolution + creation — outside spinner so plugin prompts are safe. + # Trusted import roots (absent config -> [] -> a no-op sandbox) stay on + # sys.path for the import and instantiation; each build dispatch re-enters + # the sandbox so build-time lazy imports resolve. + try: + builder_paths = resolve_builder_paths(expanded_workspace, workspace_file) + with prepended_sys_path(builder_paths): + builder_cls = resolve_builder_class(expanded_workspace) + builder = builder_cls( + session_config=expanded_workspace, + plugins=load_plugins(expanded_workspace, colors=cli_colors), + server=t, + ) + except exc.EmptyWorkspaceException: + logger.warning( + "workspace file is empty", + extra={"tmux_config_path": str(workspace_file)}, + ) + tmuxp_echo( + cli_colors.warning("[Warning]") + + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", + ) + return None + except exc.WorkspaceBuilderError as e: + logger.debug("workspace builder resolution failed", exc_info=True) + tmuxp_echo(cli_colors.error("[Builder Error]") + f" {e}") + sys.exit(1) + + session_name = expanded_workspace["session_name"] + + # Session-exists check — outside spinner so prompt_yes_no is safe + if builder.session_exists(session_name) and not append: + if not detached and ( + answer_yes + or prompt_yes_no( + f"{cli_colors.highlight(session_name)} is already running. Attach?", + default=True, + color_mode=cli_colors.mode, + ) + ): + _reattach(builder, cli_colors) + return None + + if _progress_disabled: + _private_path = str(PrivatePath(workspace_file)) + with prepended_sys_path(builder_paths): + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + ) + if result is not None: + summary = "" + try: + win_count = len(result.windows) + pane_count = sum(len(w.panes) for w in result.windows) + summary_parts: list[str] = [] + if win_count: + summary_parts.append(f"{win_count} win") + if pane_count: + summary_parts.append(f"{pane_count} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + except Exception: + logger.debug("session gone before summary", exc_info=True) + ctx = { + "session": cli_colors.highlight(session_name), + "workspace_path": cli_colors.info(_private_path), + "summary": cli_colors.muted(summary) if summary else "", + } + checkmark = cli_colors.success("\u2713") + tmuxp_echo( + f"{checkmark} {SUCCESS_TEMPLATE.format_map(_SafeFormatMap(ctx))}" + ) + return result + + # Spinner wraps only the actual build phase + _progress_fmt = resolve_progress_format( + progress_format + if progress_format is not None + else os.getenv("TMUXP_PROGRESS_FORMAT", "default") + ) + _panel_lines_env = os.getenv("TMUXP_PROGRESS_LINES") + if _panel_lines_env: + try: + _panel_lines_env_int: int | None = int(_panel_lines_env) + except ValueError: + _panel_lines_env_int = None + else: + _panel_lines_env_int = None + _panel_lines = panel_lines if panel_lines is not None else _panel_lines_env_int + _private_path = str(PrivatePath(workspace_file)) + _spinner = Spinner( + message=( + f"Loading workspace: {cli_colors.highlight(session_name)} ({_private_path})" + ), + color_mode=cli_colors.mode, + progress_format=_progress_fmt, + output_lines=_panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES, + workspace_path=_private_path, + ) + _success_emitted = False + + def _emit_success() -> None: + nonlocal _success_emitted + if _success_emitted: + return + _success_emitted = True + _spinner.success() + + with ( + _silence_stream_handlers(), + _spinner as spinner, + ): + builder.on_build_event = spinner.on_build_event + _resolved_panel = ( + _panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES + ) + if _resolved_panel != 0: + builder.on_script_output = spinner.add_output_line + with prepended_sys_path(builder_paths): + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + pre_attach_hook=_emit_success, + on_error_hook=spinner.stop, + pre_prompt_hook=spinner.stop, + ) + if result is not None: + _emit_success() + return result + + +def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``load`` subcommand.""" + workspace_files = parser.add_argument( + "workspace_files", + nargs="+", + metavar="workspace-file", + help="filepath to session or filename of session in tmuxp workspace directory", + ) + parser.add_argument( + "-L", + dest="socket_name", + metavar="socket_name", + action="store", + help="passthru to tmux(1) -L", + ) + parser.add_argument( + "-S", + dest="socket_path", + metavar="socket_path", + action="store", + help="passthru to tmux(1) -S", + ) + + tmux_config_file = parser.add_argument( + "-f", + dest="tmux_config_file", + metavar="tmux_config_file", + help="passthru to tmux(1) -f", + ) + + parser.add_argument( + "-s", + dest="new_session_name", + metavar="new_session_name", + help="start new session with new session name", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + parser.add_argument( + "-d", + dest="detached", + action="store_true", + help="load the session without attaching it", + ) + parser.add_argument( + "-a", + "--append", + dest="append", + action="store_true", + help="load workspace, appending windows to the current session", + ) + colorsgroup = parser.add_mutually_exclusive_group() + + colorsgroup.add_argument( + "-2", + dest="colors", + action="store_const", + const=256, + help="force tmux to assume the terminal supports 256 colours.", + ) + + colorsgroup.add_argument( + "-8", + dest="colors", + action="store_const", + const=88, + help="like -2, but indicates that the terminal supports 88 colours.", + ) + parser.set_defaults(colors=None) + + log_file = parser.add_argument( + "--log-file", + metavar="file_path", + action="store", + help="file to log errors/output to", + ) + + parser.add_argument( + "--progress-format", + metavar="FORMAT", + dest="progress_format", + default=None, + help=( + "Spinner line format: preset name " + "(default, minimal, window, pane, verbose) " + "or a format string with tokens " + "{session}, {window}, {progress}, {window_progress}, {pane_progress}, etc. " + "Env: TMUXP_PROGRESS_FORMAT" + ), + ) + + parser.add_argument( + "--progress-lines", + metavar="N", + dest="panel_lines", + type=int, + default=None, + help=( + "Number of script-output lines shown in the spinner panel (default: 3). " + "0 hides the panel entirely (script output goes to stdout). " + "-1 shows unlimited lines (capped to terminal height). " + "Env: TMUXP_PROGRESS_LINES" + ), + ) + + parser.add_argument( + "--no-progress", + dest="no_progress", + action="store_true", + default=False, + help=("Disable the animated progress spinner. Env: TMUXP_PROGRESS=0"), + ) + + try: + import shtab + + workspace_files.complete = shtab.FILE # type: ignore + tmux_config_file.complete = shtab.FILE # type: ignore + log_file.complete = shtab.FILE # type: ignore + except ImportError: + pass + + return parser + + +def command_load( + args: CLILoadNamespace, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Load a tmux workspace from each WORKSPACE_FILE. + + WORKSPACE_FILE is a specifier for a workspace file. + + If WORKSPACE_FILE is a path to a directory, tmuxp will search it for + ".tmuxp.{yaml,yml,json}". + + If WORKSPACE_FILE is has no directory component and only a filename, e.g. + "myworkspace.yaml", tmuxp will search the users's workspace directory for that + file. + + If WORKSPACE_FILE has no directory component, and only a name with no extension, + e.g. "myworkspace", tmuxp will search the users's workspace directory for any + file with the extension ".yaml", ".yml", or ".json" that matches that name. + + If multiple workspace files that match a given WORKSPACE_FILE are found, tmuxp + will warn and pick the first one found. + + If multiple WORKSPACE_FILEs are provided, workspaces will be created for all of + them. The last one provided will be attached. The others will be created in + detached mode. + """ + 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: + log.setup_log_file(args.log_file, args.log_level) + + if args.workspace_files is None or len(args.workspace_files) == 0: + tmuxp_echo(cli_colors.error("Enter at least one config")) + if parser is not None: + parser.print_help() + sys.exit() + return + + last_idx = len(args.workspace_files) - 1 + 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( + workspace_file, + workspace_dir=get_workspace_dir(), + ) + + detached = original_detached_option + new_session_name = original_new_session_name + + if last_idx > 0 and idx < last_idx: + detached = True + new_session_name = None + + load_workspace( + workspace_file, + socket_name=args.socket_name, + socket_path=args.socket_path, + tmux_config_file=args.tmux_config_file, + new_session_name=new_session_name, + colors=args.colors, + detached=detached, + answer_yes=args.answer_yes or False, + append=args.append or False, + cli_colors=cli_colors, + progress_format=args.progress_format, + panel_lines=args.panel_lines, + no_progress=args.no_progress, + ) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py new file mode 100644 index 0000000000..9b293d5eca --- /dev/null +++ b/src/tmuxp/cli/ls.py @@ -0,0 +1,646 @@ +"""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 argparse +import datetime +import json +import logging +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 ( + 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 + +logger = logging.getLogger(__name__) + +LS_DESCRIPTION = build_description( + """ + List workspace files in the tmuxp configuration directory. + """, + ( + ( + None, + [ + "tmuxp ls", + "tmuxp ls --tree", + "tmuxp ls --full", + ], + ), + ( + "Machine-readable output examples", + [ + "tmuxp ls --json", + "tmuxp ls --json --full", + "tmuxp ls --ndjson", + "tmuxp ls --json | jq '.workspaces[].name'", + ], + ), + ), +) + +if t.TYPE_CHECKING: + 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. + + 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 : :class:`~tmuxp._internal.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 : :class:`~tmuxp._internal.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 : :class:`~tmuxp._internal.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 : :class:`~tmuxp._internal.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. + + 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/ + """ + # 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, + } + formatter.emit_object(output_data) + # 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, + } + formatter.emit_object(output_data) + 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..cd1eb37bd7 --- /dev/null +++ b/src/tmuxp/cli/search.py @@ -0,0 +1,1303 @@ +"""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 logging +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 + +logger = logging.getLogger(__name__) + +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 : :class:`~tmuxp._internal.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 : :class:`~tmuxp._internal.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 examples", + [ + "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 new file mode 100644 index 0000000000..57a5ae8e4b --- /dev/null +++ b/src/tmuxp/cli/shell.py @@ -0,0 +1,257 @@ +"""CLI for ``tmuxp shell`` subcommand.""" + +from __future__ import annotations + +import argparse +import logging +import os +import pathlib +import typing as t + +from libtmux.server import Server + +from tmuxp import util +from tmuxp._compat import PY3, PYMINOR + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +SHELL_DESCRIPTION = build_description( + """ + 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", + "pdb", + "code", + "ptipython", + "ptpython", + "ipython", + "bpython", + ] + + +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 + colors: CLIColorsLiteral | None + log_file: str | None + window_name: str | None + command: str | None + shell: CLIShellLiteral | None + use_pythonrc: bool + use_vi_mode: bool + + +def create_shell_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``shell`` subcommand.""" + parser.add_argument("session_name", metavar="session-name", nargs="?") + parser.add_argument("window_name", metavar="window-name", nargs="?") + parser.add_argument( + "-S", + dest="socket_path", + metavar="socket-path", + help="pass-through for tmux -S", + ) + parser.add_argument( + "-L", + dest="socket_name", + metavar="socket-name", + help="pass-through for tmux -L", + ) + parser.add_argument( + "-c", + dest="command", + help="instead of opening shell, execute python code in libtmux and exit", + ) + + shells = parser.add_mutually_exclusive_group() + shells.add_argument( + "--best", + dest="shell", + const="best", + action="store_const", + help="use best shell available in site packages", + default="best", + ) + shells.add_argument( + "--pdb", + dest="shell", + const="pdb", + action="store_const", + help="use plain pdb", + ) + shells.add_argument( + "--code", + dest="shell", + const="code", + action="store_const", + help="use stdlib's code.interact()", + ) + shells.add_argument( + "--ptipython", + dest="shell", + const="ptipython", + action="store_const", + help="use ptpython + ipython", + ) + shells.add_argument( + "--ptpython", + dest="shell", + const="ptpython", + action="store_const", + help="use ptpython", + ) + shells.add_argument( + "--ipython", + dest="shell", + const="ipython", + action="store_const", + help="use ipython", + ) + shells.add_argument( + "--bpython", + dest="shell", + const="bpython", + action="store_const", + help="use bpython", + ) + + parser.add_argument( + "--use-pythonrc", + dest="use_pythonrc", + action="store_true", + help="load PYTHONSTARTUP env var and ~/.pythonrc.py script in --code", + default=False, + ) + parser.add_argument( + "--no-startup", + dest="use_pythonrc", + action="store_false", + help="load PYTHONSTARTUP env var and ~/.pythonrc.py script in --code", + default=False, + ) + parser.add_argument( + "--use-vi-mode", + dest="use_vi_mode", + action="store_true", + help="use vi-mode in ptpython/ptipython", + default=False, + ) + parser.add_argument( + "--no-vi-mode", + dest="use_vi_mode", + action="store_false", + help="use vi-mode in ptpython/ptipython", + default=False, + ) + return parser + + +def command_shell( + args: CLIShellNamespace, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp shell`` for tmux server, session, window and pane. + + Priority given to loaded session/window/pane objects: + + - session_name and window_name arguments + - current shell: envvar ``TMUX_PANE`` for determining window and session + - :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): + env_socket_path = pathlib.Path(env_tmux.split(",")[0]) + if ( + env_socket_path.exists() + and args.socket_path is None + and args.socket_name is None + ): + args.socket_path = str(env_socket_path) + + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) + + server.raise_if_dead() + + current_pane = util.get_current_pane(server=server) + + session = util.get_session( + server=server, + session_name=args.session_name, + current_pane=current_pane, + ) + + window = util.get_window( + session=session, + window_name=args.window_name, + current_pane=current_pane, + ) + + pane = util.get_pane(window=window, current_pane=current_pane) + + if args.command is not None: + exec(args.command) + elif args.shell == "pdb" or ( + os.getenv("PYTHONBREAKPOINT") and PY3 and PYMINOR >= 7 + ): + from tmuxp._compat import breakpoint as tmuxp_breakpoint + + tmuxp_echo( + 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" + tmuxp_echo( + 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 + use_vi_mode=args.use_vi_mode, # shell: ptpython, ptipython + # tmux environment / libtmux variables + server=server, + session=session, + window=window, + pane=pane, + ) diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py new file mode 100644 index 0000000000..5429b7867e --- /dev/null +++ b/src/tmuxp/cli/utils.py @@ -0,0 +1,233 @@ +"""CLI utility helpers for tmuxp.""" + +from __future__ import annotations + +import logging +import typing as t + +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 + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from collections.abc import Callable, Sequence + +# 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. + + Parameters + ---------- + name : + prompt text + default : + default value if no input provided. + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. + + Returns + ------- + str + + See Also + -------- + prompt_bool + Prompt for a boolean response. + prompt_choices + Prompt for a value from a fixed choice list. + prompt_yes_no + Prompt for a yes/no response. + + Notes + ----- + The prompt helpers are adapted from + `flask-script `_; see the + `flask-script license `_. + """ + 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 + # 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, + 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( + name: str, + 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. + + Parameters + ---------- + name : + prompt text + default : + default value if no input provided. + yes_choices : + 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") + + if default is None: + prompt_choice = "y/n" + elif default is True: + prompt_choice = "Y/n" + else: + prompt_choice = "y/N" + + prompt_ = name + " " + colors.muted(f"[{prompt_choice}]") + prompt_ += (name.endswith("?") and " ") or ": " + + while True: + rv = input(prompt_) + if not rv: + return default + if rv.lower() in yes_choices: + return True + if rv.lower() in no_choices: + return False + + +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: 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. + + Parameters + ---------- + name : + prompt text + choices : + 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] = [] + + for choice in choices: + if isinstance(choice, str): + options.append(choice) + elif isinstance(choice, tuple): + options.append(f"{choice} [{choice[0]}]") + 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: + prompt_ = prompt_text + ": " + rv = input(prompt_) or default + if not rv or rv == default: + return default + rv = rv.lower() + if rv in no_choice: + return None + if rv in choices_: + return rv + tmuxp_echo( + colors.warning(f"Invalid choice '{rv}'. ") + + f"Please choose from: {', '.join(choices_)}" + ) diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py new file mode 100644 index 0000000000..5168aa10d0 --- /dev/null +++ b/src/tmuxp/exc.py @@ -0,0 +1,246 @@ +"""Exceptions for tmuxp.""" + +from __future__ import annotations + +import logging + +from libtmux._internal.query_list import ObjectDoesNotExist + +from ._compat import implements_to_string + +logger = logging.getLogger(__name__) + + +class TmuxpException(Exception): + """Base Exception for Tmuxp Errors.""" + + +class WorkspaceError(TmuxpException): + """Error parsing tmuxp workspace data.""" + + +class SessionNotFound(TmuxpException): + """tmux session not found.""" + + def __init__( + self, + session_target: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = "Session not found" + if session_target is not None: + msg += f": {session_target}" + return super().__init__(msg, *args, **kwargs) + + +class WindowNotFound(TmuxpException): + """tmux window not found.""" + + def __init__( + self, + window_target: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = "Window not found" + if window_target is not None: + msg += f": {window_target}" + return super().__init__(msg, *args, **kwargs) + + +class PaneNotFound(TmuxpException): + """tmux pane not found.""" + + def __init__( + self, + pane_target: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = "Pane not found" + if pane_target is not None: + msg += f": {pane_target}" + return super().__init__(msg, *args, **kwargs) + + +class EmptyWorkspaceException(WorkspaceError): + """Workspace file is empty.""" + + def __init__( + self, + *args: object, + **kwargs: object, + ) -> None: + return super().__init__("Session configuration is empty.", *args, **kwargs) + + +class SessionMissingWorkspaceException(WorkspaceError, ObjectDoesNotExist): + """Session missing while loading tmuxp workspace.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__( + "No session object exists for WorkspaceBuilder. " + "Tip: Add session_name in constructor or run WorkspaceBuilder.build()", + *args, + **kwargs, + ) + + +class ActiveSessionMissingWorkspaceException(WorkspaceError): + """Active session cannot be found while loading tmuxp workspace.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__("No session active.", *args, **kwargs) + + +class WorkspaceBuilderError(WorkspaceError): + """Base error for resolving and validating a workspace builder.""" + + +class WorkspaceBuilderNotFound(WorkspaceBuilderError): + """Configured ``workspace_builder`` could not be resolved. + + >>> print(WorkspaceBuilderNotFound("nope")) + Workspace builder 'nope' could not be found.... + """ + + def __init__( + self, + target: str, + available: list[str] | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"Workspace builder {target!r} could not be found." + if available: + names = ", ".join(sorted(available)) + msg += f" Available entry point builders: {names}." + else: + msg += ( + " Provide a Python dotted path (e.g. 'package.module:Builder') " + "or register an entry point in the 'tmuxp.workspace_builders' group." + ) + return super().__init__(msg, *args, **kwargs) + + +class WorkspaceBuilderImportError(WorkspaceBuilderError): + """Configured ``workspace_builder`` failed to import. + + >>> print(WorkspaceBuilderImportError("pkg:B")) + Could not import workspace builder 'pkg:B'.... + """ + + def __init__( + self, + target: str, + reason: str | None = None, + paths: list[str] | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"Could not import workspace builder {target!r}." + if reason: + msg += f" {reason}" + if paths: + msg += f" Searched trusted paths: {', '.join(paths)}." + msg += ( + " Confirm the builder is importable, or add its directory to " + "'workspace_builder_paths'." + ) + return super().__init__(msg, *args, **kwargs) + + +class InvalidWorkspaceBuilder(WorkspaceBuilderError): + """Resolved ``workspace_builder`` object is not a usable builder. + + >>> print(InvalidWorkspaceBuilder("pkg:B")) + 'pkg:B' is not a valid workspace builder. + """ + + def __init__( + self, + target: str, + reason: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"{target!r} is not a valid workspace builder" + msg += f": {reason}" if reason else "." + return super().__init__(msg, *args, **kwargs) + + +class WorkspaceBuilderPathError(WorkspaceBuilderError): + """A ``workspace_builder_paths`` entry is not a usable directory. + + >>> print(WorkspaceBuilderPathError("/x")) + workspace_builder_paths entry is invalid: /x.... + """ + + def __init__( + self, + path: str, + reason: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"workspace_builder_paths entry is invalid: {path}." + msg += f" {reason}" if reason else " Each entry must be an existing directory." + return super().__init__(msg, *args, **kwargs) + + +class InvalidWorkspaceBuilderOption(WorkspaceBuilderError): + """A ``workspace_builder_options`` value is invalid. + + >>> print(InvalidWorkspaceBuilderOption("bad")) + Invalid workspace_builder_options: bad + """ + + def __init__(self, reason: str, *args: object, **kwargs: object) -> None: + return super().__init__( + f"Invalid workspace_builder_options: {reason}", + *args, + **kwargs, + ) + + +class TmuxpPluginException(TmuxpException): + """Base Exception for Tmuxp Errors.""" + + +class BeforeLoadScriptNotExists(OSError): + """Raises if shell script could not be found.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + + self.strerror = f"before_script file '{self.strerror}' doesn't exist." + + +@implements_to_string +class BeforeLoadScriptError(Exception): + """Shell script execution error. + + Replaces :class:`subprocess.CalledProcessError` for + :func:`tmuxp.util.run_before_script`. + """ + + def __init__( + self, + returncode: int, + cmd: str, + output: str | None = None, + ) -> None: + self.returncode = returncode + self.cmd = cmd + self.output = output + self.message = ( + f"before_script failed with returncode {self.returncode}.\n" + f"command: {self.cmd}\n" + "Error output:\n" + f"{self.output}" + ) + + def __str__(self) -> str: + """Return shell error message.""" + return self.message diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py new file mode 100644 index 0000000000..d4867f052a --- /dev/null +++ b/src/tmuxp/log.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python +"""Log utilities for tmuxp.""" + +from __future__ import annotations + +import logging +import sys +import time +import typing as t + +from tmuxp._internal.colors import _ansi_colors, _ansi_reset_all + +logger = logging.getLogger(__name__) + +_ANSI_RESET = _ansi_reset_all # "\033[0m" +_ANSI_BRIGHT = "\033[1m" +_ANSI_FG_RESET = "\033[39m" + +LEVEL_COLORS = { + "DEBUG": f"\033[{_ansi_colors['blue']}m", + "INFO": f"\033[{_ansi_colors['green']}m", + "WARNING": f"\033[{_ansi_colors['yellow']}m", + "ERROR": f"\033[{_ansi_colors['red']}m", + "CRITICAL": f"\033[{_ansi_colors['red']}m", +} + + +class TmuxpLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] + """LoggerAdapter that merges extra dictionary on Python < 3.13. + + Follows the portable pattern to avoid repeating the same `extra` on every call + while preserving the ability to add per-call `extra` kwargs. + + Examples + -------- + >>> adapter = TmuxpLoggerAdapter( + ... logging.getLogger("test"), + ... {"tmux_session": "my-session"}, + ... ) + >>> msg, kwargs = adapter.process("hello %s", {"extra": {"tmux_window": "editor"}}) + >>> msg + 'hello %s' + >>> kwargs["extra"]["tmux_session"] + 'my-session' + >>> kwargs["extra"]["tmux_window"] + 'editor' + """ + + def process( + self, msg: t.Any, kwargs: t.MutableMapping[str, t.Any] + ) -> tuple[t.Any, t.MutableMapping[str, t.Any]]: + """Merge extra dictionary on Python < 3.13.""" + extra = dict(self.extra) if self.extra else {} + if "extra" in kwargs: + extra.update(kwargs["extra"]) + kwargs["extra"] = extra + return msg, kwargs + + +def setup_logger( + logger: logging.Logger | None = None, + level: str = "INFO", +) -> None: + """Configure tmuxp's logging for CLI use. + + Can checks for any existing loggers to prevent loading handlers twice. + + Parameters + ---------- + logger : :class:`logging.Logger` + logger instance for tmuxp + """ + if not logger: # if no logger exists, make one + logger = logging.getLogger("tmuxp") + + has_handlers = any(not isinstance(h, logging.NullHandler) for h in logger.handlers) + + if not has_handlers: # setup logger handlers + channel = logging.StreamHandler() + formatter = DebugLogFormatter() if level == "DEBUG" else LogFormatter() + channel.setFormatter(formatter) + logger.addHandler(channel) + + logger.setLevel(level) + + +def set_style( + message: str, + stylized: bool, + style_before: str = "", + style_after: str = "", + prefix: str = "", + suffix: str = "", +) -> str: + """Stylize terminal logging output.""" + if stylized: + return prefix + style_before + message + style_after + suffix + + return prefix + message + suffix + + +class LogFormatter(logging.Formatter): + """Format logs for tmuxp.""" + + def template( + self: logging.Formatter, + record: logging.LogRecord, + stylized: bool = False, + **kwargs: t.Any, + ) -> str: + """ + Return the prefix for the log message. Template for Formatter. + + Parameters + ---------- + record : :class:`logging.LogRecord` + Object passed from :meth:`logging.Formatter.format`. + + Returns + ------- + str + Template for logger message. + """ + reset = _ANSI_RESET + levelname = set_style( + "(%(levelname)s)", + stylized, + style_before=(LEVEL_COLORS.get(record.levelname, "") + _ANSI_BRIGHT), + style_after=_ANSI_RESET, + suffix=" ", + ) + asctime = set_style( + "%(asctime)s", + stylized, + style_before=(f"\033[{_ansi_colors['black']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), + prefix="[", + suffix="]", + ) + name = set_style( + "%(name)s", + stylized, + style_before=(f"\033[{_ansi_colors['white']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), + prefix=" ", + suffix=" ", + ) + + if stylized: + return reset + levelname + asctime + name + reset + + return levelname + asctime + name + + def __init__(self, color: bool = True, **kwargs: t.Any) -> None: + logging.Formatter.__init__(self, **kwargs) + + def format(self, record: logging.LogRecord) -> str: + """Format a log record.""" + try: + record.message = record.getMessage() + except Exception as e: + record.message = f"Bad message ({e!r}): {record.__dict__!r}" + + date_format = "%H:%M:%S" + formatting = self.converter(record.created) + record.asctime = time.strftime(date_format, formatting) + + prefix = self.template(record) % record.__dict__ + + parts = prefix.split(record.message) + formatted = prefix + " " + record.message + return formatted.replace("\n", "\n" + parts[0] + " ") + + +def debug_log_template( + self: type[logging.Formatter], + record: logging.LogRecord, + stylized: bool | None = False, + **kwargs: t.Any, +) -> str: + """ + Return the prefix for the log message. Template for Formatter. + + Parameters + ---------- + record : :class:`logging.LogRecord` + Object passed from :meth:`logging.Formatter.format`. + + Returns + ------- + str + Log template. + """ + reset = _ANSI_RESET + levelname = ( + LEVEL_COLORS.get(record.levelname, "") + + _ANSI_BRIGHT + + "(%(levelname)1.1s)" + + _ANSI_RESET + + " " + ) + asctime = ( + "[" + + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + + "%(asctime)s" + + _ANSI_FG_RESET + + _ANSI_RESET + + "]" + ) + name = ( + " " + + f"\033[{_ansi_colors['white']}m" + + _ANSI_BRIGHT + + "%(name)s" + + _ANSI_FG_RESET + + _ANSI_RESET + + " " + ) + module_funcName = ( + f"\033[{_ansi_colors['green']}m" + _ANSI_BRIGHT + "%(module)s.%(funcName)s()" + ) + lineno = ( + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + + ":" + + _ANSI_RESET + + f"\033[{_ansi_colors['cyan']}m" + + "%(lineno)d" + ) + + return reset + levelname + asctime + name + module_funcName + lineno + reset + + +class DebugLogFormatter(LogFormatter): + """Provides greater technical details than standard log Formatter.""" + + template = debug_log_template + + +def setup_log_file(log_file: str, level: str = "INFO") -> None: + """Attach a file handler to the tmuxp logger. + + Parameters + ---------- + log_file : str + Path to the log file. + level : str + Log level name (e.g. "DEBUG", "INFO"). Selects formatter and sets + handler filtering level. + + Examples + -------- + >>> import tempfile, os, logging + >>> f = tempfile.NamedTemporaryFile(suffix=".log", delete=False) + >>> f.close() + >>> setup_log_file(f.name, level="INFO") + >>> tmuxp_logger = logging.getLogger("tmuxp") + >>> tmuxp_logger.handlers = [ + ... h for h in tmuxp_logger.handlers if not isinstance(h, logging.FileHandler) + ... ] + >>> os.unlink(f.name) + """ + handler = logging.FileHandler(log_file) + formatter = DebugLogFormatter() if level.upper() == "DEBUG" else LogFormatter() + handler.setFormatter(formatter) + handler_level = getattr(logging, level.upper()) + handler.setLevel(handler_level) + tmuxp_logger = logging.getLogger("tmuxp") + tmuxp_logger.addHandler(handler) + if tmuxp_logger.level == logging.NOTSET or tmuxp_logger.level > handler_level: + tmuxp_logger.setLevel(handler_level) + + +def tmuxp_echo( + message: str | None = None, + file: t.TextIO | None = None, +) -> None: + """Print user-facing CLI output. + + Parameters + ---------- + message : str | None + Message to print. If None, does nothing. + file : t.TextIO | None + Output stream. Defaults to sys.stdout. + + Examples + -------- + >>> tmuxp_echo("Session loaded") + Session loaded + + >>> tmuxp_echo("Warning message") + Warning message + """ + if message is None: + return + print(message, file=file or sys.stdout) diff --git a/src/tmuxp/plugin.py b/src/tmuxp/plugin.py new file mode 100644 index 0000000000..d5a65b8024 --- /dev/null +++ b/src/tmuxp/plugin.py @@ -0,0 +1,296 @@ +"""Plugin system for tmuxp.""" + +from __future__ import annotations + +import logging +import typing as t + +import libtmux +from libtmux._compat import LegacyVersion as Version +from libtmux.common import get_version + +from .__about__ import __version__ +from .exc import TmuxpPluginException + +logger = logging.getLogger(__name__) + +#: Minimum version of tmux required to run tmuxp +TMUX_MIN_VERSION = "3.2" + +#: Most recent version of tmux supported +TMUX_MAX_VERSION = None + +#: Minimum version of libtmux required to run libtmux +LIBTMUX_MIN_VERSION = "0.8.3" + +#: Most recent version of libtmux supported +LIBTMUX_MAX_VERSION = None + +#: Minimum version of tmuxp required to use plugins +TMUXP_MIN_VERSION = "1.6.0" + +#: Most recent version of tmuxp +TMUXP_MAX_VERSION = None + + +if t.TYPE_CHECKING: + from typing import TypeGuard + + from libtmux.session import Session + from libtmux.window import Window + from typing_extensions import TypedDict, Unpack + + from ._internal.types import PluginConfigSchema + + class VersionConstraints(TypedDict): + """Version constraints mapping for a tmuxp plugin.""" + + version: Version | str + vmin: str + vmax: str | None + incompatible: list[t.Any | str] + + class TmuxpPluginVersionConstraints(TypedDict): + """Version constraints for a tmuxp plugin.""" + + tmux: VersionConstraints + tmuxp: VersionConstraints + libtmux: VersionConstraints + + +class Config(t.TypedDict): + """tmuxp plugin configuration mapping.""" + + plugin_name: str + tmux_min_version: str + tmux_max_version: str | None + tmux_version_incompatible: list[str] | None + libtmux_min_version: str + libtmux_max_version: str | None + libtmux_version_incompatible: list[str] | None + tmuxp_min_version: str + tmuxp_max_version: str | None + tmuxp_version_incompatible: list[str] | None + + +DEFAULT_CONFIG: Config = { + "plugin_name": "tmuxp-plugin", + "tmux_min_version": TMUX_MIN_VERSION, + "tmux_max_version": TMUX_MAX_VERSION, + "tmux_version_incompatible": None, + "libtmux_min_version": LIBTMUX_MIN_VERSION, + "libtmux_max_version": LIBTMUX_MAX_VERSION, + "libtmux_version_incompatible": None, + "tmuxp_min_version": TMUXP_MIN_VERSION, + "tmuxp_max_version": TMUXP_MAX_VERSION, + "tmuxp_version_incompatible": None, +} + + +def validate_plugin_config(config: PluginConfigSchema) -> TypeGuard[Config]: + """Return True if tmuxp plugin configuration valid, also upcasts.""" + return isinstance(config, dict) + + +def setup_plugin_config( + config: PluginConfigSchema, + default_config: Config = DEFAULT_CONFIG, +) -> Config: + """Initialize tmuxp plugin configuration.""" + new_config = config.copy() + for default_key, default_value in default_config.items(): + if default_key not in new_config: + new_config[default_key] = default_value # type:ignore + + assert validate_plugin_config(new_config) + + return new_config + + +class TmuxpPlugin: + """Base class for a tmuxp plugin.""" + + def __init__(self, **kwargs: Unpack[PluginConfigSchema]) -> None: + """ + Initialize plugin. + + The default version values are set to the versions that the plugin + system requires. + + Parameters + ---------- + plugin_name : str + Name of the child plugin. Used in error message plugin fails to + load + + tmux_min_version : str + Min version of tmux that the plugin supports + + tmux_max_version : str + Min version of tmux that the plugin supports + + tmux_version_incompatible : list + Versions of tmux that are incompatible with the plugin + + libtmux_min_version : str + Min version of libtmux that the plugin supports + + libtmux_max_version : str + Max version of libtmux that the plugin supports + + libtmux_version_incompatible : list + Versions of libtmux that are incompatible with the plugin + + tmuxp_min_version : str + Min version of tmuxp that the plugin supports + + tmuxp_max_version : str + Max version of tmuxp that the plugin supports + + tmuxp_version_incompatible : list + Versions of tmuxp that are incompatible with the plugin + + """ + config = setup_plugin_config(config=kwargs) + self.plugin_name = config["plugin_name"] + + # Dependency versions + self.tmux_version = get_version() + self.libtmux_version = libtmux.__about__.__version__ + self.tmuxp_version = Version(__version__) + + self.version_constraints: TmuxpPluginVersionConstraints = { + "tmux": { + "version": self.tmux_version, + "vmin": config["tmux_min_version"], + "vmax": config["tmux_max_version"], + "incompatible": config["tmux_version_incompatible"] or [], + }, + "libtmux": { + "version": self.libtmux_version, + "vmin": config["libtmux_min_version"], + "vmax": config["libtmux_max_version"], + "incompatible": config["libtmux_version_incompatible"] or [], + }, + "tmuxp": { + "version": self.tmuxp_version, + "vmin": config["tmuxp_min_version"], + "vmax": config["tmuxp_max_version"], + "incompatible": config["tmuxp_version_incompatible"] or [], + }, + } + + self._version_check() + + def _version_check(self) -> None: + """Check all dependency versions for compatibility.""" + logger.debug("checking version constraints for %s", self.plugin_name) + for dep, constraints in self.version_constraints.items(): + assert isinstance(constraints, dict) + try: + assert self._pass_version_check(**constraints) + except AssertionError as e: + msg = ( + "Incompatible {dep} version: {version}\n{plugin_name} " + "requirements:\nmin: {vmin} | max: {vmax} | " + "incompatible: {incompatible}\n".format( + dep=dep, + plugin_name=self.plugin_name, + **constraints, + ) + ) + raise TmuxpPluginException( + msg, + ) from e + + def _pass_version_check( + self, + version: str | Version, + vmin: str, + vmax: str | None, + incompatible: list[t.Any | str], + ) -> bool: + """Provide affirmative if version compatibility is correct.""" + if vmin and version < Version(vmin): + return False + if vmax and version > Version(vmax): + return False + return version not in incompatible + + def before_workspace_builder(self, session: Session) -> None: + """ + Provide a session hook previous to creating the workspace. + + This runs after the session has been created but before any of + the windows/panes/commands are entered. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to hook into + """ + + def on_window_create(self, window: Window) -> None: + """ + Provide a window hook previous to doing anything with a window. + + This runs runs before anything is created in the windows, like panes. + + Parameters + ---------- + window: :class:`libtmux.Window` + window to hook into + """ + + def after_window_finished(self, window: Window) -> None: + """ + Provide a window hook after creating the window. + + This runs after everything has been created in the window, including + the panes and all of the commands for the panes. It also runs after the + ``options_after`` has been applied to the window. + + Parameters + ---------- + window: :class:`libtmux.Window` + window to hook into + """ + + def before_script(self, session: Session) -> None: + """ + Provide a session hook after the workspace has been built. + + This runs after the workspace has been loaded with ``tmuxp load``. It + augments instead of replaces the ``before_script`` section of the + workspace data. + + This hook provides access to the LibTmux.session object for any + behavior that would be used in the ``before_script`` section of the + workspace file that needs access directly to the session object. + This runs after the workspace has been loaded with ``tmuxp load``. + + The hook augments, rather than replaces, the ``before_script`` section + of the workspace. While it is possible to do all of the + ``before_script`` workspace in this function, if a shell script + is currently being used for the workspace, it would be cleaner to + continue using the script in the ``before_section``. + + If changes to the session need to be made prior to anything being + built, please use + :meth:`~tmuxp.plugin.TmuxpPlugin.before_workspace_builder` instead. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to hook into + """ + + def reattach(self, session: Session) -> None: + """ + Provide a session hook before reattaching to the session. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to hook into + """ diff --git a/tmuxp/shell.py b/src/tmuxp/shell.py similarity index 50% rename from tmuxp/shell.py rename to src/tmuxp/shell.py index 0113eb48e1..3d56655ba5 100644 --- a/tmuxp/shell.py +++ b/src/tmuxp/shell.py @@ -1,80 +1,135 @@ -"""Utility and helper methods for tmuxp. +# mypy: allow-untyped-calls +"""Utility and helper methods for tmuxp.""" -tmuxp.shell -~~~~~~~~~~~ +from __future__ import annotations -""" import logging import os +import pathlib +import typing as t logger = logging.getLogger(__name__) - -def has_ipython(): +if t.TYPE_CHECKING: + from collections.abc import Callable + from types import ModuleType + from typing import TypeAlias + + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + from typing_extensions import NotRequired, TypedDict, Unpack + + CLIShellLiteral: TypeAlias = t.Literal[ + "best", + "pdb", + "code", + "ptipython", + "ptpython", + "ipython", + "bpython", + ] + + class LaunchOptionalImports(TypedDict): + """tmuxp shell optional imports.""" + + server: NotRequired[Server] + session: NotRequired[Session] + window: NotRequired[Window] + pane: NotRequired[Pane] + + class LaunchImports(t.TypedDict): + """tmuxp shell launch import mapping.""" + + libtmux: ModuleType + Server: type[Server] + Session: type[Session] + Window: type[Window] + Pane: type[Pane] + server: Server | None + session: Session | None + window: Window | None + pane: Pane | None + + +def has_ipython() -> bool: + """Return True if ipython is installed.""" try: - from IPython import start_ipython # NOQA F841 + from IPython import start_ipython # NOQA: F401 except ImportError: try: - from IPython.Shell import IPShell # NOQA F841 + from IPython.Shell import IPShell # NOQA: F401 except ImportError: return False return True -def has_ptpython(): +def has_ptpython() -> bool: + """Return True if ptpython is installed.""" try: - from ptpython.repl import embed, run_config # NOQA F841 + from ptpython.repl import embed, run_config # F401 except ImportError: try: - from prompt_toolkit.contrib.repl import embed, run_config # NOQA F841 + from prompt_toolkit.contrib.repl import embed, run_config # NOQA: F401 except ImportError: return False return True -def has_ptipython(): +def has_ptipython() -> bool: + """Return True if ptpython + ipython are both installed.""" try: - from ptpython.ipython import embed # NOQA F841 - from ptpython.repl import run_config # NOQA F841 + from ptpython.ipython import embed # F401 + from ptpython.repl import run_config # F401 except ImportError: try: - from prompt_toolkit.contrib.ipython import embed # NOQA F841 - from prompt_toolkit.contrib.repl import run_config # NOQA F841 + from prompt_toolkit.contrib.ipython import embed # NOQA: F401 + from prompt_toolkit.contrib.repl import run_config # NOQA: F401 except ImportError: return False return True -def has_bpython(): +def has_bpython() -> bool: + """Return True if bpython is installed.""" try: - from bpython import embed # NOQA F841 + from bpython import embed # NOQA: F401 except ImportError: return False return True -def detect_best_shell(): +def detect_best_shell() -> CLIShellLiteral: + """Return the best, most feature-rich shell available.""" if has_ptipython(): - return "ptipython" + shell: CLIShellLiteral = "ptipython" elif has_ptpython(): - return "ptpython" + shell = "ptpython" elif has_ipython(): - return "ipython" + shell = "ipython" elif has_bpython(): - return "bpython" - return "code" + shell = "bpython" + else: + shell = "code" + logger.debug("detected shell: %s", shell) + return shell -def get_bpython(options, extra_args=None): +def get_bpython( + options: LaunchOptionalImports, + extra_args: dict[str, t.Any] | None = None, +) -> Callable[[], None]: + """Return bpython shell.""" if extra_args is None: extra_args = {} - from bpython import embed # NOQA F841 + from bpython import embed - def launch_bpython(): + def launch_bpython() -> None: imported_objects = get_launch_args(**options) kwargs = {} if extra_args: @@ -84,21 +139,26 @@ def launch_bpython(): return launch_bpython -def get_ipython_arguments(): +def get_ipython_arguments() -> list[str]: + """Return ipython shell args via ``IPYTHON_ARGUMENTS`` environment variables.""" ipython_args = "IPYTHON_ARGUMENTS" return os.environ.get(ipython_args, "").split() -def get_ipython(options, **extra_args): +def get_ipython( + options: LaunchOptionalImports, + **extra_args: dict[str, t.Any], +) -> t.Any: + """Return ipython shell.""" try: from IPython import start_ipython - def launch_ipython(): + def launch_ipython() -> None: imported_objects = get_launch_args(**options) ipython_arguments = extra_args or get_ipython_arguments() start_ipython(argv=ipython_arguments, user_ns=imported_objects) - return launch_ipython + return launch_ipython # NOQA: TRY300 except ImportError: # IPython < 0.11 # Explicitly pass an empty list as arguments, because otherwise @@ -106,7 +166,7 @@ def launch_ipython(): # Notebook not supported for IPython < 0.11. from IPython.Shell import IPShell - def launch_ipython(): + def launch_ipython() -> None: imported_objects = get_launch_args(**options) shell = IPShell(argv=[], user_ns=imported_objects) shell.mainloop() @@ -114,15 +174,16 @@ def launch_ipython(): return launch_ipython -def get_ptpython(options, vi_mode=False): +def get_ptpython(options: LaunchOptionalImports, vi_mode: bool = False) -> t.Any: + """Return ptpython shell.""" try: from ptpython.repl import embed, run_config except ImportError: from prompt_toolkit.contrib.repl import embed, run_config - def launch_ptpython(): + def launch_ptpython() -> None: imported_objects = get_launch_args(**options) - history_filename = os.path.expanduser("~/.ptpython_history") + history_filename = str(pathlib.Path("~/.ptpython_history").expanduser()) embed( globals=imported_objects, history_filename=history_filename, @@ -133,8 +194,8 @@ def launch_ptpython(): return launch_ptpython -def get_ptipython(options, vi_mode=False): - """Based on django-extensions +def get_ptipython(options: LaunchOptionalImports, vi_mode: bool = False) -> t.Any: + """Based on django-extensions. Run renamed to launch, get_imported_objects renamed to get_launch_args """ @@ -146,9 +207,9 @@ def get_ptipython(options, vi_mode=False): from prompt_toolkit.contrib.ipython import embed from prompt_toolkit.contrib.repl import run_config - def launch_ptipython(): + def launch_ptipython() -> None: imported_objects = get_launch_args(**options) - history_filename = os.path.expanduser("~/.ptpython_history") + history_filename = str(pathlib.Path("~/.ptpython_history").expanduser()) embed( user_ns=imported_objects, history_filename=history_filename, @@ -159,15 +220,20 @@ def launch_ptipython(): return launch_ptipython -def get_launch_args(**kwargs): +def get_launch_args(**kwargs: Unpack[LaunchOptionalImports]) -> LaunchImports: + """Return tmuxp shell launch arguments, counting for overrides.""" import libtmux + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window return { "libtmux": libtmux, - "Server": libtmux.Server, - "Session": libtmux.Session, - "Window": libtmux.Window, - "Pane": libtmux.Pane, + "Server": Server, + "Session": Session, + "Window": Window, + "Pane": Pane, "server": kwargs.get("server"), "session": kwargs.get("session"), "window": kwargs.get("window"), @@ -175,7 +241,8 @@ def get_launch_args(**kwargs): } -def get_code(use_pythonrc, imported_objects): +def get_code(use_pythonrc: bool, imported_objects: LaunchImports) -> t.Any: + """Launch basic python shell via :mod:`code`.""" import code try: @@ -188,7 +255,11 @@ def get_code(use_pythonrc, imported_objects): # we already know 'readline' was imported successfully. import rlcompleter - readline.set_completer(rlcompleter.Completer(imported_objects).complete) + readline.set_completer( + rlcompleter.Completer( + imported_objects, # type:ignore + ).complete, + ) # Enable tab completion on systems using libedit (e.g. macOS). # These lines are copied from Lib/site.py on Python 3.4. readline_doc = getattr(readline, "__doc__", "") @@ -200,27 +271,37 @@ def get_code(use_pythonrc, imported_objects): # We want to honor both $PYTHONSTARTUP and .pythonrc.py, so follow system # conventions and get $PYTHONSTARTUP first then .pythonrc.py. if use_pythonrc: + PYTHONSTARTUP = os.environ.get("PYTHONSTARTUP") for pythonrc in { - os.environ.get("PYTHONSTARTUP"), - os.path.expanduser("~/.pythonrc.py"), + *([pathlib.Path(PYTHONSTARTUP)] if PYTHONSTARTUP is not None else []), + pathlib.Path("~/.pythonrc.py").expanduser(), }: if not pythonrc: continue - if not os.path.isfile(pythonrc): + if not pythonrc.is_file(): continue - with open(pythonrc) as handle: + with pythonrc.open() as handle: pythonrc_code = handle.read() # Match the behavior of the cpython shell where an error in # PYTHONSTARTUP prints an exception and continues. - exec(compile(pythonrc_code, pythonrc, "exec"), imported_objects) + exec( + compile(pythonrc_code, pythonrc, "exec"), + imported_objects, # type:ignore + ) - def launch_code(): - code.interact(local=imported_objects) + def launch_code() -> None: + code.interact(local=imported_objects) # type:ignore return launch_code -def launch(shell="best", use_pythonrc=False, use_vi_mode=False, **kwargs): +def launch( + shell: CLIShellLiteral | None = "best", + use_pythonrc: bool = False, + use_vi_mode: bool = False, + **kwargs: Unpack[LaunchOptionalImports], +) -> None: + """Launch interactive libtmux shell for tmuxp shell.""" # Also allowing passing shell='code' to force using code.interact imported_objects = get_launch_args(**kwargs) diff --git a/src/tmuxp/types.py b/src/tmuxp/types.py new file mode 100644 index 0000000000..4aa5031239 --- /dev/null +++ b/src/tmuxp/types.py @@ -0,0 +1,21 @@ +"""Internal :term:`type annotations `. + +Notes +----- +:class:`StrPath` and :class:`StrOrBytesPath` is based on `typeshed's`_. + +.. _typeshed's: https://github.com/python/typeshed/blob/9687d5/stdlib/_typeshed/__init__.pyi#L98 +""" # E501 + +from __future__ import annotations + +import logging +import typing as t + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from os import PathLike + +StrPath = t.Union[str, "PathLike[str]"] +""":class:`os.PathLike` or :class:`str`""" diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py new file mode 100644 index 0000000000..152b1f6c06 --- /dev/null +++ b/src/tmuxp/util.py @@ -0,0 +1,217 @@ +"""Utility and helper methods for tmuxp.""" + +from __future__ import annotations + +import logging +import os +import shlex +import subprocess +import sys +import typing as t + +from . import exc +from .log import tmuxp_echo + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + +logger = logging.getLogger(__name__) + +PY2 = sys.version_info[0] == 2 + + +def run_before_script( + script_file: str | pathlib.Path, + cwd: pathlib.Path | None = None, + on_line: t.Callable[[str], None] | None = None, +) -> int: + """Execute shell script, streaming output to callback or terminal (if TTY). + + Output is buffered and optionally forwarded via the ``on_line`` callback. + """ + script_cmd = shlex.split(str(script_file)) + + try: + proc = subprocess.Popen( + script_cmd, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, # decode to str + errors="backslashreplace", + ) + except FileNotFoundError as e: + raise exc.BeforeLoadScriptNotExists( + e, + os.path.abspath(script_file), # NOQA: PTH100 + ) from e + + out_buffer = [] + err_buffer = [] + + # While process is running, read lines from stdout/stderr + # and write them to this process's stdout/stderr if isatty + is_out_tty = sys.stdout.isatty() + is_err_tty = sys.stderr.isatty() + + # You can do a simple loop reading in real-time: + while True: + # Use .poll() to check if the child has exited + return_code = proc.poll() + + # Read one line from stdout, if available + line_out = proc.stdout.readline() if proc.stdout else "" + + # Read one line from stderr, if available + line_err = proc.stderr.readline() if proc.stderr else "" + + if line_out and line_out.strip(): + out_buffer.append(line_out) + if on_line is not None: + on_line(line_out) + elif is_out_tty: + sys.stdout.write(line_out) + sys.stdout.flush() + + if line_err and line_err.strip(): + err_buffer.append(line_err) + if on_line is not None: + on_line(line_err) + elif is_err_tty: + sys.stderr.write(line_err) + sys.stderr.flush() + + # If no more data from pipes and process ended, break + if not line_out and not line_err and return_code is not None: + break + + # At this point, the process has finished + return_code = proc.wait() + + if return_code != 0: + # Join captured stderr lines for your exception + stderr_str = "".join(err_buffer).strip() + raise exc.BeforeLoadScriptError( + return_code, + os.path.abspath(script_file), # NOQA: PTH100 + stderr_str, + ) + + return return_code + + +def oh_my_zsh_auto_title() -> None: + """Give warning and offer to fix ``DISABLE_AUTO_TITLE``. + + See: https://github.com/robbyrussell/oh-my-zsh/pull/257 + """ + if ( + "SHELL" in os.environ + and "zsh" in os.environ.get("SHELL", "") + and os.path.exists(os.path.expanduser("~/.oh-my-zsh")) # NOQA: PTH110, PTH111 + and ( + "DISABLE_AUTO_TITLE" not in os.environ + or os.environ.get("DISABLE_AUTO_TITLE") == "false" + ) + ): + logger.warning("oh-my-zsh DISABLE_AUTO_TITLE not set") + tmuxp_echo( + "oh-my-zsh DISABLE_AUTO_TITLE not set.\n\n" + "Please set:\n\n" + "\texport DISABLE_AUTO_TITLE='true'\n\n" + "in ~/.zshrc or where your zsh profile is stored.\n" + 'Remember the "export" at the beginning!\n\n' + "Then create a new shell or type:\n\n" + "\t$ source ~/.zshrc", + ) + + +def get_current_pane(server: Server) -> Pane | None: + """Return Pane if one found in env.""" + if os.getenv("TMUX_PANE") is not None: + try: + return next(p for p in server.panes if p.pane_id == os.getenv("TMUX_PANE")) + except StopIteration: + pass + return None + + +def get_session( + server: Server, + session_name: str | None = None, + current_pane: Pane | None = None, +) -> Session: + """Get tmux session for server by session name, respects current pane, if passed.""" + try: + if session_name: + session = server.sessions.get(session_name=session_name) + elif current_pane is not None: + session = server.sessions.get(session_id=current_pane.session_id) + else: + current_pane = get_current_pane(server) + if current_pane: + session = server.sessions.get(session_id=current_pane.session_id) + else: + session = server.sessions[0] + + except Exception as e: + if session_name: + raise exc.SessionNotFound(session_name) from e + raise exc.SessionNotFound from e + + assert session is not None + return session + + +def get_window( + session: Session, + window_name: str | None = None, + current_pane: Pane | None = None, +) -> Window: + """Get tmux window for server by window name, respects current pane, if passed.""" + try: + if window_name: + window = session.windows.get(window_name=window_name) + elif current_pane is not None: + window = session.windows.get(window_id=current_pane.window_id) + else: + window = session.windows[0] + except Exception as e: + if window_name: + raise exc.WindowNotFound(window_target=window_name) from e + if current_pane: + raise exc.WindowNotFound(window_target=str(current_pane)) from e + raise exc.WindowNotFound from e + + assert window is not None + return window + + +def get_pane(window: Window, current_pane: Pane | None = None) -> Pane: + """Get tmux pane for server by pane name, respects current pane, if passed.""" + pane = None + try: + if current_pane is not None: + pane = window.panes.get(pane_id=current_pane.pane_id) + else: + pane = window.active_pane + except Exception as e: + logger.debug( + "pane lookup failed", + extra={"tmux_pane": str(current_pane) if current_pane else ""}, + ) + if current_pane: + raise exc.PaneNotFound(str(current_pane)) from e + raise exc.PaneNotFound from e + + if pane is None: + if current_pane: + raise exc.PaneNotFound(str(current_pane)) + raise exc.PaneNotFound + + return pane diff --git a/src/tmuxp/workspace/__init__.py b/src/tmuxp/workspace/__init__.py new file mode 100644 index 0000000000..2b3996b050 --- /dev/null +++ b/src/tmuxp/workspace/__init__.py @@ -0,0 +1,7 @@ +"""tmuxp workspace functionality.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/workspace/builder/__init__.py b/src/tmuxp/workspace/builder/__init__.py new file mode 100644 index 0000000000..469a5308e9 --- /dev/null +++ b/src/tmuxp/workspace/builder/__init__.py @@ -0,0 +1,43 @@ +"""Workspace builders: turn an expanded workspace ``dict`` into a tmux session. + +:class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` is the default +implementation. A workspace may select a different builder with the +``workspace_builder`` config key, resolved by +:mod:`tmuxp.workspace.builder.registry`. + +``WorkspaceBuilder`` remains importable here as a backwards-compatible alias of +:class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. +""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ( + ClassicWorkspaceBuilder, + get_default_columns, + get_default_rows, +) +from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol +from tmuxp.workspace.builder.registry import ( + WORKSPACE_BUILDERS_GROUP, + available_builders, + prepended_sys_path, + resolve_builder_class, + resolve_builder_paths, +) + +# Backwards-compatible alias: the classic builder was historically named +# ``WorkspaceBuilder`` and imported from ``tmuxp.workspace.builder``. +WorkspaceBuilder = ClassicWorkspaceBuilder + +__all__ = [ + "WORKSPACE_BUILDERS_GROUP", + "ClassicWorkspaceBuilder", + "WorkspaceBuilder", + "WorkspaceBuilderProtocol", + "available_builders", + "get_default_columns", + "get_default_rows", + "prepended_sys_path", + "resolve_builder_class", + "resolve_builder_paths", +] diff --git a/src/tmuxp/workspace/builder/classic.py b/src/tmuxp/workspace/builder/classic.py new file mode 100644 index 0000000000..c465eb29e4 --- /dev/null +++ b/src/tmuxp/workspace/builder/classic.py @@ -0,0 +1,907 @@ +"""Create a tmux workspace from a workspace :class:`dict`.""" + +from __future__ import annotations + +import logging +import os +import shutil +import time +import typing as t + +from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.pane import Pane +from libtmux.server import Server +from libtmux.session import Session +from libtmux.window import Window + +from tmuxp import exc +from tmuxp.log import TmuxpLoggerAdapter +from tmuxp.util import get_current_pane, run_before_script +from tmuxp.workspace.options import ( + PaneReadiness, + WorkspaceBuilderOptions, + resolve_session_shell, + shell_is_zsh, +) + +if t.TYPE_CHECKING: + from collections.abc import Iterator + +logger = logging.getLogger(__name__) + + +def _wait_for_pane_ready( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, +) -> bool: + """Wait for pane shell to draw its prompt. + + Polls the pane's cursor position until it moves from origin (0, 0), + indicating the shell has finished initializing and drawn its prompt. + + Parameters + ---------- + pane : :class:`libtmux.Pane` + pane to wait for + timeout : float + maximum seconds to wait before giving up + interval : float + seconds between polling attempts + + Returns + ------- + bool + True if pane became ready, False on timeout or error + + Examples + -------- + >>> pane = session.active_window.active_pane + + Wait for the shell to be ready: + + >>> _wait_for_pane_ready(pane, timeout=5.0) + True + """ + start = time.monotonic() + while time.monotonic() - start < timeout: + try: + pane.refresh() + except Exception: + logger.debug( + "pane refresh failed during readiness check", + exc_info=True, + extra={"tmux_pane": str(pane.pane_id)}, + ) + return False + if pane.cursor_x != "0" or pane.cursor_y != "0": + logger.debug( + "pane ready, cursor moved from origin", + extra={"tmux_pane": str(pane.pane_id)}, + ) + return True + time.sleep(interval) + logger.debug( + "pane readiness check timed out after %.1f seconds", + timeout, + extra={"tmux_pane": str(pane.pane_id)}, + ) + return False + + +COLUMNS_FALLBACK = 80 + + +def get_default_columns() -> int: + """Return default session column size use when building new tmux sessions.""" + return int( + os.getenv("TMUXP_DEFAULT_COLUMNS", os.getenv("COLUMNS", COLUMNS_FALLBACK)), + ) + + +ROWS_FALLBACK = int(os.getenv("TMUXP_DEFAULT_ROWS", os.getenv("ROWS", 24))) + + +def get_default_rows() -> int: + """Return default session row size use when building new tmux sessions.""" + return int(os.getenv("TMUXP_DEFAULT_ROWS", os.getenv("ROWS", ROWS_FALLBACK))) + + +class ClassicWorkspaceBuilder: + """Load workspace from workspace :class:`dict` object. + + Build tmux workspace from a configuration. Creates and names windows, sets options, + splits windows into panes. + + Examples + -------- + >>> import yaml + + >>> session_config = yaml.load(''' + ... session_name: sample workspace + ... start_directory: '~' + ... windows: + ... - window_name: editor + ... layout: main-vertical + ... panes: + ... - shell_command: + ... - cmd: vim + ... - shell_command: + ... - cmd: echo "hey" + ... + ... - window_name: logging + ... panes: + ... - shell_command: + ... - cmd: tail | echo 'hi' + ... + ... - window_name: test + ... panes: + ... - shell_command: + ... - cmd: htop + ... ''', Loader=yaml.Loader) + + >>> builder = ClassicWorkspaceBuilder(session_config=session_config, server=server) + + **New session:** + + >>> builder.build() + + >>> new_session = builder.session + + >>> new_session.name == 'sample workspace' + True + + >>> len(new_session.windows) + 3 + + >>> sorted([window.name for window in new_session.windows]) + ['editor', 'logging', 'test'] + + **Existing session:** + + >>> len(session.windows) + 1 + + >>> builder.build(session=session) + + _Caveat:_ Preserves old session name: + + >>> session.name == 'sample workspace' + False + + >>> len(session.windows) + 3 + + >>> sorted([window.name for window in session.windows]) + ['editor', 'logging', 'test'] + + **Progress callback:** + + >>> calls: list[str] = [] + >>> progress_cfg = { + ... "session_name": "progress-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=progress_cfg, + ... server=server, + ... on_progress=calls.append, + ... ) + >>> builder.build() + >>> "Workspace built" in calls + True + + **Before-script hook:** + + >>> hook_calls: list[bool] = [] + >>> no_script_cfg = { + ... "session_name": "hook-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=no_script_cfg, + ... server=server, + ... on_before_script=lambda: hook_calls.append(True), + ... ) + >>> builder.build() + >>> hook_calls # no before_script in config, callback not fired + [] + + **Script output hook:** + + >>> script_lines: list[str] = [] + >>> no_script_cfg2 = { + ... "session_name": "script-output-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=no_script_cfg2, + ... server=server, + ... on_script_output=script_lines.append, + ... ) + >>> builder.build() + >>> script_lines # no before_script in config, callback not fired + [] + + **Build events hook:** + + >>> events: list[dict] = [] + >>> event_cfg = { + ... "session_name": "events-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=event_cfg, + ... server=server, + ... on_build_event=events.append, + ... ) + >>> builder.build() + >>> [e["event"] for e in events] + ['session_created', 'window_started', 'pane_creating', + 'window_done', 'workspace_built'] + >>> next(e for e in events if e["event"] == "session_created")["session_pane_total"] + 1 + + **Build events with before_script:** + + ``before_script_started`` fires before the script runs; + ``before_script_done`` fires in ``finally`` (success or failure). + + >>> script_events: list[dict] = [] + >>> script_event_cfg = { + ... "session_name": "script-events-demo", + ... "before_script": "echo hello", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = ClassicWorkspaceBuilder( + ... session_config=script_event_cfg, + ... server=server, + ... on_build_event=script_events.append, + ... ) + >>> builder.build() + >>> event_names = [e["event"] for e in script_events] + >>> "before_script_started" in event_names + True + >>> "before_script_done" in event_names + True + >>> bs_start = event_names.index("before_script_started") + >>> bs_done = event_names.index("before_script_done") + >>> win_start = event_names.index("window_started") + >>> bs_start < bs_done < win_start + True + + The normal phase of loading is: + + 1. Load JSON / YAML file via :class:`pathlib.Path`:: + + from tmuxp._internal import config_reader + session_config = config_reader.ConfigReader._load(raw_yaml) + + The reader automatically detects the file type from :attr:`pathlib.suffix`. + + We can also parse raw file:: + + import pathlib + from tmuxp._internal import config_reader + + session_config = config_reader.ConfigReader._from_file( + pathlib.Path('path/to/config.yaml') + ) + + 2. :func:`tmuxp.workspace.loader.expand` session_config inline shorthand:: + + from tmuxp.workspace import loader + session_config = loader.expand(session_config) + + 3. :func:`tmuxp.workspace.loader.trickle` passes down default values from session + -> window -> pane if applicable:: + + session_config = loader.trickle(session_config) + + 4. (You are here) We will create a :class:`libtmux.Session` (a real + ``tmux(1)`` session) and iterate through the list of windows, and + their panes, returning full :class:`libtmux.Window` and + :class:`libtmux.Pane` objects each step of the way:: + + workspace = ClassicWorkspaceBuilder( + session_config=session_config, server=server + ) + + It handles the magic of cases where the user may want to start + a session inside tmux (when `$TMUX` is in the env variables). + """ + + server: Server + _session: Session | None + session_name: str + on_progress: t.Callable[[str], None] | None + on_before_script: t.Callable[[], None] | None + on_script_output: t.Callable[[str], None] | None + on_build_event: t.Callable[[dict[str, t.Any]], None] | None + _builder_options: WorkspaceBuilderOptions + _pane_readiness_wait: bool + + def __init__( + self, + session_config: dict[str, t.Any], + server: Server, + plugins: list[t.Any] | None = None, + on_progress: t.Callable[[str], None] | None = None, + on_before_script: t.Callable[[], None] | None = None, + on_script_output: t.Callable[[str], None] | None = None, + on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None, + ) -> None: + """Initialize workspace loading. + + Parameters + ---------- + session_config : dict + session config, includes a :class:`list` of ``windows``. + + plugins : list + plugins to be used for this session + + server : :class:`libtmux.Server` + tmux server to build session in + + on_progress : callable, optional + callback for progress updates during building + + on_before_script : callable, optional + called just before ``before_script`` runs; use to clear the terminal + (e.g. stop a spinner) so script output is not interleaved + + on_script_output : callable, optional + called with each output line from ``before_script`` subprocess; when + set, raw TTY tee is suppressed so the caller can route lines to a + live panel instead + + on_build_event : callable, optional + called with a dict event at each structural build milestone (session + created, window started/done, pane creating, workspace built); used + by the CLI to render a live session tree + + Notes + ----- + TODO: Initialize :class:`libtmux.Session` from here, in + ``self.session``. + """ + if plugins is None: + plugins = [] + if not session_config: + raise exc.EmptyWorkspaceException + + # validation.validate_schema(session_config) + + assert isinstance(server, Server) + self.server = server + + self.session_config = session_config + self.plugins = plugins + self.on_progress = on_progress + self.on_before_script = on_before_script + self.on_script_output = on_script_output + self.on_build_event = on_build_event + + # Builder-behavior catalog (e.g. pane readiness). Parsed eagerly so an + # invalid value fails fast; the readiness decision itself needs a live + # session and is resolved in build(). + self._builder_options = WorkspaceBuilderOptions.from_config(session_config) + # Safe default for direct iter_create_panes() use that bypasses build(); + # build() replaces this with the policy-resolved value. + self._pane_readiness_wait = True + + if self.server is not None and self.session_exists( + session_name=self.session_config["session_name"], + ): + try: + session = self.server.sessions.get( + session_name=self.session_config["session_name"], + ) + assert session is not None + self._session = session + except ObjectDoesNotExist: + pass + + @property + def session(self) -> Session: + """Return tmux session using in workspace builder session.""" + if self._session is None: + raise exc.SessionMissingWorkspaceException + return self._session + + def session_exists(self, session_name: str) -> bool: + """Return true if tmux session already exists.""" + assert session_name is not None + assert isinstance(session_name, str) + assert self.server is not None + exists = self.server.has_session(session_name) + if not exists: + return exists + + try: + self.server.sessions.get(session_name=session_name) + except ObjectDoesNotExist: + return False + return True + + def build(self, session: Session | None = None, append: bool = False) -> None: + """Build tmux workspace in session. + + Optionally accepts ``session`` to build with only session object. + + Without ``session``, it will use :class:`libtmux.Server` at ``self.server`` + passed in on initialization to create a new Session object. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to build workspace in + append : bool + append windows in current active session + """ + if not session: + if not self.server: + msg = ( + "ClassicWorkspaceBuilder.build requires server to be passed " + "on initialization, or pass in session object to here." + ) + raise exc.TmuxpException( + msg, + ) + new_session_kwargs = {} + if "start_directory" in self.session_config: + new_session_kwargs["start_directory"] = self.session_config[ + "start_directory" + ] + + if os.getenv("TMUXP_DETECT_TERMINAL_SIZE", "1") == "1": + terminal_size = shutil.get_terminal_size( + fallback=(get_default_columns(), get_default_rows()), + ) + new_session_kwargs["x"] = terminal_size.columns + new_session_kwargs["y"] = terminal_size.lines + + session = self.server.new_session( + session_name=self.session_config["session_name"], + **new_session_kwargs, + ) + assert session is not None + + assert self.session_config["session_name"] == session.name + assert len(self.session_config["session_name"]) > 0 + + assert session is not None + assert session.name is not None + + if self.on_progress: + self.on_progress(f"Session created: {session.name}") + + self._session = session + if self.on_build_event: + self.on_build_event( + { + "event": "session_created", + "name": session.name, + "window_total": len(self.session_config["windows"]), + "session_pane_total": sum( + len(w.get("panes", [])) for w in self.session_config["windows"] + ), + } + ) + _log = TmuxpLoggerAdapter( + logger, + {"tmux_session": self.session_config["session_name"]}, + ) + _log.info("session created") + + assert session.server is not None + + self.server: Server = session.server + assert self.server.sessions is not None + assert self.server.has_session(session.name) + assert session.id + + assert isinstance(session, Session) + + for plugin in self.plugins: + plugin.before_workspace_builder(self.session) + + focus = None + + if "before_script" in self.session_config: + if self.on_before_script: + self.on_before_script() + if self.on_build_event: + self.on_build_event({"event": "before_script_started"}) + try: + cwd = None + + # we want to run the before_script file cwd'd from the + # session start directory, if it exists. + if "start_directory" in self.session_config: + cwd = self.session_config["start_directory"] + _log.debug( + "running before script", + ) + run_before_script( + self.session_config["before_script"], + cwd=cwd, + on_line=self.on_script_output, + ) + except Exception: + _log.error( + "before script failed", + extra={ + "tmux_config_path": str( + self.session_config["before_script"], + ), + }, + ) + self.session.kill() + raise + finally: + if self.on_build_event: + self.on_build_event({"event": "before_script_done"}) + + if "options" in self.session_config: + for option, value in self.session_config["options"].items(): + self.session.set_option(option, value) + + if "global_options" in self.session_config: + for option, value in self.session_config["global_options"].items(): + self.session.set_option(option, value, global_=True) + + if "environment" in self.session_config: + for option, value in self.session_config["environment"].items(): + self.session.set_environment(option, value) + + # Resolve the pane-readiness decision once, now that the session exists + # and its options (including `default-shell`) have been applied above. + # AUTO waits only for zsh (the shell the prompt-redraw wait was added + # for); ALWAYS/NEVER force the choice. + readiness = self._builder_options.pane_readiness + if readiness is PaneReadiness.ALWAYS: + self._pane_readiness_wait = True + elif readiness is PaneReadiness.NEVER: + self._pane_readiness_wait = False + else: # PaneReadiness.AUTO + self._pane_readiness_wait = shell_is_zsh( + resolve_session_shell(self.session), + ) + _log.debug( + "pane readiness resolved", + extra={ + "tmux_pane_readiness": readiness.value, + "tmux_pane_readiness_wait": self._pane_readiness_wait, + }, + ) + + for window, window_config in self.iter_create_windows(session, append): + assert isinstance(window, Window) + + for plugin in self.plugins: + plugin.on_window_create(window) + + focus_pane = None + for pane, pane_config in self.iter_create_panes(window, window_config): + assert isinstance(pane, Pane) + pane = pane + + if pane_config.get("focus"): + focus_pane = pane + + if window_config.get("focus"): + focus = window + + self.config_after_window(window, window_config) + + for plugin in self.plugins: + plugin.after_window_finished(window) + + if focus_pane: + focus_pane.select() + + if self.on_build_event: + self.on_build_event({"event": "window_done"}) + + if focus: + focus.select() + + if self.on_progress: + self.on_progress("Workspace built") + _log.info("workspace built") + if self.on_build_event: + self.on_build_event({"event": "workspace_built"}) + + def iter_create_windows( + self, + session: Session, + append: bool = False, + ) -> Iterator[t.Any]: + """Return :class:`libtmux.Window` iterating through session config dict. + + Generator yielding :class:`libtmux.Window` by iterating through + ``session_config['windows']``. + + Applies ``window_options`` to window. + + Parameters + ---------- + session : :class:`libtmux.Session` + session to create windows in + append : bool + append windows in current active session + + Returns + ------- + tuple of (:class:`libtmux.Window`, ``window_config``) + Newly created window, and the section from the tmuxp configuration + that was used to create the window. + """ + for window_iterator, window_config in enumerate( + self.session_config["windows"], + start=1, + ): + window_name = window_config.get("window_name", None) + + if self.on_progress: + self.on_progress(f"Creating window: {window_name or window_iterator}") + if self.on_build_event: + self.on_build_event( + { + "event": "window_started", + "name": window_name or str(window_iterator), + "pane_total": len(window_config["panes"]), + } + ) + + is_first_window_pass = self.first_window_pass( + window_iterator, + session, + append, + ) + + w1 = None + if is_first_window_pass: # if first window, use window 1 + w1 = session.active_window + w1.move_window("99") + + start_directory = window_config.get("start_directory", None) + + # If the first pane specifies a start_directory, use that instead. + panes = window_config["panes"] + if panes and "start_directory" in panes[0]: + start_directory = panes[0]["start_directory"] + + window_shell = window_config.get("window_shell", None) + + # If the first pane specifies a shell, use that instead. + try: + if window_config["panes"][0]["shell"] != "": + window_shell = window_config["panes"][0]["shell"] + except (KeyError, IndexError): + pass + + environment = panes[0].get("environment", window_config.get("environment")) + + window = session.new_window( + window_name=window_name, + start_directory=start_directory, + attach=False, # do not move to the new window + window_index=window_config.get("window_index", ""), + window_shell=window_shell, + environment=environment, + ) + assert isinstance(window, Window) + window_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": session.name or "", + "tmux_window": window_name or "", + }, + ) + window_log.debug("window created") + + if is_first_window_pass: # if first window, use window 1 + session.active_window.kill() + + if "options" in window_config and isinstance( + window_config["options"], + dict, + ): + for key, val in window_config["options"].items(): + window.set_option(key, val) + + if window_config.get("focus"): + window.select() + + yield window, window_config + + def iter_create_panes( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> Iterator[t.Any]: + """Return :class:`libtmux.Pane` iterating through window config dict. + + Run ``shell_command`` with ``$ tmux send-keys``. + + Parameters + ---------- + window : :class:`libtmux.Window` + window to create panes for + window_config : dict + config section for window + + Returns + ------- + tuple of (:class:`libtmux.Pane`, ``pane_config``) + Newly created pane, and the section from the tmuxp configuration + that was used to create the pane. + """ + assert isinstance(window, Window) + + pane_base_index = window.show_option("pane-base-index", global_=True) + assert pane_base_index is not None + + pane = None + + for pane_index, pane_config in enumerate( + window_config["panes"], + start=pane_base_index, + ): + if self.on_progress: + self.on_progress(f"Creating pane: {pane_index}") + if self.on_build_event: + self.on_build_event( + { + "event": "pane_creating", + "pane_num": pane_index - int(pane_base_index) + 1, + "pane_total": len(window_config["panes"]), + } + ) + + if pane_index == int(pane_base_index): + pane = window.active_pane + else: + + def get_pane_start_directory( + pane_config: dict[str, str], + window_config: dict[str, str], + ) -> str | None: + if "start_directory" in pane_config: + return pane_config["start_directory"] + if "start_directory" in window_config: + return window_config["start_directory"] + return None + + def get_pane_shell( + pane_config: dict[str, str], + window_config: dict[str, str], + ) -> str | None: + if "shell" in pane_config: + return pane_config["shell"] + if "window_shell" in window_config: + return window_config["window_shell"] + return None + + environment = pane_config.get( + "environment", + window_config.get("environment"), + ) + + assert pane is not None + + pane = pane.split( + attach=True, + start_directory=get_pane_start_directory( + pane_config=pane_config, + window_config=window_config, + ), + shell=get_pane_shell( + pane_config=pane_config, + window_config=window_config, + ), + environment=environment, + ) + + assert isinstance(pane, Pane) + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) + pane_log.debug("pane created") + + # Skip readiness wait when a custom shell/command launcher is set. + # The shell/window_shell key runs a command (e.g. "top", "sleep 999") + # that replaces the default shell — the pane exits when the command + # exits, so there is no interactive prompt to wait for. The + # pane_readiness policy (resolved in build()) further gates whether + # default-shell panes wait at all. + pane_shell = pane_config.get("shell", window_config.get("window_shell")) + if pane_shell is None and self._pane_readiness_wait: + _wait_for_pane_ready(pane) + + if "layout" in window_config: + window.select_layout(window_config["layout"]) + + if "suppress_history" in pane_config: + suppress = pane_config["suppress_history"] + elif "suppress_history" in window_config: + suppress = window_config["suppress_history"] + else: + suppress = True + + enter = pane_config.get("enter", True) + sleep_before = pane_config.get("sleep_before", None) + sleep_after = pane_config.get("sleep_after", None) + for cmd in pane_config["shell_command"]: + enter = cmd.get("enter", enter) + sleep_before = cmd.get("sleep_before", sleep_before) + sleep_after = cmd.get("sleep_after", sleep_after) + + if sleep_before is not None: + time.sleep(sleep_before) + + pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) + pane_log.debug("sent command %s", cmd["cmd"]) + + if sleep_after is not None: + time.sleep(sleep_after) + + if pane_config.get("focus"): + assert pane.pane_id is not None + window.select_pane(pane.pane_id) + + yield pane, pane_config + + def config_after_window( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> None: + """Actions to apply to window after window and pane finished. + + When building a tmux session, sometimes its easier to postpone things + like setting options until after things are already structurally + prepared. + + Parameters + ---------- + window : :class:`libtmux.Window` + window to create panes for + window_config : dict + config section for window + """ + if "options_after" in window_config and isinstance( + window_config["options_after"], + dict, + ): + for key, val in window_config["options_after"].items(): + window.set_option(key, val) + + def find_current_attached_session(self) -> Session: + """Return current attached session.""" + assert self.server is not None + + current_active_pane = get_current_pane(self.server) + + if current_active_pane is None: + raise exc.ActiveSessionMissingWorkspaceException + + return next( + ( + s + for s in self.server.sessions + if s.session_id == current_active_pane.session_id + ), + ) + + def first_window_pass(self, i: int, session: Session, append: bool) -> bool: + """Return True first window, used when iterating session windows.""" + return len(session.windows) == 1 and i == 1 and not append diff --git a/src/tmuxp/workspace/builder/protocol.py b/src/tmuxp/workspace/builder/protocol.py new file mode 100644 index 0000000000..0557bf7849 --- /dev/null +++ b/src/tmuxp/workspace/builder/protocol.py @@ -0,0 +1,108 @@ +"""Public contract for tmuxp workspace builders. + +A workspace builder turns an expanded workspace ``dict`` into a live tmux +session. :class:`tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` is the +default implementation; third parties may ship their own and select them with +the ``workspace_builder`` config key (see +:mod:`tmuxp.workspace.builder.registry`). + +This module is intentionally dependency-light (typing only) so builder authors +can import the contract without pulling in tmuxp's resolution machinery. + +The contract is synchronous today. Async builders are an *additive* future +extension: a subclass protocol can declare an ``async`` build entry point and a +capability flag, discovered through the same entry-point group, without +changing this sync surface. +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +@t.runtime_checkable +class WorkspaceBuilderProtocol(t.Protocol): + """Contract a builder must satisfy to be driven by ``tmuxp load``. + + A conforming builder is constructed with at least ``session_config`` and + ``server`` (plus the optional ``plugins`` and ``on_*`` callbacks the classic + builder accepts). It exposes :meth:`build`, surfaces the populated tmux + session via :attr:`session`, and honors the plugin/progress integration + points the CLI drives. + + Because the protocol carries data members (``session``, ``plugins``, the + ``on_*`` callbacks), validate *instances* with :func:`isinstance`; + class-level ``issubclass`` checks are not supported for runtime-checkable + protocols with non-method members. + + Examples + -------- + A builder implementing the full contract satisfies the protocol: + + >>> from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol + >>> class MiniBuilder: + ... plugins: list = [] + ... on_progress = None + ... on_before_script = None + ... on_script_output = None + ... on_build_event = None + ... def __init__(self, session_config, server, **kwargs): + ... self._session_config = session_config + ... def build(self, session=None, append=False): + ... ... + ... def session_exists(self, session_name): + ... ... + ... def find_current_attached_session(self): + ... ... + ... @property + ... def session(self): + ... ... + >>> builder = MiniBuilder(session_config={}, server=None) + >>> isinstance(builder, WorkspaceBuilderProtocol) + True + + An object missing the contract does not: + + >>> isinstance(object(), WorkspaceBuilderProtocol) + False + """ + + plugins: list[t.Any] + on_progress: t.Callable[[str], None] | None + on_before_script: t.Callable[[], None] | None + on_script_output: t.Callable[[str], None] | None + on_build_event: t.Callable[[dict[str, t.Any]], None] | None + + def __init__( + self, + session_config: dict[str, t.Any], + server: Server, + plugins: list[t.Any] | None = None, + on_progress: t.Callable[[str], None] | None = None, + on_before_script: t.Callable[[], None] | None = None, + on_script_output: t.Callable[[str], None] | None = None, + on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None, + ) -> None: + """Construct a builder from an expanded workspace and a tmux server.""" + ... + + @property + def session(self) -> Session: + """Return the tmux session the builder created or populated.""" + ... + + def build(self, session: Session | None = None, append: bool = False) -> None: + """Build the workspace, creating or populating a tmux session.""" + ... + + def session_exists(self, session_name: str) -> bool: + """Return ``True`` if a session with ``session_name`` already exists.""" + ... + + def find_current_attached_session(self) -> Session: + """Return the session currently attached within ``$TMUX``.""" + ... diff --git a/src/tmuxp/workspace/builder/registry.py b/src/tmuxp/workspace/builder/registry.py new file mode 100644 index 0000000000..e29a58ec70 --- /dev/null +++ b/src/tmuxp/workspace/builder/registry.py @@ -0,0 +1,326 @@ +"""Resolve and sandbox workspace builders selected by a workspace config. + +A workspace may point tmuxp at a builder other than the classic +:class:`tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` via the +``workspace_builder`` key (a Python dotted path, ``module:attr`` reference, or +an entry-point name in the ``tmuxp.workspace_builders`` group). When the builder +lives outside the active environment, ``workspace_builder_paths`` lists trusted +directories that are temporarily added to ``sys.path`` for the import and build. + +Security note: only literal directories are prepended to ``sys.path``. This +deliberately avoids :func:`site.addsitedir`, which executes ``.pth`` startup +files and is broader than making a module importable. The trust boundary is the +author of the workspace file. +""" + +from __future__ import annotations + +import contextlib +import importlib +import inspect +import os +import pathlib +import sys +import typing as t +from importlib import metadata + +from tmuxp import exc +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder +from tmuxp.workspace.loader import expandshell + +if t.TYPE_CHECKING: + from collections.abc import Iterator + + from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol + +WORKSPACE_BUILDERS_GROUP = "tmuxp.workspace_builders" +"""Entry-point group packaged builders register under.""" + + +def resolve_builder_paths( + session_config: dict[str, t.Any], + workspace_file: str | os.PathLike[str] | None = None, +) -> list[pathlib.Path]: + """Resolve and validate trusted ``workspace_builder_paths`` import roots. + + Each entry is shell-expanded (``~`` and ``$VARS``), resolved relative to the + workspace file's directory when not absolute, and required to be an existing + directory. Returns ``[]`` when the key is absent, so existing workspaces add + nothing to ``sys.path``. + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + workspace_file : str or os.PathLike, optional + path to the workspace file; its parent anchors relative entries + + Returns + ------- + list of pathlib.Path + + Examples + -------- + Absent key resolves to an empty list (no-op for existing workspaces): + + >>> resolve_builder_paths({}, None) + [] + + An existing directory is resolved: + + >>> root = tmp_path / "roots" + >>> root.mkdir() + >>> resolve_builder_paths({"workspace_builder_paths": [str(root)]}, None) == [ + ... root.resolve() + ... ] + True + + A missing directory is rejected: + + >>> resolve_builder_paths( + ... {"workspace_builder_paths": [str(tmp_path / "missing")]}, None + ... ) + Traceback (most recent call last): + ... + tmuxp.exc.WorkspaceBuilderPathError: ... + """ + raw = session_config.get("workspace_builder_paths") or [] + if isinstance(raw, (str, os.PathLike)): + raw = [raw] + + base = ( + pathlib.Path(workspace_file).parent + if workspace_file is not None + else pathlib.Path.cwd() + ) + + resolved: list[pathlib.Path] = [] + for entry in raw: + if not isinstance(entry, (str, os.PathLike)): + raise exc.WorkspaceBuilderPathError( + str(entry), + reason="entries must be path strings", + ) + candidate = pathlib.Path(expandshell(str(entry))) + if not candidate.is_absolute(): + candidate = base / candidate + candidate = candidate.resolve() + if not candidate.is_dir(): + raise exc.WorkspaceBuilderPathError(str(PrivatePath(candidate))) + resolved.append(candidate) + return resolved + + +@contextlib.contextmanager +def prepended_sys_path( + paths: list[pathlib.Path] | None, +) -> Iterator[None]: + """Temporarily prepend directories to ``sys.path``, restoring it on exit. + + Paths are prepended in order (first entry ends up at ``sys.path[0]``). An + empty or falsy ``paths`` is a no-op. Uses only literal directory entries; it + avoids :func:`site.addsitedir` (which runs ``.pth`` startup code). + + Parameters + ---------- + paths : list of pathlib.Path or None + directories to prepend + + Examples + -------- + >>> import sys + >>> ext = tmp_path / "ext" + >>> ext.mkdir() + >>> before = list(sys.path) + >>> with prepended_sys_path([ext]): + ... sys.path[0] == str(ext) + True + >>> sys.path == before + True + """ + if not paths: + yield + return + saved = list(sys.path) + for path in reversed(paths): + sys.path.insert(0, str(path)) + try: + yield + finally: + sys.path[:] = saved + + +def available_builders() -> list[str]: + """Return the names of builders registered via entry points. + + Examples + -------- + >>> isinstance(available_builders(), list) + True + """ + return [ep.name for ep in metadata.entry_points(group=WORKSPACE_BUILDERS_GROUP)] + + +def _load_entry_point(name: str) -> t.Any | None: + """Load a builder registered under entry-point ``name``, else ``None``. + + Examples + -------- + >>> _load_entry_point("definitely-not-a-registered-builder") is None + True + """ + for ep in metadata.entry_points(group=WORKSPACE_BUILDERS_GROUP): + if ep.name == name: + try: + return ep.load() + except (ImportError, AttributeError) as e: + raise exc.WorkspaceBuilderImportError(name, reason=str(e)) from e + return None + + +def _import_target(target: str) -> t.Any: + """Import an object from a ``module:attr`` or dotted ``module.attr`` path. + + Examples + -------- + >>> _import_target( + ... "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + ... ).__name__ + 'ClassicWorkspaceBuilder' + + The historical ``tmuxp.workspace.builder:WorkspaceBuilder`` alias resolves + to the same class: + + >>> _import_target("tmuxp.workspace.builder:WorkspaceBuilder").__name__ + 'ClassicWorkspaceBuilder' + """ + if ":" in target: + module_name, _, attr = target.partition(":") + else: + module_name, _, attr = target.rpartition(".") + if not module_name or not attr: + raise exc.WorkspaceBuilderNotFound(target) + try: + module = importlib.import_module(module_name) + except ImportError as e: + raise exc.WorkspaceBuilderImportError(target, reason=str(e)) from e + obj: t.Any = module + try: + for part in attr.split("."): + obj = getattr(obj, part) + except AttributeError as e: + raise exc.WorkspaceBuilderImportError(target, reason=str(e)) from e + return obj + + +def _validate_builder(obj: t.Any, target: str) -> None: + """Validate that ``obj`` is a usable workspace builder. + + A class must expose a callable ``build`` method and a constructor accepting + ``session_config``, ``server``, and ``plugins`` (or ``**kwargs``) — the + arguments ``tmuxp load`` always passes. Non-class callables (factories) are + trusted and validated at instantiation. + + Examples + -------- + >>> from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + >>> _validate_builder(ClassicWorkspaceBuilder, "classic") + + >>> _validate_builder(object, "object") + Traceback (most recent call last): + ... + tmuxp.exc.InvalidWorkspaceBuilder: 'object' is not a valid workspace builder: ... + """ + if inspect.isclass(obj): + if not callable(getattr(obj, "build", None)): + raise exc.InvalidWorkspaceBuilder( + target, + reason="class has no callable 'build' method", + ) + try: + params = inspect.signature(obj).parameters + except (TypeError, ValueError): + return + has_var_kw = any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values() + ) + missing = {"session_config", "server", "plugins"} - set(params) + if not has_var_kw and missing: + joined = ", ".join(sorted(missing)) + raise exc.InvalidWorkspaceBuilder( + target, + reason=f"constructor missing parameter(s): {joined}", + ) + elif not callable(obj): + raise exc.InvalidWorkspaceBuilder(target, reason="not a class or callable") + + +def resolve_builder_class( + session_config: dict[str, t.Any], +) -> type[WorkspaceBuilderProtocol]: + """Resolve the workspace builder class selected by ``session_config``. + + Resolution of the ``workspace_builder`` value: + + 1. absent/empty → the classic + :class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` (no + import, no entry-point scan); + 2. contains ``:`` → a ``module:attr`` object reference; + 3. no ``.`` and no ``:`` → an entry-point name in the + ``tmuxp.workspace_builders`` group; + 4. dotted, no ``:`` → an entry-point name if registered, otherwise a + ``module.attr`` dotted path. + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + + Returns + ------- + type + a workspace builder class (or builder factory) satisfying + :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + + Examples + -------- + The default resolves to the classic builder without importing anything: + + >>> from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + >>> resolve_builder_class({}) is ClassicWorkspaceBuilder + True + + A dotted ``module:attr`` reference is imported and validated: + + >>> resolve_builder_class( + ... { + ... "workspace_builder": ( + ... "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + ... ) + ... } + ... ) is ClassicWorkspaceBuilder + True + """ + target = session_config.get("workspace_builder") + if not target: + return ClassicWorkspaceBuilder + target_str = str(target).strip() + + obj: t.Any + if ":" in target_str: + obj = _import_target(target_str) + elif "." not in target_str: + obj = _load_entry_point(target_str) + if obj is None: + raise exc.WorkspaceBuilderNotFound( + target_str, + available=available_builders(), + ) + else: + obj = _load_entry_point(target_str) + if obj is None: + obj = _import_target(target_str) + + _validate_builder(obj, target_str) + return t.cast("type[WorkspaceBuilderProtocol]", obj) diff --git a/src/tmuxp/workspace/constants.py b/src/tmuxp/workspace/constants.py new file mode 100644 index 0000000000..4a39082b6d --- /dev/null +++ b/src/tmuxp/workspace/constants.py @@ -0,0 +1,9 @@ +"""Constant variables for tmuxp workspace functionality.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +VALID_WORKSPACE_DIR_FILE_EXTENSIONS = [".yaml", ".yml", ".json"] diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py new file mode 100644 index 0000000000..2bc7704c28 --- /dev/null +++ b/src/tmuxp/workspace/finders.py @@ -0,0 +1,424 @@ +"""Workspace (configuration file) finders for tmuxp.""" + +from __future__ import annotations + +import logging +import os +import pathlib +import typing as t + +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: + from typing import TypeAlias + + from tmuxp.types import StrPath + + ValidExtensions: TypeAlias = t.Literal[".yml", ".yaml", ".json"] + + +def is_workspace_file( + filename: str, + extensions: ValidExtensions | list[ValidExtensions] | None = None, +) -> bool: + """ + Return True if file has a valid workspace file type. + + Parameters + ---------- + filename : str + filename to check (e.g. ``mysession.json``). + extensions : str or list + filetypes to check (e.g. ``['.yaml', '.json']``). + + Returns + ------- + bool + """ + if extensions is None: + extensions = [".yml", ".yaml", ".json"] + extensions = [extensions] if isinstance(extensions, str) else extensions + return any(filename.endswith(e) for e in extensions) + + +def in_dir( + workspace_dir: pathlib.Path | str | None = None, + extensions: list[ValidExtensions] | None = None, +) -> list[str]: + """ + Return a list of workspace_files in ``workspace_dir``. + + Parameters + ---------- + workspace_dir : str + directory to search + extensions : list + filetypes to check (e.g. ``['.yaml', '.json']``). + + Returns + ------- + list + """ + if workspace_dir is None: + workspace_dir = os.path.expanduser("~/.tmuxp") + + if extensions is None: + extensions = [".yml", ".yaml", ".json"] + + return [ + filename + for filename in os.listdir(workspace_dir) + if is_workspace_file(filename, extensions) and not filename.startswith(".") + ] + + +def in_cwd() -> list[str]: + """ + Return list of workspace_files in current working directory. + + If filename is ``.tmuxp.py``, ``.tmuxp.json``, ``.tmuxp.yaml``. + + Returns + ------- + list + workspace_files in current working directory + + Examples + -------- + >>> sorted(in_cwd()) + ['.tmuxp.json', '.tmuxp.yaml'] + """ + return [ + filename + for filename in os.listdir(os.getcwd()) + if filename.startswith(".tmuxp") and is_workspace_file(filename) + ] + + +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() + + logger.debug( + "searching for local workspace files from %s", + start_dir, + extra={"tmux_config_path": str(start_dir)}, + ) + + current = pathlib.Path(start_dir).resolve() + home = pathlib.Path.home().resolve() + found: list[pathlib.Path] = [] + + 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. + + ``TMUXP_CONFIGDIR`` environmental variable has precedence if set. We also + evaluate XDG default directory from XDG_CONFIG_HOME environmental variable + if set or its default. Then the old default ~/.tmuxp is returned for + compatibility. + + Returns + ------- + str : + absolute path to tmuxp config directory + """ + paths = [] + if "TMUXP_CONFIGDIR" in os.environ: + paths.append(os.environ["TMUXP_CONFIGDIR"]) + if "XDG_CONFIG_HOME" in os.environ: + paths.append(os.path.join(os.environ["XDG_CONFIG_HOME"], "tmuxp")) + else: + paths.append("~/.config/tmuxp/") + paths.append("~/.tmuxp") + + for path in paths: + path = os.path.expanduser(path) + if os.path.isdir(path): + return path + # Return last path as default if none of the previous ones matched + 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, +) -> str: + """ + Return the real config path or raise an exception. + + If workspace file is directory, scan for .tmuxp.{yaml,yml,json} in directory. If + one or more found, it will warn and pick the first. + + If workspace file is ".", "./" or None, it will scan current directory. + + If workspace file is has no path and only a filename, e.g. "my_workspace.yaml" it + will search workspace dir. + + If workspace file has no path and no extension, e.g. "my_workspace", it will scan + for file name with yaml, yml and json. If multiple exist, it will warn and pick the + first. + + Parameters + ---------- + workspace_file : str + Workspace file, valid examples: + + - a file name, my_workspace.yaml + - relative path, ../my_workspace.yaml or ../project + - a period, . + + Returns + ------- + str + Resolved absolute path to workspace file. + + Raises + ------ + FileNotFoundError + If workspace file cannot be found. + """ + if not workspace_dir: + workspace_dir = get_workspace_dir() + path = os.path + exists, join, isabs = path.exists, path.join, path.isabs + dirname, normpath, splitext = path.dirname, path.normpath, path.splitext + cwd = os.getcwd() + is_name = False + file_error = None + + workspace_file = os.path.expanduser(workspace_file) + # if purename, resolve to confg dir + if is_pure_name(workspace_file): + is_name = True + elif ( + not isabs(workspace_file) + or len(dirname(workspace_file)) > 1 + or workspace_file in {".", "", "./"} + ): # if relative, fill in full path + workspace_file = normpath(join(cwd, workspace_file)) + + # no extension, scan + if path.isdir(workspace_file) or not splitext(workspace_file)[1]: + if is_name: + candidates = [ + x + for x in [ + f"{join(workspace_dir, workspace_file)}{ext}" + for ext in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ] + if exists(x) + ] + if not candidates: + file_error = ( + "workspace-file not found " + f"in workspace dir (yaml/yml/json) {workspace_dir} for name" + ) + else: + candidates = [ + x + for x in [ + join(workspace_file, ext) + for ext in [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"] + ] + if exists(x) + ] + + if len(candidates) > 1: + logger.warning( + "multiple workspace files found, use distinct file names" + " to avoid ambiguity", + extra={"tmux_config_path": workspace_file}, + ) + colors = Colors(ColorMode.AUTO) + tmuxp_echo( + colors.error( + "Multiple .tmuxp.{yaml,yml,json} files found in " + + str(workspace_file) + ) + ) + tmuxp_echo( + "This is undefined behavior, use only one. " + "Use file names e.g. myproject.json, coolproject.yaml. " + "You can load them by filename.", + ) + elif not candidates: + file_error = "No tmuxp files found in directory" + if candidates: + workspace_file = candidates[0] + elif not exists(workspace_file): + file_error = "file not found" + + if file_error: + raise FileNotFoundError(file_error, workspace_file) + + logger.debug( + "resolved workspace file %s", + workspace_file, + extra={"tmux_config_path": workspace_file}, + ) + return workspace_file + + +def is_pure_name(path: str) -> bool: + """ + Return True if path is a name and not a file path. + + Parameters + ---------- + path : str + Path (can be absolute, relative, etc.) + + Returns + ------- + bool + True if path is a name of workspace in workspace dir, not file path. + """ + return ( + not os.path.isabs(path) + and len(os.path.dirname(path)) == 0 + and not os.path.splitext(path)[1] + and path not in {".", ""} + ) diff --git a/src/tmuxp/workspace/freezer.py b/src/tmuxp/workspace/freezer.py new file mode 100644 index 0000000000..a1df02c1dc --- /dev/null +++ b/src/tmuxp/workspace/freezer.py @@ -0,0 +1,137 @@ +"""Tmux session freezing functionality for tmuxp.""" + +from __future__ import annotations + +import logging +import typing as t + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.session import Session + from libtmux.window import Window + + +def inline(workspace_dict: dict[str, t.Any]) -> t.Any: + """Return workspace with inlined shorthands. + + Opposite of :func:`~tmuxp.workspace.loader.expand`. + + Parameters + ---------- + workspace_dict : dict + + Returns + ------- + dict + workspace with shorthands inlined. + """ + if ( + "shell_command" in workspace_dict + and isinstance(workspace_dict["shell_command"], list) + and len(workspace_dict["shell_command"]) == 1 + ): + workspace_dict["shell_command"] = workspace_dict["shell_command"][0] + + if len(workspace_dict.keys()) == 1: + return workspace_dict["shell_command"] + if ( + "shell_command_before" in workspace_dict + and isinstance(workspace_dict["shell_command_before"], list) + and len(workspace_dict["shell_command_before"]) == 1 + ): + workspace_dict["shell_command_before"] = workspace_dict["shell_command_before"][ + 0 + ] + + # recurse into window and pane workspace items + if "windows" in workspace_dict: + workspace_dict["windows"] = [ + inline(window) for window in workspace_dict["windows"] + ] + if "panes" in workspace_dict: + workspace_dict["panes"] = [inline(pane) for pane in workspace_dict["panes"]] + + return workspace_dict + + +def freeze(session: Session) -> dict[str, t.Any]: + """Freeze live tmux session into a tmuxp workspace. + + Parameters + ---------- + session : :class:`libtmux.Session` + session object + + Returns + ------- + dict + tmuxp compatible workspace + """ + logger.debug("freezing session", extra={"tmux_session": session.session_name}) + + session_config: dict[str, t.Any] = { + "session_name": session.session_name, + "windows": [], + } + + for window in session.windows: + window_config: dict[str, t.Any] = { + "options": window.show_options(), + "window_name": window.name, + "layout": window.window_layout, + "panes": [], + } + + if getattr(window, "window_active", "0") == "1": + window_config["focus"] = "true" + + # If all panes have same path, set 'start_directory' instead + # of using 'cd' shell commands. + def pane_has_same_path(window: Window, pane: Pane) -> bool: + return window.panes[0].pane_current_path == pane.pane_current_path + + if all(pane_has_same_path(window=window, pane=pane) for pane in window.panes): + window_config["start_directory"] = window.panes[0].pane_current_path + + for pane in window.panes: + pane_config: str | dict[str, t.Any] = {"shell_command": []} + assert isinstance(pane_config, dict) + + if "start_directory" not in window_config and pane.pane_current_path: + pane_config["shell_command"].append("cd " + pane.pane_current_path) + + if getattr(pane, "pane_active", "0") == "1": + pane_config["focus"] = "true" + + current_cmd = pane.pane_current_command + + def filter_interpreters_and_shells(current_cmd: str | None) -> bool: + return current_cmd is not None and ( + current_cmd.startswith("-") + or any( + current_cmd.endswith(cmd) for cmd in ["python", "ruby", "node"] + ) + ) + + if filter_interpreters_and_shells(current_cmd=current_cmd): + current_cmd = None + + if current_cmd: + pane_config["shell_command"].append(current_cmd) + elif not len(pane_config["shell_command"]): + pane_config = "pane" + + window_config["panes"].append(pane_config) + + session_config["windows"].append(window_config) + logger.debug( + "frozen window", + extra={ + "tmux_session": session.session_name, + "tmux_window": window.name, + }, + ) + + return session_config diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py new file mode 100644 index 0000000000..3505981ec4 --- /dev/null +++ b/src/tmuxp/workspace/importers.py @@ -0,0 +1,187 @@ +"""Configuration import adapters to load teamocil, tmuxinator, etc. in tmuxp.""" + +from __future__ import annotations + +import logging +import typing as t + +logger = logging.getLogger(__name__) + + +def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Return tmuxp workspace from a `tmuxinator`__ YAML workspace. + + __ https://github.com/aziz/tmuxinator + + Parameters + ---------- + workspace_dict : dict + python dict for tmuxp workspace. + + Returns + ------- + dict + """ + logger.debug( + "importing tmuxinator workspace", + extra={ + "tmux_session": workspace_dict.get("project_name") + or workspace_dict.get("name", ""), + }, + ) + + tmuxp_workspace: dict[str, t.Any] = {} + + if "project_name" in workspace_dict: + tmuxp_workspace["session_name"] = workspace_dict.pop("project_name") + elif "name" in workspace_dict: + tmuxp_workspace["session_name"] = workspace_dict.pop("name") + else: + tmuxp_workspace["session_name"] = None + + if "project_root" in workspace_dict: + tmuxp_workspace["start_directory"] = workspace_dict.pop("project_root") + elif "root" in workspace_dict: + tmuxp_workspace["start_directory"] = workspace_dict.pop("root") + + if "cli_args" in workspace_dict: + tmuxp_workspace["config"] = workspace_dict["cli_args"] + + if "-f" in tmuxp_workspace["config"]: + tmuxp_workspace["config"] = ( + tmuxp_workspace["config"].replace("-f", "").strip() + ) + elif "tmux_options" in workspace_dict: + tmuxp_workspace["config"] = workspace_dict["tmux_options"] + + if "-f" in tmuxp_workspace["config"]: + tmuxp_workspace["config"] = ( + tmuxp_workspace["config"].replace("-f", "").strip() + ) + + if "socket_name" in workspace_dict: + tmuxp_workspace["socket_name"] = workspace_dict["socket_name"] + + tmuxp_workspace["windows"] = [] + + if "tabs" in workspace_dict: + workspace_dict["windows"] = workspace_dict.pop("tabs") + + if "pre" in workspace_dict and "pre_window" in workspace_dict: + tmuxp_workspace["shell_command"] = workspace_dict["pre"] + + if isinstance(workspace_dict["pre"], str): + tmuxp_workspace["shell_command_before"] = [workspace_dict["pre_window"]] + else: + tmuxp_workspace["shell_command_before"] = workspace_dict["pre_window"] + elif "pre" in workspace_dict: + if isinstance(workspace_dict["pre"], str): + tmuxp_workspace["shell_command_before"] = [workspace_dict["pre"]] + else: + tmuxp_workspace["shell_command_before"] = workspace_dict["pre"] + + if "rbenv" in workspace_dict: + if "shell_command_before" not in tmuxp_workspace: + tmuxp_workspace["shell_command_before"] = [] + tmuxp_workspace["shell_command_before"].append( + "rbenv shell {}".format(workspace_dict["rbenv"]), + ) + + for window_dict in workspace_dict["windows"]: + for k, v in window_dict.items(): + window_dict = {"window_name": k} + + if isinstance(v, str) or v is None: + window_dict["panes"] = [v] + tmuxp_workspace["windows"].append(window_dict) + continue + if isinstance(v, list): + window_dict["panes"] = v + tmuxp_workspace["windows"].append(window_dict) + continue + + if "pre" in v: + window_dict["shell_command_before"] = v["pre"] + if "panes" in v: + window_dict["panes"] = v["panes"] + if "root" in v: + window_dict["start_directory"] = v["root"] + + if "layout" in v: + window_dict["layout"] = v["layout"] + tmuxp_workspace["windows"].append(window_dict) + return tmuxp_workspace + + +def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Return tmuxp workspace from a `teamocil`__ YAML workspace. + + __ https://github.com/remiprev/teamocil + + Parameters + ---------- + workspace_dict : dict + python dict for tmuxp workspace + + Notes + ----- + Todos: + + - change 'root' to a cd or start_directory + - width in pane -> main-pain-width + - with_env_var + - clear + - cmd_separator + """ + _inner = workspace_dict.get("session", workspace_dict) + logger.debug( + "importing teamocil workspace", + extra={"tmux_session": _inner.get("name", "")}, + ) + + tmuxp_workspace: dict[str, t.Any] = {} + + if "session" in workspace_dict: + workspace_dict = workspace_dict["session"] + + tmuxp_workspace["session_name"] = workspace_dict.get("name", None) + + if "root" in workspace_dict: + tmuxp_workspace["start_directory"] = workspace_dict.pop("root") + + tmuxp_workspace["windows"] = [] + + for w in workspace_dict["windows"]: + window_dict = {"window_name": w["name"]} + + if "clear" in w: + window_dict["clear"] = w["clear"] + + if "filters" in w: + if "before" in w["filters"]: + for _b in w["filters"]["before"]: + window_dict["shell_command_before"] = w["filters"]["before"] + if "after" in w["filters"]: + for _b in w["filters"]["after"]: + window_dict["shell_command_after"] = w["filters"]["after"] + + if "root" in w: + window_dict["start_directory"] = w.pop("root") + + if "splits" in w: + w["panes"] = w.pop("splits") + + if "panes" in w: + for p in w["panes"]: + if "cmd" in p: + p["shell_command"] = p.pop("cmd") + if "width" in p: + # TODO support for height/width + p.pop("width") + window_dict["panes"] = w["panes"] + + if "layout" in w: + window_dict["layout"] = w["layout"] + tmuxp_workspace["windows"].append(window_dict) + + return tmuxp_workspace diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py new file mode 100644 index 0000000000..b022a4da21 --- /dev/null +++ b/src/tmuxp/workspace/loader.py @@ -0,0 +1,275 @@ +"""Workspace hydration and loading for tmuxp.""" + +from __future__ import annotations + +import logging +import os +import pathlib +import typing as t + +logger = logging.getLogger(__name__) + + +def expandshell(value: str) -> str: + """Resolve shell variables based on user's ``$HOME`` and ``env``. + + :py:func:`os.path.expanduser` and :py:func:`os.path.expandvars`. + + Parameters + ---------- + value : str + value to expand + + Returns + ------- + str + value with shell variables expanded + """ + return os.path.expandvars(os.path.expanduser(value)) # NOQA: PTH111 + + +def expand_cmd(p: dict[str, t.Any]) -> dict[str, t.Any]: + """Resolve shell variables and expand shorthands in a tmuxp config mapping.""" + if isinstance(p, str): + p = {"shell_command": [p]} + elif isinstance(p, list): + p = {"shell_command": p} + elif not p: + p = {"shell_command": []} + + assert isinstance(p, dict) + if "shell_command" in p: + cmds = p["shell_command"] + + if isinstance(p["shell_command"], str): + cmds = [cmds] + + if not cmds or any(a == cmds for a in [None, "blank", "pane"]): + cmds = [] + + if ( + isinstance(cmds, list) + and len(cmds) == 1 + and any(a in cmds for a in [None, "blank", "pane"]) + ): + cmds = [] + + for cmd_idx, cmd in enumerate(cmds): + if isinstance(cmd, str): + cmds[cmd_idx] = {"cmd": cmd} + cmds[cmd_idx]["cmd"] = expandshell(cmds[cmd_idx]["cmd"]) + + p["shell_command"] = cmds + else: + p["shell_command"] = [] + return p + + +def expand( + workspace_dict: dict[str, t.Any], + cwd: pathlib.Path | str | None = None, + parent: t.Any | None = None, +) -> dict[str, t.Any]: + """Resolve workspace variables and expand shorthand style / inline properties. + + This is necessary to keep the code in + :class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` clean and + also allow for neat, short-hand "sugarified" syntax. + + As a simple example, internally, tmuxp expects that workspace options + like ``shell_command`` are a list (array):: + + 'shell_command': ['htop'] + + tmuxp workspace allow for it to be simply a string:: + + 'shell_command': 'htop' + + ConfigReader will load JSON/YAML files into python dicts for you. + + Parameters + ---------- + workspace_dict : dict + the tmuxp workspace for the session + cwd : str + directory to expand relative paths against. should be the dir of the + workspace directory. + parent : str + (used on recursive entries) start_directory of parent window or session + object. + + Returns + ------- + dict + """ + logger.debug( + "expanding workspace config", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + + # Note: cli.py will expand workspaces relative to project's workspace directory + # for the first cwd argument. + cwd = pathlib.Path().cwd() if not cwd else pathlib.Path(cwd) + + if "session_name" in workspace_dict: + workspace_dict["session_name"] = expandshell(workspace_dict["session_name"]) + if "window_name" in workspace_dict: + workspace_dict["window_name"] = expandshell(workspace_dict["window_name"]) + if "environment" in workspace_dict: + for key in workspace_dict["environment"]: + val = workspace_dict["environment"][key] + val = expandshell(val) + if any(val.startswith(a) for a in [".", "./"]): + val = str(cwd / val) + workspace_dict["environment"][key] = val + if "global_options" in workspace_dict: + for key in workspace_dict["global_options"]: + val = workspace_dict["global_options"][key] + if isinstance(val, str): + val = expandshell(val) + if any(val.startswith(a) for a in [".", "./"]): + val = str(cwd / val) + workspace_dict["global_options"][key] = val + if "options" in workspace_dict: + for key in workspace_dict["options"]: + val = workspace_dict["options"][key] + if isinstance(val, str): + val = expandshell(val) + if any(val.startswith(a) for a in [".", "./"]): + val = str(cwd / val) + workspace_dict["options"][key] = val + + # Any workspace section, session, window, pane that can contain the + # 'shell_command' value + if "start_directory" in workspace_dict: + workspace_dict["start_directory"] = expandshell( + workspace_dict["start_directory"], + ) + start_path = workspace_dict["start_directory"] + if any(start_path.startswith(a) for a in [".", "./"]): + # if window has a session, or pane has a window with a + # start_directory of . or ./, make sure the start_directory can be + # relative to the parent. + # + # This is for the case where you may be loading a workspace from + # outside your shell current directory. + if parent: + cwd = pathlib.Path(parent["start_directory"]) + + start_path = str((cwd / start_path).resolve(strict=False)) + + workspace_dict["start_directory"] = start_path + + if "before_script" in workspace_dict: + workspace_dict["before_script"] = expandshell(workspace_dict["before_script"]) + if any(workspace_dict["before_script"].startswith(a) for a in [".", "./"]): + workspace_dict["before_script"] = str(cwd / workspace_dict["before_script"]) + + if "shell_command" in workspace_dict and isinstance( + workspace_dict["shell_command"], + str, + ): + workspace_dict["shell_command"] = [workspace_dict["shell_command"]] + + if "shell_command_before" in workspace_dict: + shell_command_before = workspace_dict["shell_command_before"] + + workspace_dict["shell_command_before"] = expand_cmd(shell_command_before) + + # recurse into window and pane workspace items + if "windows" in workspace_dict: + workspace_dict["windows"] = [ + expand(window, parent=workspace_dict) + for window in workspace_dict["windows"] + ] + elif "panes" in workspace_dict: + pane_dicts = workspace_dict["panes"] + for pane_idx, pane_dict in enumerate(pane_dicts): + pane_dicts[pane_idx] = {} + pane_dicts[pane_idx].update(expand_cmd(pane_dict)) + workspace_dict["panes"] = [ + expand(pane, parent=workspace_dict) for pane in pane_dicts + ] + + return workspace_dict + + +def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Return a dict with "trickled down" / inherited workspace values. + + This will only work if workspace has been expanded to full form with + :func:`~tmuxp.workspace.loader.expand`. + + tmuxp allows certain commands to be default at the session, window + level. shell_command_before trickles down and prepends the + ``shell_command`` for the pane. + + Parameters + ---------- + workspace_dict : dict + the tmuxp workspace. + + Returns + ------- + dict + """ + logger.debug( + "trickling down workspace defaults", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + + # prepends a pane's ``shell_command`` list with the window and sessions' + # ``shell_command_before``. + + session_start_directory = workspace_dict.get("start_directory") + + suppress_history = workspace_dict.get("suppress_history") + + for window_dict in workspace_dict["windows"]: + # Prepend start_directory to relative window commands + if session_start_directory: + session_start_directory = session_start_directory + if "start_directory" not in window_dict: + window_dict["start_directory"] = session_start_directory + elif not any( + window_dict["start_directory"].startswith(a) for a in ["~", "/"] + ): + window_start_path = ( + pathlib.Path(session_start_directory) + / window_dict["start_directory"] + ) + window_dict["start_directory"] = str(window_start_path) + + # We only need to trickle to the window, workspace builder checks wconf + if suppress_history is not None and "suppress_history" not in window_dict: + window_dict["suppress_history"] = suppress_history + + # If panes were NOT specified for a window, assume that a single pane + # with no shell commands is desired + if "panes" not in window_dict: + window_dict["panes"] = [{"shell_command": []}] + + for pane_idx, pane_dict in enumerate(window_dict["panes"]): + commands_before = [] + + # Prepend shell_command_before to commands + if "shell_command_before" in workspace_dict: + commands_before.extend( + workspace_dict["shell_command_before"]["shell_command"], + ) + if "shell_command_before" in window_dict: + commands_before.extend( + window_dict["shell_command_before"]["shell_command"], + ) + if "shell_command_before" in pane_dict: + commands_before.extend( + pane_dict["shell_command_before"]["shell_command"], + ) + + if "shell_command" in pane_dict: + commands_before.extend(pane_dict["shell_command"]) + + window_dict["panes"][pane_idx]["shell_command"] = commands_before + # pane_dict['shell_command'] = commands_before + + return workspace_dict diff --git a/src/tmuxp/workspace/options.py b/src/tmuxp/workspace/options.py new file mode 100644 index 0000000000..37fa90244f --- /dev/null +++ b/src/tmuxp/workspace/options.py @@ -0,0 +1,231 @@ +"""Behavior options for tmuxp workspace builders. + +The ``workspace_builder_options`` config catalog holds settings that tune how a +workspace builder runs, independent of *which* builder is selected. It is a +sibling to the tmux ``options`` / ``global_options`` / ``environment`` catalogs +and is the home for builder-behavior knobs (today: pane readiness; later: +parallel/async builder settings). + +Example +------- +.. code-block:: yaml + + workspace_builder_options: + pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) +""" + +from __future__ import annotations + +import dataclasses +import enum +import os +import typing as t + +from tmuxp import exc + +_AUTO_ALIASES = frozenset({"auto"}) +_ALWAYS_ALIASES = frozenset({"always", "true", "on", "yes", "1"}) +_NEVER_ALIASES = frozenset({"never", "false", "off", "no", "0"}) + + +class PaneReadiness(enum.Enum): + """Policy for whether the builder waits for a pane's shell prompt. + + tmuxp waits for each default-shell pane to draw its prompt before + dispatching layout and commands, which avoids a zsh prompt-redraw artifact + (see :func:`tmuxp.workspace.builder.classic._wait_for_pane_ready`). The wait + is only needed for zsh, so the default + :attr:`~tmuxp.workspace.options.PaneReadiness.AUTO` policy waits only when + the session's interactive shell is zsh. + """ + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + @classmethod + def from_config(cls, value: t.Any) -> PaneReadiness: + """Parse a ``pane_readiness`` config value into a policy. + + Accepts the canonical ``auto`` / ``always`` / ``never`` strings, plus + the truthy/falsy aliases users expect from boolean-like config keys. + + Parameters + ---------- + value : Any + value from ``workspace_builder_options.pane_readiness``; ``None`` + (key absent) resolves to + :attr:`~tmuxp.workspace.options.PaneReadiness.AUTO` + + Returns + ------- + PaneReadiness + + Examples + -------- + >>> PaneReadiness.from_config(None) is PaneReadiness.AUTO + True + >>> PaneReadiness.from_config("auto") is PaneReadiness.AUTO + True + >>> PaneReadiness.from_config(True) is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config("on") is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config(1) is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config(False) is PaneReadiness.NEVER + True + >>> PaneReadiness.from_config("never") is PaneReadiness.NEVER + True + + Unknown values raise an actionable error: + + >>> PaneReadiness.from_config("sometimes") + Traceback (most recent call last): + ... + ValueError: invalid pane_readiness value: 'sometimes'; expected one of: + auto, always/true/on/yes/1, never/false/off/no/0 + """ + if value is None: + return cls.AUTO + if isinstance(value, cls): + return value + if isinstance(value, bool): + return cls.ALWAYS if value else cls.NEVER + normalized = str(value).strip().lower() + if normalized in _AUTO_ALIASES: + return cls.AUTO + if normalized in _ALWAYS_ALIASES: + return cls.ALWAYS + if normalized in _NEVER_ALIASES: + return cls.NEVER + msg = ( + f"invalid pane_readiness value: {value!r}; expected one of: " + "auto, always/true/on/yes/1, never/false/off/no/0" + ) + raise ValueError(msg) + + +@dataclasses.dataclass(frozen=True) +class WorkspaceBuilderOptions: + """Parsed ``workspace_builder_options`` catalog. + + An absent ``workspace_builder_options`` catalog uses the defaults, whose + :attr:`PaneReadiness.AUTO` policy waits for a pane's prompt only when the + session shell is zsh: zsh workspaces build as before, while bash, sh, and + other shells skip the wait. Set ``pane_readiness: always`` to restore the + previous wait-everywhere behavior. + """ + + pane_readiness: PaneReadiness = PaneReadiness.AUTO + """pane-prompt wait policy; defaults to :attr:`PaneReadiness.AUTO`""" + + @classmethod + def from_config(cls, session_config: dict[str, t.Any]) -> WorkspaceBuilderOptions: + """Build options from a full workspace ``session_config`` dict. + + Reads the optional ``workspace_builder_options`` catalog; an absent or + empty catalog yields the defaults (see the class docstring for how the + default :attr:`PaneReadiness.AUTO` policy affects non-zsh shells). + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + + Returns + ------- + WorkspaceBuilderOptions + + Examples + -------- + >>> WorkspaceBuilderOptions.from_config({}).pane_readiness + + + >>> cfg = {"workspace_builder_options": {"pane_readiness": "always"}} + >>> WorkspaceBuilderOptions.from_config(cfg).pane_readiness + + """ + catalog = session_config.get("workspace_builder_options") or {} + if not isinstance(catalog, dict): + msg = f"must be a mapping, got {type(catalog).__name__}" + raise exc.InvalidWorkspaceBuilderOption(msg) + try: + pane_readiness = PaneReadiness.from_config(catalog.get("pane_readiness")) + except ValueError as e: + raise exc.InvalidWorkspaceBuilderOption(str(e)) from e + return cls(pane_readiness=pane_readiness) + + +def shell_is_zsh(shell: str | None) -> bool: + """Return ``True`` when ``shell`` names the zsh shell. + + Parameters + ---------- + shell : str or None + a shell path or name (e.g. ``/usr/bin/zsh``) + + Returns + ------- + bool + + Examples + -------- + >>> shell_is_zsh("/usr/bin/zsh") + True + >>> shell_is_zsh("/bin/bash") + False + >>> shell_is_zsh(None) + False + """ + return "zsh" in (shell or "") + + +def resolve_session_shell( + session: t.Any, + env: t.Mapping[str, str] | None = None, +) -> str: + """Resolve the effective interactive shell for a tmux session. + + Prefers tmux's ``default-shell`` option (which reflects a workspace's + ``options.default-shell`` once applied, otherwise tmux's global default), + and falls back to the ``SHELL`` environment variable. + + Parameters + ---------- + session : :class:`libtmux.Session` + live session exposing ``show_option("default-shell")`` + env : Mapping, optional + environment mapping for the ``SHELL`` fallback; defaults to + :data:`os.environ` + + Returns + ------- + str + resolved shell path/name, or ``""`` when undeterminable + + Examples + -------- + >>> class FakeSession: + ... def __init__(self, shell): + ... self._shell = shell + ... def show_option(self, name, **kwargs): + ... return self._shell + + The tmux ``default-shell`` wins when set: + + >>> resolve_session_shell(FakeSession("/usr/bin/zsh"), env={}) + '/usr/bin/zsh' + + The ``SHELL`` env var is the fallback: + + >>> resolve_session_shell(FakeSession(None), env={"SHELL": "/bin/bash"}) + '/bin/bash' + """ + # include_inherited resolves a globally-set default-shell (e.g. + # ``set -g default-shell`` in tmux.conf) that the session inherits rather + # than overrides; without it tmux returns None and we'd fall back to $SHELL. + default_shell = session.show_option("default-shell", include_inherited=True) + environ = os.environ if env is None else env + env_shell = environ.get("SHELL", "") + return str(default_shell or env_shell or "") diff --git a/src/tmuxp/workspace/validation.py b/src/tmuxp/workspace/validation.py new file mode 100644 index 0000000000..e7fe4da743 --- /dev/null +++ b/src/tmuxp/workspace/validation.py @@ -0,0 +1,99 @@ +"""Validation errors for tmuxp configuration files.""" + +from __future__ import annotations + +import logging +import typing as t + +from tmuxp import exc + +logger = logging.getLogger(__name__) + + +class SchemaValidationError(exc.WorkspaceError): + """Tmuxp configuration validation base error.""" + + +class SessionNameMissingValidationError(SchemaValidationError): + """Tmuxp configuration error for session name missing.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__( + 'workspace requires "session_name"', + *args, + **kwargs, + ) + + +class WindowListMissingValidationError(SchemaValidationError): + """Tmuxp configuration error for window list missing.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__( + 'workspace requires list of "windows"', + *args, + **kwargs, + ) + + +class WindowNameMissingValidationError(SchemaValidationError): + """Tmuxp configuration error for missing window_name.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + return super().__init__( + 'workspace window is missing "window_name"', + *args, + **kwargs, + ) + + +class InvalidPluginsValidationError(SchemaValidationError): + """Tmuxp configuration error for invalid plugins.""" + + def __init__(self, plugins: t.Any, *args: object, **kwargs: object) -> None: + return super().__init__( + '"plugins" only supports list type. ' + f" Received {type(plugins)}, " + f"value: {plugins}", + *args, + **kwargs, + ) + + +def validate_schema(workspace_dict: t.Any) -> bool: + """ + Return True if workspace schema is correct. + + Parameters + ---------- + workspace_dict : dict + tmuxp workspace data + + Returns + ------- + bool + """ + logger.debug( + "validating workspace schema", + extra={ + "tmux_session": workspace_dict.get("session_name", "") + if isinstance(workspace_dict, dict) + else "", + }, + ) + + # verify session_name + if "session_name" not in workspace_dict: + raise SessionNameMissingValidationError + + if "windows" not in workspace_dict: + raise WindowListMissingValidationError + + for window in workspace_dict["windows"]: + if "window_name" not in window: + raise WindowNameMissingValidationError + + if "plugins" in workspace_dict and not isinstance(workspace_dict["plugins"], list): + raise InvalidPluginsValidationError(plugins=workspace_dict.get("plugins")) + + return True diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb2..6940f22868 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for tmuxp.""" 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..800c810bd5 --- /dev/null +++ b/tests/_internal/test_colors.py @@ -0,0 +1,362 @@ +"""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, + build_description, + 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 + + +# build_description tests + + +def test_build_description_named_heading_includes_examples_suffix() -> None: + """Named heading should include 'examples:' suffix for formatter detection.""" + result = build_description("My tool.", [("sync", ["mytool sync repo"])]) + + # Should be "sync examples:" not just "sync:" + assert "sync examples:" in result + # Verify the old format is not present (unless contained in "sync examples:") + lines = result.split("\n") + heading_line = next(line for line in lines if "sync" in line.lower()) + assert heading_line == "sync examples:" + + +def test_build_description_no_heading_uses_examples() -> None: + """Heading=None should produce bare 'examples:' title.""" + result = build_description("My tool.", [(None, ["mytool run"])]) + + assert "examples:" in result + assert result.count("examples:") == 1 # Just one + + +def test_build_description_multiple_named_headings() -> None: + """Multiple named headings should all have 'examples:' suffix.""" + result = build_description( + "My tool.", + [ + ("load", ["mytool load"]), + ("freeze", ["mytool freeze"]), + ("ls", ["mytool ls"]), + ], + ) + + assert "load examples:" in result + assert "freeze examples:" in result + assert "ls examples:" in result + + +def test_build_description_empty_intro() -> None: + """Empty intro should not add blank sections.""" + result = build_description("", [(None, ["cmd"])]) + + assert result.startswith("examples:") + assert "cmd" 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/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000000..95437011db --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1 @@ +"""CLI tests for tmuxp.""" 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_cli.py b/tests/cli/test_cli.py new file mode 100644 index 0000000000..34524946d4 --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,175 @@ +"""CLI tests for tmuxp's core shell functionality.""" + +from __future__ import annotations + +import argparse +import contextlib +import pathlib +import typing as t + +import libtmux +import pytest + +from tests.fixtures import utils as test_utils +from tmuxp import cli +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.cli.import_config import get_teamocil_dir, get_tmuxinator_dir +from tmuxp.cli.load import _reattach, load_plugins +from tmuxp.cli.utils import tmuxp_echo +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder +from tmuxp.workspace.finders import find_workspace_file + +if t.TYPE_CHECKING: + import _pytest.capture + from libtmux.server import Server + + +def test_creates_config_dir_not_exists(tmp_path: pathlib.Path) -> None: + """cli.startup() creates config dir if not exists.""" + cli.startup(tmp_path) + assert tmp_path.exists() + + +class HelpTestFixture(t.NamedTuple): + """Test fixture for help command tests.""" + + test_id: str + cli_args: list[str] + + +HELP_TEST_FIXTURES: list[HelpTestFixture] = [ + HelpTestFixture( + test_id="help_long_flag", + cli_args=["--help"], + ), + HelpTestFixture( + test_id="help_short_flag", + cli_args=["-h"], + ), +] + + +@pytest.mark.parametrize( + list(HelpTestFixture._fields), + HELP_TEST_FIXTURES, + ids=[test.test_id for test in HELP_TEST_FIXTURES], +) +def test_help( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test tmuxp --help / -h.""" + # In scrunched terminals, prevent width causing differentiation in result.out. + monkeypatch.setenv("COLUMNS", "100") + monkeypatch.setenv("LINES", "100") + + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + + assert "usage: tmuxp [-h] [--version] [--log-level log-level]" in result.out + + +def test_resolve_behavior( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test resolution of file paths.""" + expect = tmp_path + monkeypatch.chdir(tmp_path) + assert pathlib.Path("../").resolve() == expect.parent + assert pathlib.Path.cwd() == expect + assert pathlib.Path("./").resolve() == expect + assert pathlib.Path(expect).resolve() == expect + + +def test_get_tmuxinator_dir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test get_tmuxinator_dir() helper function.""" + assert get_tmuxinator_dir() == pathlib.Path("~/.tmuxinator").expanduser() + + monkeypatch.setenv("HOME", "/moo") + assert get_tmuxinator_dir() == pathlib.Path("/moo/.tmuxinator/") + assert str(get_tmuxinator_dir()) == "/moo/.tmuxinator" + assert get_tmuxinator_dir() == pathlib.Path("~/.tmuxinator/").expanduser() + + +def test_get_teamocil_dir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test get_teamocil_dir() helper function.""" + assert get_teamocil_dir() == pathlib.Path("~/.teamocil/").expanduser() + + monkeypatch.setenv("HOME", "/moo") + assert get_teamocil_dir() == pathlib.Path("/moo/.teamocil/") + assert str(get_teamocil_dir()) == "/moo/.teamocil" + assert get_teamocil_dir() == pathlib.Path("~/.teamocil/").expanduser() + + +def test_pass_config_dir_argparse( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test workspace configurations can be detected via directory.""" + configdir = tmp_path / "myconfigdir" + configdir.mkdir() + user_config_name = "myconfig" + user_config = configdir / f"{user_config_name}.yaml" + user_config.touch() + + expect = str(user_config) + + parser = argparse.ArgumentParser() + parser.add_argument("workspace_file", type=str) + + def config_cmd(workspace_file: str) -> None: + tmuxp_echo(find_workspace_file(workspace_file, workspace_dir=configdir)) + + def check_cmd(config_arg: str) -> _pytest.capture.CaptureResult[str]: + args = parser.parse_args([config_arg]) + config_cmd(workspace_file=args.workspace_file) + return capsys.readouterr() + + monkeypatch.chdir(configdir) + + assert expect in check_cmd("myconfig").out + assert expect in check_cmd("myconfig.yaml").out + assert expect in check_cmd("./myconfig.yaml").out + assert str(user_config) in check_cmd(str(configdir / "myconfig.yaml")).out + + with pytest.raises(FileNotFoundError): + assert "FileNotFoundError" in check_cmd(".tmuxp.json").out + + +def test_reattach_plugins( + monkeypatch_plugin_test_packages: None, + server: Server, +) -> None: + """Test reattach plugin hook.""" + config_plugins = test_utils.read_workspace_file("workspace/builder/plugin_r.yaml") + + session_config = ConfigReader._load(fmt="yaml", content=config_plugins) + session_config = loader.expand(session_config) + + # open it detached + builder = WorkspaceBuilder( + session_config=session_config, + plugins=load_plugins(session_config), + server=server, + ) + builder.build() + + with contextlib.suppress(libtmux.exc.LibTmuxException): + _reattach(builder) + + assert builder.session is not None + proc = builder.session.cmd("display-message", "-p", "'#S'") + + assert proc.stdout[0] == "'plugin_test_r'" diff --git a/tests/cli/test_convert.py b/tests/cli/test_convert.py new file mode 100644 index 0000000000..8cafecd12c --- /dev/null +++ b/tests/cli/test_convert.py @@ -0,0 +1,140 @@ +"""CLI tests for tmuxp convert.""" + +from __future__ import annotations + +import contextlib +import io +import json +import typing as t + +import pytest + +from tmuxp import cli + +if t.TYPE_CHECKING: + import pathlib + + +class ConvertTestFixture(t.NamedTuple): + """Test fixture for tmuxp convert command tests.""" + + test_id: str + cli_args: list[str] + + +CONVERT_TEST_FIXTURES: list[ConvertTestFixture] = [ + ConvertTestFixture( + test_id="convert_current_dir", + cli_args=["convert", "."], + ), + ConvertTestFixture( + test_id="convert_yaml_file", + cli_args=["convert", ".tmuxp.yaml"], + ), + ConvertTestFixture( + test_id="convert_yaml_file_auto_confirm", + cli_args=["convert", ".tmuxp.yaml", "-y"], + ), + ConvertTestFixture( + test_id="convert_yml_file", + cli_args=["convert", ".tmuxp.yml"], + ), + ConvertTestFixture( + test_id="convert_yml_file_auto_confirm", + cli_args=["convert", ".tmuxp.yml", "-y"], + ), +] + + +@pytest.mark.parametrize( + list(ConvertTestFixture._fields), + CONVERT_TEST_FIXTURES, + ids=[test.test_id for test in CONVERT_TEST_FIXTURES], +) +def test_convert( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Parametrized tests for tmuxp convert.""" + # create dummy tmuxp yaml so we don't get yelled at + filename = cli_args[1] + if filename == ".": + filename = ".tmuxp.yaml" + file_ext = filename.rsplit(".", 1)[-1] + assert file_ext in {"yaml", "yml"}, file_ext + workspace_file_path = tmp_path / filename + workspace_file_path.write_text("\nsession_name: hello\n", encoding="utf-8") + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + # If autoconfirm (-y) no need to prompt y + input_args = "y\ny\n" if "-y" not in cli_args else "" + + monkeypatch.setattr("sys.stdin", io.StringIO(input_args)) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + tmuxp_json = tmp_path / ".tmuxp.json" + assert tmuxp_json.exists() + assert tmuxp_json.open().read() == json.dumps({"session_name": "hello"}, indent=2) + + +class ConvertJsonTestFixture(t.NamedTuple): + """Test fixture for tmuxp convert json command tests.""" + + test_id: str + cli_args: list[str] + + +CONVERT_JSON_TEST_FIXTURES: list[ConvertJsonTestFixture] = [ + ConvertJsonTestFixture( + test_id="convert_json_current_dir", + cli_args=["convert", "."], + ), + ConvertJsonTestFixture( + test_id="convert_json_file", + cli_args=["convert", ".tmuxp.json"], + ), + ConvertJsonTestFixture( + test_id="convert_json_file_auto_confirm", + cli_args=["convert", ".tmuxp.json", "-y"], + ), +] + + +@pytest.mark.parametrize( + list(ConvertJsonTestFixture._fields), + CONVERT_JSON_TEST_FIXTURES, + ids=[test.test_id for test in CONVERT_JSON_TEST_FIXTURES], +) +def test_convert_json( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CLI test using tmuxp convert to convert configuration from json to yaml.""" + # create dummy tmuxp yaml so we don't get yelled at + json_config = tmp_path / ".tmuxp.json" + json_config.write_text('{"session_name": "hello"}', encoding="utf-8") + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + # If autoconfirm (-y) no need to prompt y + input_args = "y\ny\n" if "-y" not in cli_args else "" + + monkeypatch.setattr("sys.stdin", io.StringIO(input_args)) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + tmuxp_yaml = tmp_path / ".tmuxp.yaml" + assert tmuxp_yaml.exists() + assert tmuxp_yaml.open().read() == "session_name: hello\n" 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 new file mode 100644 index 0000000000..9aba904d13 --- /dev/null +++ b/tests/cli/test_debug_info.py @@ -0,0 +1,192 @@ +"""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 + + +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( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Basic CLI test for tmuxp debug-info (human output).""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info"]) + cli_output = capsys.readouterr().out + assert "environment" in cli_output + assert "python version" in cli_output + assert "system PATH" in cli_output + assert "tmux version" in cli_output + assert "libtmux version" in cli_output + assert "tmuxp version" in cli_output + assert "tmux path" in cli_output + assert "tmuxp path" in cli_output + assert "shell" in cli_output + assert "tmux session" in cli_output + assert "tmux windows" in cli_output + 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']}" + ) + + +def test_debug_info_reports_raw_tmux_version( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """debug-info reports tmux's raw version, keeping the point-release suffix. + + libtmux's ``get_version()`` strips the letter suffix (``"3.7a"`` -> ``"3.7"``) + for numeric comparison; ``get_version_str()`` keeps it. Bug reports want the + exact patch release, so debug-info reads the raw string verbatim. + """ + monkeypatch.setenv("SHELL", "/bin/bash") + monkeypatch.setattr( + "tmuxp.cli.debug_info.get_version_str", + lambda *args, **kwargs: "3.7a", + ) + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + data = json.loads(output) + + assert data["tmux_version"] == "3.7a" 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..3f1ba3782e --- /dev/null +++ b/tests/cli/test_formatter.py @@ -0,0 +1,264 @@ +"""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 + + +def test_fill_text_section_heading_with_examples_suffix( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Section headings ending with 'examples:' are colorized without initial block.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # This is what build_description produces - no initial "examples:" block + text = ( + "load examples:\n tmuxp load myproject\n\n" + "freeze examples:\n tmuxp freeze mysession" + ) + result = formatter._fill_text(text, 80, "") + + # Both headings should be colorized (contain ANSI escape codes) + assert "\033[" in result + # Commands should be colorized + assert "tmuxp" in result + + +def test_fill_text_multiple_example_sections_all_colorized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Multiple 'X examples:' sections should all be colorized.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = """load examples: + tmuxp load myproject + +freeze examples: + tmuxp freeze mysession + +ls examples: + tmuxp ls""" + result = formatter._fill_text(text, 80, "") + + # All sections should have ANSI codes + # Count ANSI escape sequences - should have at least heading + command per section + assert result.count("\033[") >= 6 diff --git a/tests/cli/test_freeze.py b/tests/cli/test_freeze.py new file mode 100644 index 0000000000..fd828dbb4a --- /dev/null +++ b/tests/cli/test_freeze.py @@ -0,0 +1,149 @@ +"""Test workspace freezing functionality for tmuxp.""" + +from __future__ import annotations + +import contextlib +import io +import typing as t + +import pytest + +from tmuxp import cli +from tmuxp._internal.config_reader import ConfigReader + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.server import Server + + +class FreezeTestFixture(t.NamedTuple): + """Test fixture for tmuxp freeze command tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +class FreezeOverwriteTestFixture(t.NamedTuple): + """Test fixture for tmuxp freeze overwrite command tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +FREEZE_TEST_FIXTURES: list[FreezeTestFixture] = [ + FreezeTestFixture( + test_id="freeze_named_session", + cli_args=["freeze", "myfrozensession"], + inputs=["y\n", "./la.yaml\n", "y\n"], + ), + FreezeTestFixture( + test_id="freeze_named_session_exists", + cli_args=["freeze", "myfrozensession"], + inputs=["y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), + FreezeTestFixture( + test_id="freeze_current_session", + cli_args=["freeze"], + inputs=["y\n", "./la.yaml\n", "y\n"], + ), + FreezeTestFixture( + test_id="freeze_current_session_exists", + cli_args=["freeze"], + inputs=["y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), +] + + +FREEZE_OVERWRITE_TEST_FIXTURES: list[FreezeOverwriteTestFixture] = [ + FreezeOverwriteTestFixture( + test_id="force_overwrite_named_session", + cli_args=["freeze", "mysession", "--force"], + inputs=["\n", "\n", "y\n", "./exists.yaml\n", "y\n"], + ), + FreezeOverwriteTestFixture( + test_id="force_overwrite_current_session", + cli_args=["freeze", "--force"], + inputs=["\n", "\n", "y\n", "./exists.yaml\n", "y\n"], + ), +] + + +@pytest.mark.parametrize( + list(FreezeTestFixture._fields), + FREEZE_TEST_FIXTURES, + ids=[test.test_id for test in FREEZE_TEST_FIXTURES], +) +def test_freeze( + server: Server, + test_id: str, + cli_args: list[str], + inputs: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Parametrized test for freezing a tmux session to a tmuxp config file.""" + monkeypatch.setenv("HOME", str(tmp_path)) + exists_yaml = tmp_path / "exists.yaml" + exists_yaml.touch() + + server.new_session(session_name="myfirstsession") + server.new_session(session_name="myfrozensession") + + # Assign an active pane to the session + second_session = server.sessions[1] + first_pane_on_second_session_id = second_session.windows[0].panes[0].pane_id + assert first_pane_on_second_session_id + monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) + + monkeypatch.chdir(tmp_path) + # Use tmux server (socket name) used in the test + assert server.socket_name is not None + cli_args = [*cli_args, "-L", server.socket_name] + + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + yaml_config_path = tmp_path / "la.yaml" + assert yaml_config_path.exists() + + yaml_config = yaml_config_path.open().read() + frozen_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + assert frozen_config["session_name"] == "myfrozensession" + + +@pytest.mark.parametrize( + list(FreezeOverwriteTestFixture._fields), + FREEZE_OVERWRITE_TEST_FIXTURES, + ids=[test.test_id for test in FREEZE_OVERWRITE_TEST_FIXTURES], +) +def test_freeze_overwrite( + server: Server, + test_id: str, + cli_args: list[str], + inputs: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test overwrite prompt when freezing a tmuxp configuration file.""" + monkeypatch.setenv("HOME", str(tmp_path)) + exists_yaml = tmp_path / "exists.yaml" + exists_yaml.touch() + + server.new_session(session_name="mysession") + + monkeypatch.chdir(tmp_path) + # Use tmux server (socket name) used in the test + assert server.socket_name is not None + cli_args = [*cli_args, "-L", server.socket_name] + + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + yaml_config_path = tmp_path / "exists.yaml" + assert yaml_config_path.exists() 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..9cbe365db2 --- /dev/null +++ b/tests/cli/test_help_examples.py @@ -0,0 +1,273 @@ +"""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:" (category headings with examples suffix) + # - "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 + + +def test_main_help_example_sections_have_examples_suffix() -> None: + """Main --help should have section headings ending with 'examples:'.""" + help_text = _get_help_text() + + # Should have "load examples:", "freeze examples:", etc. + # NOT just "load:", "freeze:" + assert "load examples:" in help_text.lower() + assert "freeze examples:" in help_text.lower() + + +def test_main_help_examples_are_colorized(monkeypatch: pytest.MonkeyPatch) -> None: + """Main --help should have colorized example sections when FORCE_COLOR is set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + + help_text = _get_help_text() + + # Should contain ANSI escape codes for colorization + assert "\033[" in help_text, "Example sections should be colorized" diff --git a/tests/cli/test_import.py b/tests/cli/test_import.py new file mode 100644 index 0000000000..4faad8fe2c --- /dev/null +++ b/tests/cli/test_import.py @@ -0,0 +1,175 @@ +"""CLI tests for tmuxp import.""" + +from __future__ import annotations + +import contextlib +import io +import typing as t + +import pytest + +from tests.fixtures import utils as test_utils +from tmuxp import cli + +if t.TYPE_CHECKING: + import pathlib + + +class ImportTestFixture(t.NamedTuple): + """Test fixture for basic tmuxp import command tests.""" + + test_id: str + cli_args: list[str] + + +IMPORT_TEST_FIXTURES: list[ImportTestFixture] = [ + ImportTestFixture( + test_id="basic_import", + cli_args=["import"], + ), +] + + +@pytest.mark.parametrize( + list(ImportTestFixture._fields), + IMPORT_TEST_FIXTURES, + ids=[test.test_id for test in IMPORT_TEST_FIXTURES], +) +def test_import( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Basic CLI test for tmuxp import.""" + cli.cli(cli_args) + result = capsys.readouterr() + assert "tmuxinator" in result.out + assert "teamocil" in result.out + + +class ImportTeamocilTestFixture(t.NamedTuple): + """Test fixture for tmuxp import teamocil command tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +IMPORT_TEAMOCIL_TEST_FIXTURES: list[ImportTeamocilTestFixture] = [ + ImportTeamocilTestFixture( + test_id="import_teamocil_config_file", + cli_args=["import", "teamocil", "./.teamocil/config.yaml"], + inputs=["\n", "y\n", "./la.yaml\n", "y\n"], + ), + ImportTeamocilTestFixture( + test_id="import_teamocil_config_file_exists", + cli_args=["import", "teamocil", "./.teamocil/config.yaml"], + inputs=["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), + ImportTeamocilTestFixture( + test_id="import_teamocil_config_name", + cli_args=["import", "teamocil", "config"], + inputs=["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), +] + + +@pytest.mark.parametrize( + list(ImportTeamocilTestFixture._fields), + IMPORT_TEAMOCIL_TEST_FIXTURES, + ids=[test.test_id for test in IMPORT_TEAMOCIL_TEST_FIXTURES], +) +def test_import_teamocil( + test_id: str, + cli_args: list[str], + inputs: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CLI test for tmuxp import w/ teamocil.""" + teamocil_config = test_utils.read_workspace_file("import_teamocil/test4.yaml") + + teamocil_path = tmp_path / ".teamocil" + teamocil_path.mkdir() + + teamocil_config_path = teamocil_path / "config.yaml" + teamocil_config_path.write_text(teamocil_config, encoding="utf-8") + + exists_yaml = tmp_path / "exists.yaml" + exists_yaml.touch() + + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + new_config_yaml = tmp_path / "la.yaml" + assert new_config_yaml.exists() + + +class ImportTmuxinatorTestFixture(t.NamedTuple): + """Test fixture for tmuxp import tmuxinator command tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +IMPORT_TMUXINATOR_TEST_FIXTURES: list[ImportTmuxinatorTestFixture] = [ + ImportTmuxinatorTestFixture( + test_id="import_tmuxinator_config_file", + cli_args=["import", "tmuxinator", "./.tmuxinator/config.yaml"], + inputs=["\n", "y\n", "./la.yaml\n", "y\n"], + ), + ImportTmuxinatorTestFixture( + test_id="import_tmuxinator_config_file_exists", + cli_args=["import", "tmuxinator", "./.tmuxinator/config.yaml"], + inputs=["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), + ImportTmuxinatorTestFixture( + test_id="import_tmuxinator_config_name", + cli_args=["import", "tmuxinator", "config"], + inputs=["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], + ), +] + + +@pytest.mark.parametrize( + list(ImportTmuxinatorTestFixture._fields), + IMPORT_TMUXINATOR_TEST_FIXTURES, + ids=[test.test_id for test in IMPORT_TMUXINATOR_TEST_FIXTURES], +) +def test_import_tmuxinator( + test_id: str, + cli_args: list[str], + inputs: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CLI test for tmuxp import w/ tmuxinator.""" + tmuxinator_config = test_utils.read_workspace_file("import_tmuxinator/test3.yaml") + + tmuxinator_path = tmp_path / ".tmuxinator" + tmuxinator_path.mkdir() + + tmuxinator_config_path = tmuxinator_path / "config.yaml" + tmuxinator_config_path.write_text(tmuxinator_config, encoding="utf-8") + + exists_yaml = tmp_path / "exists.yaml" + exists_yaml.touch() + + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + new_config_yaml = tmp_path / "la.yaml" + assert new_config_yaml.exists() 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 new file mode 100644 index 0000000000..275e798d68 --- /dev/null +++ b/tests/cli/test_load.py @@ -0,0 +1,993 @@ +"""CLI tests for tmuxp load.""" + +from __future__ import annotations + +import contextlib +import io +import pathlib +import typing as t + +import libtmux +import pytest +from libtmux.server import Server +from libtmux.session import Session + +from tests.constants import FIXTURE_PATH +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.load import ( + _load_append_windows_to_current_session, + _load_attached, + load_plugins, + load_workspace, +) +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder + + +def test_load_workspace( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Generic test for loading a tmuxp workspace via tmuxp load.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_workspace_passes_tmux_config( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load with a tmux configuration file.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + tmux_config_file=str(FIXTURE_PATH / "tmux" / "tmux.conf"), + detached=True, + ) + + assert isinstance(session, Session) + assert isinstance(session.server, Server) + assert session.server.config_file == str(FIXTURE_PATH / "tmux" / "tmux.conf") + + +def test_load_workspace_named_session( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load with a custom tmux session name.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + new_session_name="tmuxp-new", + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "tmuxp-new" + + +def test_load_workspace_name_match_regression_252( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load for a regression where tmux shell names would not match.""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + workspace_file = tmp_path / "simple.yaml" + + workspace_file.write_text( + """ +session_name: sampleconfi +start_directory: './' +windows: +- panes: + - echo 'hey'""", + encoding="utf-8", + ) + + # open it detached + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert session.name == "sampleconfi" + + +def test_load_symlinked_workspace( + server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load can follow a symlinked tmuxp config file.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + + realtemp = tmp_path / "myrealtemp" + realtemp.mkdir() + linktemp = tmp_path / "symlinktemp" + linktemp.symlink_to(realtemp) + workspace_file = linktemp / "simple.yaml" + + workspace_file.write_text( + """ +session_name: samplesimple +start_directory: './' +windows: +- panes: + - echo 'hey'""", + encoding="utf-8", + ) + + # open it detached + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert session.active_window is not None + pane = session.active_window.active_pane + + assert isinstance(session, Session) + assert session.name == "samplesimple" + + assert pane is not None + assert pane.pane_current_path == str(realtemp) + + +if t.TYPE_CHECKING: + from typing import TypeAlias + + from pytest_mock import MockerFixture + + ExpectedOutput: TypeAlias = str | list[str] | None + + +class CLILoadFixture(t.NamedTuple): + """Test fixture for tmuxp load tests.""" + + # pytest (internal): Test fixture name + test_id: str + + # test params + cli_args: list[str | list[str]] + config_paths: list[str] + session_names: list[str] + expected_exit_code: int + expected_in_out: ExpectedOutput = None + expected_not_in_out: ExpectedOutput = None + expected_in_err: ExpectedOutput = None + expected_not_in_err: ExpectedOutput = None + + +TEST_LOAD_FIXTURES: list[CLILoadFixture] = [ + CLILoadFixture( + test_id="dir-relative-dot-samedir", + cli_args=["load", "."], + config_paths=["{tmp_path}/.tmuxp.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="dir-relative-dot-slash-samedir", + cli_args=["load", "./"], + config_paths=["{tmp_path}/.tmuxp.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="dir-relative-file-samedir", + cli_args=["load", "./.tmuxp.yaml"], + config_paths=["{tmp_path}/.tmuxp.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="filename-relative-file-samedir", + cli_args=["load", "./my_config.yaml"], + config_paths=["{tmp_path}/my_config.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="configdir-session-name", + cli_args=["load", "my_config"], + config_paths=["{TMUXP_CONFIGDIR}/my_config.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + CLILoadFixture( + test_id="configdir-absolute", + cli_args=["load", "~/.config/tmuxp/my_config.yaml"], + config_paths=["{TMUXP_CONFIGDIR}/my_config.yaml"], + session_names=["my_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), + # + # Multiple configs + # + CLILoadFixture( + test_id="configdir-session-name-double", + cli_args=["load", "my_config", "second_config"], + config_paths=[ + "{TMUXP_CONFIGDIR}/my_config.yaml", + "{TMUXP_CONFIGDIR}/second_config.yaml", + ], + session_names=["my_config", "second_config"], + expected_exit_code=0, + expected_in_out=None, + expected_not_in_out=None, + ), +] + + +@pytest.mark.parametrize( + list(CLILoadFixture._fields), + TEST_LOAD_FIXTURES, + ids=[test.test_id for test in TEST_LOAD_FIXTURES], +) +@pytest.mark.usefixtures("tmuxp_configdir_default") +def test_load( + tmp_path: pathlib.Path, + tmuxp_configdir: pathlib.Path, + server: Server, + session: Session, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + test_id: str, + cli_args: list[str], + config_paths: list[str], + session_names: list[str], + expected_exit_code: int, + expected_in_out: ExpectedOutput, + expected_not_in_out: ExpectedOutput, + expected_in_err: ExpectedOutput, + expected_not_in_err: ExpectedOutput, +) -> None: + """Parametrized test battery for tmuxp load CLI command.""" + assert server.socket_name is not None + + monkeypatch.chdir(tmp_path) + for session_name, config_path in zip(session_names, config_paths, strict=False): + tmuxp_config = pathlib.Path( + config_path.format(tmp_path=tmp_path, TMUXP_CONFIGDIR=tmuxp_configdir), + ) + tmuxp_config.write_text( + f""" + session_name: {session_name} + windows: + - window_name: test + panes: + - + """, + encoding="utf-8", + ) + + with contextlib.suppress(SystemExit): + cli.cli([*cli_args, "-d", "-L", server.socket_name, "-y"]) + + result = capsys.readouterr() + output = "".join(list(result.out)) + + if expected_in_out is not None: + if isinstance(expected_in_out, str): + expected_in_out = [expected_in_out] + for needle in expected_in_out: + assert needle in output + + if expected_not_in_out is not None: + if isinstance(expected_not_in_out, str): + expected_not_in_out = [expected_not_in_out] + for needle in expected_not_in_out: + assert needle not in output + + for session_name in session_names: + assert server.has_session(session_name) + + +def test_regression_00132_session_name_with_dots( + tmp_path: pathlib.Path, + server: Server, + session: Session, + capsys: pytest.CaptureFixture[str], +) -> None: + """Regression test for session names with dots.""" + yaml_config = FIXTURE_PATH / "workspace/builder" / "regression_00132_dots.yaml" + cli_args = [str(yaml_config)] + with pytest.raises(libtmux.exc.BadSessionName): + cli.cli(["load", *cli_args]) + + +class ZshAutotitleTestFixture(t.NamedTuple): + """Test fixture for zsh auto title warning tests.""" + + test_id: str + cli_args: list[str] + + +ZSH_AUTOTITLE_TEST_FIXTURES: list[ZshAutotitleTestFixture] = [ + ZshAutotitleTestFixture( + test_id="load_dot_detached", + cli_args=["load", ".", "-d"], + ), + ZshAutotitleTestFixture( + test_id="load_yaml_detached", + cli_args=["load", ".tmuxp.yaml", "-d"], + ), +] + + +@pytest.mark.parametrize( + list(ZshAutotitleTestFixture._fields), + ZSH_AUTOTITLE_TEST_FIXTURES, + ids=[test.test_id for test in ZSH_AUTOTITLE_TEST_FIXTURES], +) +def test_load_zsh_autotitle_warning( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + server: Server, +) -> None: + """Test warning when ZSH auto title is enabled.""" + # create dummy tmuxp yaml so we don't get yelled at + yaml_config = tmp_path / ".tmuxp.yaml" + yaml_config.write_text( + """ + session_name: test + windows: + - window_name: test + panes: + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + monkeypatch.setenv("SHELL", "zsh") + + # Use tmux server (socket name) used in the test + assert server.socket_name is not None + cli_args = [*cli_args, "-L", server.socket_name] + + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" in result.out + + monkeypatch.setenv("DISABLE_AUTO_TITLE", "false") + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" in result.out + + monkeypatch.setenv("DISABLE_AUTO_TITLE", "true") + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" not in result.out + + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + monkeypatch.setenv("SHELL", "sh") + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" not in result.out + + +class LogFileTestFixture(t.NamedTuple): + """Test fixture for log file tests.""" + + test_id: str + cli_args: list[str] + + +LOG_FILE_TEST_FIXTURES: list[LogFileTestFixture] = [ + LogFileTestFixture( + test_id="load_with_log_file", + cli_args=["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"], + ), +] + + +@pytest.mark.parametrize( + list(LogFileTestFixture._fields), + LOG_FILE_TEST_FIXTURES, + ids=[test.test_id for test in LOG_FILE_TEST_FIXTURES], +) +def test_load_log_file( + test_id: str, + cli_args: list[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test loading with a log file.""" + # create dummy tmuxp yaml that breaks to prevent actually loading tmux + tmuxp_config_path = tmp_path / ".tmuxp.yaml" + tmuxp_config_path.write_text( + """ +session_name: hello + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + + monkeypatch.chdir(tmp_path) + + with contextlib.suppress(Exception): + cli.cli(cli_args) + + result = capsys.readouterr() + log_file_path = tmp_path / "log.txt" + assert "loading workspace" in log_file_path.open().read() + assert result.out is not None + + +def test_load_log_file_level_filtering( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Log-level filtering: INFO log file should not contain DEBUG messages.""" + tmuxp_config_path = tmp_path / ".tmuxp.yaml" + tmuxp_config_path.write_text( + """ +session_name: hello + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + with contextlib.suppress(Exception): + cli.cli(["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"]) + + log_file_path = tmp_path / "log.txt" + log_contents = log_file_path.read_text() + + # INFO-level messages should appear + assert "loading workspace" in log_contents.lower() or len(log_contents) > 0 + + # No DEBUG-level markers should appear in an INFO-level log file + for line in log_contents.splitlines(): + assert "(DEBUG)" not in line, ( + f"DEBUG message leaked into INFO-level log file: {line}" + ) + + +def test_load_plugins( + monkeypatch_plugin_test_packages: None, +) -> None: + """Test loading via tmuxp load with plugins.""" + from tmuxp_test_plugin_bwb.plugin import ( # type: ignore + PluginBeforeWorkspaceBuilder, + ) + + plugins_config = test_utils.read_workspace_file("workspace/builder/plugin_bwb.yaml") + + session_config = ConfigReader._load(fmt="yaml", content=plugins_config) + session_config = loader.expand(session_config) + + plugins = load_plugins(session_config) + + assert len(plugins) == 1 + + test_plugin_class_types = [ + PluginBeforeWorkspaceBuilder().__class__, + ] + for plugin in plugins: + assert plugin.__class__ in test_plugin_class_types + + +class PluginVersionTestFixture(t.NamedTuple): + """Test fixture for plugin version tests.""" + + test_id: str + cli_args: list[str] + inputs: list[str] + + +PLUGIN_VERSION_SKIP_TEST_FIXTURES: list[PluginVersionTestFixture] = [ + PluginVersionTestFixture( + test_id="skip_version_fail", + cli_args=["load", "tests/fixtures/workspace/builder/plugin_versions_fail.yaml"], + inputs=["y\n"], + ), +] + + +@pytest.mark.skip("Not sure how to clean up the tmux session this makes") +@pytest.mark.parametrize( + list(PluginVersionTestFixture._fields), + PLUGIN_VERSION_SKIP_TEST_FIXTURES, + ids=[test.test_id for test in PLUGIN_VERSION_SKIP_TEST_FIXTURES], +) +def test_load_plugins_version_fail_skip( + monkeypatch_plugin_test_packages: None, + test_id: str, + cli_args: list[str], + inputs: list[str], + capsys: pytest.CaptureFixture[str], +) -> None: + """Test plugin version failure with skip.""" + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + + assert "[Loading]" in result.out + + +PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES: list[PluginVersionTestFixture] = [ + PluginVersionTestFixture( + test_id="no_skip_version_fail", + cli_args=["load", "tests/fixtures/workspace/builder/plugin_versions_fail.yaml"], + inputs=["n\n"], + ), +] + + +@pytest.mark.parametrize( + list(PluginVersionTestFixture._fields), + PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES, + ids=[test.test_id for test in PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES], +) +def test_load_plugins_version_fail_no_skip( + monkeypatch_plugin_test_packages: None, + test_id: str, + cli_args: list[str], + inputs: list[str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test plugin version failure without skip.""" + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + + assert "[Not Skipping]" in result.out + + +class PluginMissingTestFixture(t.NamedTuple): + """Test fixture for plugin missing tests.""" + + test_id: str + cli_args: list[str] + + +PLUGIN_MISSING_TEST_FIXTURES: list[PluginMissingTestFixture] = [ + PluginMissingTestFixture( + test_id="missing_plugin", + cli_args=["load", "tests/fixtures/workspace/builder/plugin_missing_fail.yaml"], + ), +] + + +@pytest.mark.parametrize( + list(PluginMissingTestFixture._fields), + PLUGIN_MISSING_TEST_FIXTURES, + ids=[test.test_id for test in PLUGIN_MISSING_TEST_FIXTURES], +) +def test_load_plugins_plugin_missing( + monkeypatch_plugin_test_packages: None, + test_id: str, + cli_args: list[str], + capsys: pytest.CaptureFixture[str], +) -> None: + """Test loading with missing plugin.""" + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + + assert "[Plugin Error]" in result.out + + +class WorkspaceBuilderErrorFixture(t.NamedTuple): + """Test fixture for invalid ``workspace_builder`` selections.""" + + test_id: str + yaml: str + expected_substring: str + + +WORKSPACE_BUILDER_ERROR_FIXTURES: list[WorkspaceBuilderErrorFixture] = [ + WorkspaceBuilderErrorFixture( + test_id="unknown_entry_point_name", + yaml="""\ +session_name: builder-error +workspace_builder: nonexistent-builder +windows: +- panes: + - echo hi +""", + expected_substring="could not be found", + ), + WorkspaceBuilderErrorFixture( + test_id="dotted_import_error", + yaml="""\ +session_name: builder-error +workspace_builder: tmuxp.does_not_exist:Thing +windows: +- panes: + - echo hi +""", + expected_substring="Could not import", + ), + WorkspaceBuilderErrorFixture( + test_id="invalid_object", + yaml="""\ +session_name: builder-error +workspace_builder: tests.fixtures.workspace_builders.invalid:NotABuilder +windows: +- panes: + - echo hi +""", + expected_substring="not a valid workspace builder", + ), + WorkspaceBuilderErrorFixture( + test_id="missing_builder_path", + yaml="""\ +session_name: builder-error +workspace_builder: any:Builder +workspace_builder_paths: +- /nonexistent/tmuxp/builder/dir +windows: +- panes: + - echo hi +""", + expected_substring="workspace_builder_paths entry is invalid", + ), + WorkspaceBuilderErrorFixture( + test_id="invalid_pane_readiness", + yaml="""\ +session_name: builder-error +workspace_builder_options: + pane_readiness: sometimes +windows: +- panes: + - echo hi +""", + expected_substring="pane_readiness", + ), +] + + +@pytest.mark.parametrize( + list(WorkspaceBuilderErrorFixture._fields), + WORKSPACE_BUILDER_ERROR_FIXTURES, + ids=[test.test_id for test in WORKSPACE_BUILDER_ERROR_FIXTURES], +) +def test_load_workspace_builder_error_exits( + tmp_path: pathlib.Path, + server: Server, + capsys: pytest.CaptureFixture[str], + test_id: str, + yaml: str, + expected_substring: str, +) -> None: + """Invalid workspace_builder exits with a styled error, not a traceback.""" + config_file = tmp_path / ".tmuxp.yaml" + config_file.write_text(yaml, encoding="utf-8") + + with pytest.raises(SystemExit) as excinfo: + load_workspace(config_file, socket_name=server.socket_name, detached=True) + + assert excinfo.value.code == 1 + assert expected_substring in capsys.readouterr().out + + +def test_plugin_system_before_script( + monkeypatch_plugin_test_packages: None, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load with sessions using before_script.""" + # this is an implementation test. Since this testsuite may be ran within + # a tmux session by the developer himself, delete the TMUX variable + # temporarily. + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "plugin_bs.yaml" + + # open it detached + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "plugin_test_bs" + + +def test_load_attached( + server: Server, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Test tmuxp load's attachment behavior.""" + # Load a session and attach from outside tmux + monkeypatch.delenv("TMUX", raising=False) + + attach_mock = mocker.patch("libtmux.session.Session.attach") + attach_mock.return_value.stderr = None + + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + + _load_attached(builder, False) + + assert attach_mock.call_count == 1 + + +def test_load_attached_detached( + server: Server, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Test tmuxp load when sessions are build without attaching client.""" + # Load a session but don't attach + monkeypatch.delenv("TMUX", raising=False) + + attach_mock = mocker.patch("libtmux.session.Session.attach") + attach_mock.return_value.stderr = None + + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + + _load_attached(builder, True) + + assert attach_mock.call_count == 0 + + +def test_load_attached_within_tmux( + server: Server, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Test loading via tmuxp load when already within a tmux session.""" + # Load a session and attach from within tmux + monkeypatch.setenv("TMUX", "/tmp/tmux-1234/default,123,0") + + switch_client_mock = mocker.patch("libtmux.session.Session.switch_client") + switch_client_mock.return_value.stderr = None + + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + + _load_attached(builder, False) + + assert switch_client_mock.call_count == 1 + + +def test_load_attached_within_tmux_detached( + server: Server, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + """Test loading via tmuxp load within a tmux session switches clients.""" + # Load a session and attach from within tmux + monkeypatch.setenv("TMUX", "/tmp/tmux-1234/default,123,0") + + switch_client_mock = mocker.patch("libtmux.session.Session.switch_client") + switch_client_mock.return_value.stderr = None + + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + + _load_attached(builder, True) + + assert switch_client_mock.call_count == 1 + + +def test_load_append_windows_to_current_session( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test tmuxp load when windows are appended to the current session.""" + yaml_config = test_utils.read_workspace_file("workspace/builder/two_pane.yaml") + session_config = ConfigReader._load(fmt="yaml", content=yaml_config) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + builder.build() + + assert len(server.sessions) == 1 + assert len(server.windows) == 3 + + # Assign an active pane to the session + assert server.panes[0].pane_id + monkeypatch.setenv("TMUX_PANE", server.panes[0].pane_id) + + builder = WorkspaceBuilder(session_config=session_config, server=server) + _load_append_windows_to_current_session(builder) + + assert len(server.sessions) == 1 + assert len(server.windows) == 6 + + +# Privacy masking in load command + + +def test_load_no_ansi_in_nontty_stderr( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """No ANSI escape codes in stderr when running in non-TTY context (CI/pipe).""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + load_workspace(str(session_file), socket_name=server.socket_name, detached=True) + + captured = capsys.readouterr() + assert "\x1b[" not in captured.err, "ANSI codes leaked into non-TTY stderr" + + +class ProgressDisableFixture(t.NamedTuple): + """Test fixture for progress disable logic.""" + + test_id: str + env_value: str | None + no_progress_flag: bool + expected_disabled: bool + + +PROGRESS_DISABLE_FIXTURES: list[ProgressDisableFixture] = [ + ProgressDisableFixture("default_enabled", None, False, False), + ProgressDisableFixture("env_disabled", "0", False, True), + ProgressDisableFixture("flag_disabled", None, True, True), + ProgressDisableFixture("env_enabled_explicit", "1", False, False), + ProgressDisableFixture("flag_overrides_env", "1", True, True), +] + + +@pytest.mark.parametrize( + list(ProgressDisableFixture._fields), + PROGRESS_DISABLE_FIXTURES, + ids=[f.test_id for f in PROGRESS_DISABLE_FIXTURES], +) +def test_progress_disable_logic( + test_id: str, + env_value: str | None, + no_progress_flag: bool, + expected_disabled: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Progress disable expression matches expected behavior.""" + if env_value is not None: + monkeypatch.setenv("TMUXP_PROGRESS", env_value) + else: + monkeypatch.delenv("TMUXP_PROGRESS", raising=False) + + import os + + result = no_progress_flag or os.getenv("TMUXP_PROGRESS", "1") == "0" + assert result is expected_disabled + + +def test_load_workspace_no_progress( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with no_progress=True creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + no_progress=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_workspace_env_progress_disabled( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with TMUXP_PROGRESS=0 creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setenv("TMUXP_PROGRESS", "0") + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Spinner message should mask home directory via PrivatePath.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + workspace_file = pathlib.Path("/home/testuser/work/project/.tmuxp.yaml") + private_path = str(PrivatePath(workspace_file)) + message = f"Loading workspace: myproject ({private_path})" + + assert "~/work/project/.tmuxp.yaml" in message + assert "/home/testuser" not in message diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py new file mode 100644 index 0000000000..e6f64e28fc --- /dev/null +++ b/tests/cli/test_ls.py @@ -0,0 +1,746 @@ +"""CLI tests for tmuxp ls command.""" + +from __future__ import annotations + +import contextlib +import json +import pathlib + +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 + + +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( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls.""" + filenames = [ + ".git/", + ".gitignore/", + "session_1.yaml", + "session_2.yaml", + "session_3.json", + "session_4.txt", + ] + + # should ignore: + # - directories should be ignored + # - extensions not covered in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ignored_filenames = [".git/", ".gitignore/", "session_4.txt"] + stems = [pathlib.Path(f).stem for f in filenames if f not in ignored_filenames] + + for filename in filenames: + location = isolated_home / f".tmuxp/{filename}" + if filename.endswith("/"): + location.mkdir(parents=True) + else: + location.touch() + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + cli_output = capsys.readouterr().out + + # 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 + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + 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 + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" + lines = [line for line in output.strip().split("\n") if line] + + assert len(lines) == 2 + + # 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..84bee97aa0 --- /dev/null +++ b/tests/cli/test_output.py @@ -0,0 +1,290 @@ +"""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_emit_object_json_writes_immediately( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode emit_object should write indented JSON immediately.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"status": "ok", "count": 3} + # Indented output (indent=2) + assert "\n" in captured.out + + +def test_emit_object_ndjson_writes_compact( + capsys: pytest.CaptureFixture[str], +) -> None: + """NDJSON mode emit_object should write compact single-line JSON.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 1 + assert json.loads(lines[0]) == {"status": "ok", "count": 3} + + +def test_emit_object_human_silent(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode emit_object should produce no output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_object({"status": "ok"}) + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_object_does_not_buffer() -> None: + """emit_object must not affect _json_buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.emit_object({"status": "ok"}) + finally: + sys.stdout = old_stdout + assert formatter._json_buffer == [] + + +def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete HUMAN output workflow.""" + formatter = OutputFormatter(OutputMode.HUMAN) + + # 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_progress.py b/tests/cli/test_progress.py new file mode 100644 index 0000000000..8ace187b65 --- /dev/null +++ b/tests/cli/test_progress.py @@ -0,0 +1,1425 @@ +"""Tests for tmuxp CLI progress indicator.""" + +from __future__ import annotations + +import atexit +import io +import pathlib +import time +import typing as t + +import libtmux +import pytest + +from tmuxp.cli._colors import ColorMode +from tmuxp.cli._progress import ( + BAR_WIDTH, + ERASE_LINE, + HIDE_CURSOR, + PROGRESS_PRESETS, + SHOW_CURSOR, + SUCCESS_TEMPLATE, + BuildTree, + Spinner, + _truncate_visible, + _visible_len, + render_bar, + resolve_progress_format, +) + + +class SpinnerEnablementFixture(t.NamedTuple): + """Test fixture for spinner TTY/color enablement matrix.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_enabled: bool + + +SPINNER_ENABLEMENT_FIXTURES: list[SpinnerEnablementFixture] = [ + SpinnerEnablementFixture("tty_color_always", True, ColorMode.ALWAYS, True), + SpinnerEnablementFixture("tty_color_auto", True, ColorMode.AUTO, True), + SpinnerEnablementFixture("tty_color_never", True, ColorMode.NEVER, True), + SpinnerEnablementFixture("non_tty_color_always", False, ColorMode.ALWAYS, False), + SpinnerEnablementFixture("non_tty_color_never", False, ColorMode.NEVER, False), +] + + +@pytest.mark.parametrize( + list(SpinnerEnablementFixture._fields), + SPINNER_ENABLEMENT_FIXTURES, + ids=[f.test_id for f in SPINNER_ENABLEMENT_FIXTURES], +) +def test_spinner_enablement( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_enabled: bool, +) -> None: + """Spinner._enabled depends only on TTY, not on color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + assert spinner._enabled is expected_enabled + + +def test_spinner_disabled_output() -> None: + """Disabled spinner produces no output.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + with Spinner(message="Test", stream=stream) as spinner: + spinner.update_message("Updated") + + assert stream.getvalue() == "" + + +def test_spinner_enabled_output() -> None: + """Enabled spinner writes ANSI control sequences.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Test", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ): + pass # enter and exit — enough for at least one frame + cleanup + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert ERASE_LINE in output + assert "Test" in output + + +def test_spinner_atexit_registered(monkeypatch: pytest.MonkeyPatch) -> None: + """atexit.register called on start, unregistered on stop.""" + registered: list[t.Any] = [] + unregistered: list[t.Any] = [] + monkeypatch.setattr(atexit, "register", lambda fn, *a: registered.append(fn)) + monkeypatch.setattr(atexit, "unregister", lambda fn: unregistered.append(fn)) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream) as spinner: + assert len(registered) == 1 + assert spinner._restore_cursor in registered + + assert len(unregistered) == 1 + assert spinner._restore_cursor in unregistered + + +def test_spinner_cleans_up_on_exception() -> None: + """SHOW_CURSOR written even when body raises.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + msg = "deliberate" + with ( + pytest.raises(ValueError), + Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream), + ): + raise ValueError(msg) + + assert SHOW_CURSOR in stream.getvalue() + + +def test_spinner_update_message_thread_safe() -> None: + """update_message() can be called from the main thread without error.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Start", color_mode=ColorMode.NEVER, stream=stream) + spinner.update_message("Updated") + assert spinner.message == "Updated" + + +def test_spinner_add_output_line_accumulates() -> None: + """add_output_line() appends stripped lines to the panel deque on TTY.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + spinner.add_output_line("Session created: test\n") + spinner.add_output_line("Creating window: editor") + spinner.add_output_line("") # blank lines are ignored + + assert list(spinner._output_lines) == [ + "Session created: test", + "Creating window: editor", + ] + + +def test_spinner_panel_respects_maxlen() -> None: + """Panel deque enforces output_lines maxlen, dropping oldest lines.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", color_mode=ColorMode.NEVER, stream=stream, output_lines=3 + ) + for i in range(5): + spinner.add_output_line(f"line {i}") + + panel = list(spinner._output_lines) + assert len(panel) == 3 + assert panel == ["line 2", "line 3", "line 4"] + + +def test_spinner_panel_rendered_in_output() -> None: + """Enabled spinner writes panel lines and spinner line to stream.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Building...", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ) as spinner: + spinner.add_output_line("Session created: my-session") + # Wait long enough for the spinner thread to render at least one frame + # that includes the panel line (interval=0.01s, so 0.05s is sufficient). + time.sleep(0.05) + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert "Session created: my-session" in output + assert "Building..." in output + + +# BuildTree tests + + +def test_build_tree_empty_renders_nothing() -> None: + """BuildTree.render() returns [] before any session_created event.""" + colors = ColorMode.NEVER + tree = BuildTree() + from tmuxp.cli._colors import Colors + + assert tree.render(Colors(colors), 80) == [] + + +def test_build_tree_session_created_shows_header() -> None: + """After session_created, render() returns the 'Session' heading line.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines == ["Session"] + + +def test_build_tree_window_started_no_pane_yet() -> None: + """window_started adds a window line with just the name (no pane info).""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert len(lines) == 2 + assert lines[1] == "- editor" + + +def test_build_tree_pane_creating_shows_progress() -> None: + """pane_creating updates the last window to show pane N of M.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 3}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- editor, pane (2 of 3)" + + +def test_build_tree_window_done_shows_checkmark() -> None: + """window_done marks the window as done; render shows checkmark.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + tree.on_event({"event": "window_done"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + + +def test_build_tree_workspace_built_marks_all_done() -> None: + """workspace_built marks all windows as done.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "window_started", "name": "logs", "pane_total": 1}) + tree.on_event({"event": "workspace_built"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- ✓ logs" + + +def test_build_tree_multiple_windows_accumulate() -> None: + """Multiple window_started events accumulate into separate tree lines.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "logging", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- logging, pane (1 of 1)" + + +def test_spinner_on_build_event_delegates_to_tree() -> None: + """Spinner.on_build_event() updates the internal BuildTree state.""" + import io + + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Building...", color_mode=ColorMode.NEVER, stream=stream) + spinner.on_build_event({"event": "session_created", "name": "test-session"}) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 1} + ) + + assert spinner._build_tree.session_name == "test-session" + assert len(spinner._build_tree.windows) == 1 + assert spinner._build_tree.windows[0].name == "editor" + + +# BuildTree.format_inline tests + + +def test_build_tree_format_inline_empty() -> None: + """format_inline returns base unchanged when no session has been created.""" + tree = BuildTree() + assert tree.format_inline("Building projects...") == "Building projects..." + + +def test_build_tree_format_inline_session_only() -> None: + """format_inline returns 'base session' after session_created with no windows.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_inline("Building projects...") == "Building projects... cihai" + + +def test_build_tree_format_inline_with_window_total() -> None: + """format_inline shows window index/total bracket after window_started.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows] gp-libs" + + +def test_build_tree_format_inline_with_panes() -> None: + """format_inline includes pane progress once pane_creating fires.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs" + + +def test_build_tree_format_inline_no_window_total() -> None: + """format_inline omits window count bracket when window_total is absent.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai"}) + tree.on_event({"event": "window_started", "name": "main", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + result = tree.format_inline("Building...") + assert result == "Building... cihai [1 of 1 panes] main" + + +def test_spinner_on_build_event_updates_message() -> None: + """on_build_event updates spinner.message via format_inline after each event.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + assert spinner.message == "Building... cihai [1 of 2 windows, 2 of 3 panes] editor" + + +# resolve_progress_format tests + + +def test_resolve_progress_format_preset_name() -> None: + """A known preset name resolves to its format string.""" + assert resolve_progress_format("default") == PROGRESS_PRESETS["default"] + assert resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + assert resolve_progress_format("verbose") == PROGRESS_PRESETS["verbose"] + + +def test_resolve_progress_format_raw_string() -> None: + """A raw template string is returned unchanged.""" + raw = "{session} w{window_progress}" + assert resolve_progress_format(raw) == raw + + +def test_resolve_progress_format_unknown_name() -> None: + """An unknown name not in presets is returned as-is (raw template pass-through).""" + assert resolve_progress_format("not-a-preset") == "not-a-preset" + + +# BuildTree.format_template tests + + +def test_build_tree_format_template_before_session() -> None: + """format_template returns '' before session_created fires.""" + tree = BuildTree() + assert tree.format_template("{session} [{progress}] {window}") == "" + + +def test_build_tree_format_template_session_only() -> None: + """After session_created alone, progress and window are empty.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_template("{session} [{progress}] {window}") == "cihai [] " + + +def test_build_tree_format_template_with_window() -> None: + """After window_started, window progress appears but pane progress does not.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win] editor" + ) + + +def test_build_tree_format_template_with_pane() -> None: + """After pane_creating, both window and pane progress appear.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win · 2/4 pane] editor" + ) + + +def test_build_tree_format_template_minimal() -> None: + """The minimal preset-style template shows only window fraction.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert tree.format_template("{session} [{window_progress}]") == "cihai [1/3]" + + +def test_build_tree_format_template_verbose() -> None: + """Verbose template shows window/pane indices and totals explicitly.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 12}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + result = tree.format_template(PROGRESS_PRESETS["verbose"]) + assert result == "Loading workspace: cihai [window 1 of 12 · pane 2 of 4] editor" + + +def test_build_tree_format_template_bad_token() -> None: + """Unknown tokens are left as {name}, known tokens still resolve.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown}") + # _SafeFormatMap: {session} resolves, {unknown} stays as-is + assert result == "cihai {unknown}" + + +# Spinner.progress_format integration tests + + +def test_spinner_progress_format_updates_message() -> None: + """Spinner with explicit progress_format uses format_template for updates.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + # Use an explicit format string rather than "default" preset to avoid + # coupling this test to the preset definition (which now includes {bar}). + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} [{progress}] {window}", + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 3} + ) + # No windows yet — falls back to base message to avoid showing empty brackets. + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 4} + ) + assert spinner.message == "cihai [1/3 win] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert spinner.message == "cihai [1/3 win · 2/4 pane] editor" + + +def test_spinner_progress_format_none_uses_inline() -> None: + """Spinner with progress_format=None preserves the format_inline path.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + +# render_bar tests + + +def test_render_bar_empty() -> None: + """render_bar with done=0 produces an all-empty bar.""" + assert render_bar(0, 10) == "░░░░░░░░░░" + + +def test_render_bar_half() -> None: + """render_bar with done=5, total=10 fills exactly half.""" + assert render_bar(5, 10) == "█████░░░░░" + + +def test_render_bar_full() -> None: + """render_bar with done=total fills the entire bar.""" + assert render_bar(10, 10) == "██████████" + + +def test_render_bar_zero_total() -> None: + """render_bar with total=0 returns empty string.""" + assert render_bar(0, 0) == "" + + +def test_render_bar_custom_width() -> None: + """render_bar with custom width produces bar of that inner width.""" + assert render_bar(3, 10, width=5) == "█░░░░" + + +def test_render_bar_width_constant() -> None: + """BAR_WIDTH is the default inner width used by render_bar.""" + bar = render_bar(0, 10) + assert len(bar) == BAR_WIDTH + + +# BuildTree new token tests + + +def test_build_tree_context_session_pane_total() -> None: + """session_pane_total token reflects count from session_created event.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + ctx = tree._context() + assert ctx["session_pane_total"] == 8 + assert ctx["session_pane_progress"] == "0/8" + assert ctx["overall_percent"] == 0 + + +def test_build_tree_context_window_progress_rel() -> None: + """window_progress_rel is 0/N from session_created, increments on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 2}) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_done"}) + assert tree._context()["window_progress_rel"] == "1/3" + + +def test_build_tree_context_pane_progress_rel() -> None: + """pane_progress_rel shows 0/M after window_started, N/M after pane_creating.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 4, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "0/4" + + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "2/4" + assert tree._context()["pane_done"] == 2 + assert tree._context()["pane_remaining"] == 2 + + +def test_build_tree_context_overall_percent() -> None: + """overall_percent is pane-based 0-100; updates on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + assert tree._context()["overall_percent"] == 0 + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + tree.on_event({"event": "window_done"}) + assert tree._context()["session_panes_done"] == 4 + assert tree._context()["overall_percent"] == 50 + + +def test_build_tree_before_script_event_toggle() -> None: + """before_script_started sets the Event; before_script_done clears it.""" + tree = BuildTree() + assert not tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_started"}) + assert tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_done"}) + assert not tree._before_script_event.is_set() + + +def test_build_tree_zero_pane_window() -> None: + """Windows with pane_total=0 do not cause division-by-zero or exceptions.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + + assert tree.session_panes_done == 0 + assert tree.windows_done == 1 + ctx = tree._context() + assert ctx["session_pane_progress"] == "" + assert ctx["overall_percent"] == 0 + + +def test_format_template_extra_backward_compat() -> None: + """format_template(fmt) without extra still works as before.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} [{progress}] {window}") + assert result == "cihai [] " + + +def test_format_template_extra_injected() -> None: + """format_template resolves extra tokens from the extra dict.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {bar}", extra={"bar": "[TEST_BAR]"}) + assert result == "cihai [TEST_BAR]" + + +def test_format_template_unknown_token_preserved() -> None: + """Unknown tokens in the format string render as {name}, not blank or raw fmt.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown_token}") + assert result == "cihai {unknown_token}" + + +# Spinner bar token tests + + +def test_spinner_bar_token_no_color() -> None: + """With ColorMode.NEVER, {bar} token in message contains bar characters.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} {bar} {progress} {window}", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "cihai", + "window_total": 3, + "session_pane_total": 6, + } + ) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 2} + ) + spinner.on_build_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_pane_bar_preset() -> None: + """The 'pane' preset wires {pane_bar} and {session_pane_progress}.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="pane", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 4, + } + ) + spinner.on_build_event({"event": "window_started", "name": "w1", "pane_total": 2}) + spinner.on_build_event({"event": "window_done"}) + + assert "2/4" in spinner.message + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_before_script_event_via_events() -> None: + """before_script_started / before_script_done toggle the BuildTree Event flag.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.on_build_event({"event": "before_script_started"}) + assert spinner._build_tree._before_script_event.is_set() + + spinner.on_build_event({"event": "before_script_done"}) + assert not spinner._build_tree._before_script_event.is_set() + + +def test_progress_presets_have_expected_keys() -> None: + """All expected preset names are present in PROGRESS_PRESETS.""" + for name in ("default", "minimal", "window", "pane", "verbose"): + assert name in PROGRESS_PRESETS, f"Missing preset: {name}" + + +def test_progress_presets_default_includes_bar() -> None: + """The 'default' preset includes the {bar} token.""" + assert "{bar}" in PROGRESS_PRESETS["default"] + + +def test_progress_presets_minimal_format() -> None: + """The 'minimal' preset includes the Loading prefix and window_progress token.""" + expected = "Loading workspace: {session} [{window_progress}]" + assert PROGRESS_PRESETS["minimal"] == expected + + +# BuildTree remaining token tests + + +class RemainingTokenFixture(t.NamedTuple): + """Test fixture for windows_remaining and session_panes_remaining tokens.""" + + test_id: str + events: list[dict[str, t.Any]] + token: str + expected: int + + +REMAINING_TOKEN_FIXTURES: list[RemainingTokenFixture] = [ + RemainingTokenFixture( + "windows_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + ], + "windows_remaining", + 3, + ), + RemainingTokenFixture( + "windows_remaining_after_done", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + {"event": "window_started", "name": "w1", "pane_total": 2}, + {"event": "window_done"}, + ], + "windows_remaining", + 2, + ), + RemainingTokenFixture( + "session_panes_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + ], + "session_panes_remaining", + 5, + ), + RemainingTokenFixture( + "session_panes_remaining_after_window", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + {"event": "window_started", "name": "w1", "pane_total": 3}, + {"event": "window_done"}, + ], + "session_panes_remaining", + 2, + ), +] + + +@pytest.mark.parametrize( + list(RemainingTokenFixture._fields), + REMAINING_TOKEN_FIXTURES, + ids=[f.test_id for f in REMAINING_TOKEN_FIXTURES], +) +def test_build_tree_remaining_tokens( + test_id: str, + events: list[dict[str, t.Any]], + token: str, + expected: int, +) -> None: + """Remaining tokens decrement correctly as windows/panes complete.""" + tree = BuildTree() + for ev in events: + tree.on_event(ev) + assert tree._context()[token] == expected + + +# _visible_len tests + + +class VisibleLenFixture(t.NamedTuple): + """Test fixture for _visible_len ANSI-aware length calculation.""" + + test_id: str + text: str + expected_len: int + + +VISIBLE_LEN_FIXTURES: list[VisibleLenFixture] = [ + VisibleLenFixture("plain_text", "hello", 5), + VisibleLenFixture("ansi_green", "\033[32mgreen\033[0m", 5), + VisibleLenFixture("empty_string", "", 0), + VisibleLenFixture("nested_ansi", "\033[1m\033[31mbold red\033[0m", 8), + VisibleLenFixture("ansi_only", "\033[0m", 0), +] + + +@pytest.mark.parametrize( + list(VisibleLenFixture._fields), + VISIBLE_LEN_FIXTURES, + ids=[f.test_id for f in VISIBLE_LEN_FIXTURES], +) +def test_visible_len( + test_id: str, + text: str, + expected_len: int, +) -> None: + """_visible_len returns the visible character count, ignoring ANSI escapes.""" + assert _visible_len(text) == expected_len + + +# Spinner.add_output_line non-TTY write-through tests + + +class OutputLineFixture(t.NamedTuple): + """Test fixture for add_output_line TTY vs non-TTY behavior.""" + + test_id: str + isatty: bool + lines: list[str] + expected_deque: list[str] + expected_stream_contains: str + + +OUTPUT_LINE_FIXTURES: list[OutputLineFixture] = [ + OutputLineFixture( + "tty_accumulates_in_deque", + isatty=True, + lines=["line1\n", "line2\n"], + expected_deque=["line1", "line2"], + expected_stream_contains="", + ), + OutputLineFixture( + "non_tty_writes_to_stream", + isatty=False, + lines=["hello\n", "world\n"], + expected_deque=[], + expected_stream_contains="hello\nworld\n", + ), + OutputLineFixture( + "blank_lines_ignored", + isatty=True, + lines=["", "\n"], + expected_deque=[], + expected_stream_contains="", + ), +] + + +@pytest.mark.parametrize( + list(OutputLineFixture._fields), + OUTPUT_LINE_FIXTURES, + ids=[f.test_id for f in OUTPUT_LINE_FIXTURES], +) +def test_spinner_output_line_behavior( + test_id: str, + isatty: bool, + lines: list[str], + expected_deque: list[str], + expected_stream_contains: str, +) -> None: + """add_output_line accumulates in deque (TTY) or writes to stream (non-TTY).""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + for line in lines: + spinner.add_output_line(line) + + assert list(spinner._output_lines) == expected_deque + assert expected_stream_contains in stream.getvalue() + + +# Spinner.success tests + + +# Panel lines special values tests + + +class PanelLinesFixture(t.NamedTuple): + """Test fixture for Spinner panel_lines special values.""" + + test_id: str + output_lines: int + expected_maxlen: int | None # None = unbounded + expected_hidden: bool + add_count: int + expected_retained: int + + +PANEL_LINES_FIXTURES: list[PanelLinesFixture] = [ + PanelLinesFixture("zero_hides_panel", 0, 1, True, 10, 1), + PanelLinesFixture("negative_unlimited", -1, None, False, 100, 100), + PanelLinesFixture("positive_normal", 5, 5, False, 10, 5), + PanelLinesFixture("default_three", 3, 3, False, 5, 3), +] + + +@pytest.mark.parametrize( + list(PanelLinesFixture._fields), + PANEL_LINES_FIXTURES, + ids=[f.test_id for f in PANEL_LINES_FIXTURES], +) +def test_spinner_panel_lines_special_values( + test_id: str, + output_lines: int, + expected_maxlen: int | None, + expected_hidden: bool, + add_count: int, + expected_retained: int, +) -> None: + """Spinner panel_lines=0 hides, -1 is unlimited, positive caps normally.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=output_lines, + ) + for i in range(add_count): + spinner.add_output_line(f"line {i}") + + assert len(spinner._output_lines) == expected_retained + assert spinner._output_lines.maxlen == expected_maxlen + assert spinner._panel_hidden is expected_hidden + + +def test_spinner_unlimited_caps_rendered_panel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unlimited panel (-1) caps rendered lines to terminal_height - 2.""" + import os as _os + import shutil + + monkeypatch.setattr( + shutil, + "get_terminal_size", + lambda fallback=(80, 24): _os.terminal_size((80, 10)), + ) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=-1, + interval=0.01, + ) + for i in range(50): + spinner.add_output_line(f"line {i}") + + # All 50 lines should be retained in the unbounded deque + assert len(spinner._output_lines) == 50 + + # Start spinner briefly to render at least one frame + spinner.start() + time.sleep(0.05) + spinner.stop() + + output = stream.getvalue() + # Verify that not all 50 lines appear in any single frame + # The cap should limit to terminal_height - 2 = 8 lines + # Only the last 8 lines should appear in output + assert "line 49" in output + assert "line 0" not in output + + +class SuccessFixture(t.NamedTuple): + """Test fixture for Spinner.success() output behavior.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_contains: str + + +SUCCESS_FIXTURES: list[SuccessFixture] = [ + SuccessFixture("tty_with_color", True, ColorMode.ALWAYS, "done"), + SuccessFixture("tty_no_color", True, ColorMode.NEVER, "✓ done"), + SuccessFixture("non_tty", False, ColorMode.NEVER, "✓ done"), +] + + +@pytest.mark.parametrize( + list(SuccessFixture._fields), + SUCCESS_FIXTURES, + ids=[f.test_id for f in SUCCESS_FIXTURES], +) +def test_spinner_success_behavior( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_contains: str, +) -> None: + """success() always emits the checkmark message regardless of TTY/color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + spinner.success("done") + + output = stream.getvalue() + assert "✓" in output + assert expected_contains in output + + +# _truncate_visible tests + + +def test_truncate_visible_plain_text() -> None: + """Plain text is truncated to max_visible chars with default suffix.""" + assert _truncate_visible("hello world", 5) == "hello\x1b[0m..." + + +def test_truncate_visible_ansi_preserved() -> None: + """ANSI sequences are preserved whole; only visible chars count.""" + result = _truncate_visible("\033[32mgreen\033[0m", 3) + assert result == "\x1b[32mgre\x1b[0m..." + + +def test_truncate_visible_no_truncation() -> None: + """String shorter than max_visible is returned unchanged.""" + assert _truncate_visible("short", 10) == "short" + + +def test_truncate_visible_empty() -> None: + """Empty string returns empty string.""" + assert _truncate_visible("", 5) == "" + + +def test_truncate_visible_custom_suffix() -> None: + """Custom suffix is appended after truncation.""" + assert _truncate_visible("hello world", 5, suffix="~") == "hello\x1b[0m~" + + +def test_truncate_visible_no_suffix() -> None: + """Empty suffix produces only the reset sequence.""" + assert _truncate_visible("hello world", 5, suffix="") == "hello\x1b[0m" + + +# workspace_path token tests + + +def test_build_tree_workspace_path_in_context() -> None: + """workspace_path is available in _context() when set on construction.""" + tree = BuildTree(workspace_path="~/.tmuxp/foo.yaml") + tree.on_event({"event": "session_created", "name": "foo", "window_total": 1}) + ctx = tree._context() + assert ctx["workspace_path"] == "~/.tmuxp/foo.yaml" + + +def test_build_tree_workspace_path_empty_default() -> None: + """workspace_path defaults to empty string in _context().""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "s", "window_total": 1}) + assert tree._context()["workspace_path"] == "" + + +def test_spinner_workspace_path_passed_to_tree() -> None: + """Spinner passes workspace_path through to its BuildTree.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/proj.yaml", + ) + assert spinner._build_tree.workspace_path == "~/.tmuxp/proj.yaml" + + +def test_build_tree_workspace_path_in_template() -> None: + """workspace_path token resolves in format_template.""" + tree = BuildTree(workspace_path="~/.tmuxp/bar.yaml") + tree.on_event({"event": "session_created", "name": "bar", "window_total": 1}) + result = tree.format_template("{session} ({workspace_path})") + assert result == "bar (~/.tmuxp/bar.yaml)" + + +# {summary} token tests + + +def test_build_tree_summary_empty_state() -> None: + """Summary token is empty string before any windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["summary"] == "" + + +def test_build_tree_summary_after_windows_done() -> None: + """Summary token shows bracketed win/pane counts after windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 8, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w3", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[3 win, 8 panes]" + + +def test_build_tree_summary_windows_only_no_panes() -> None: + """Summary token shows only win count when pane_total is 0.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[2 win]" + + +def test_build_tree_summary_panes_only() -> None: + """Summary token shows only pane count when windows_done is 0 (edge case).""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 6, + } + ) + # Manually set session_panes_done without window_done to test edge case + tree.session_panes_done = 6 + assert tree._context()["summary"] == "[6 panes]" + + +# format_success() tests + + +def test_spinner_format_success_full_build() -> None: + """format_success renders SUCCESS_TEMPLATE with session, path, and summary.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/myapp.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "myapp", + "window_total": 3, + "session_pane_total": 8, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w1", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w2", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w3", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "myapp" in result + assert "~/.tmuxp/myapp.yaml" in result + assert "[3 win, 8 panes]" in result + + +def test_spinner_format_success_no_windows() -> None: + """format_success with no windows/panes done omits brackets.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/empty.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "empty", + "window_total": 0, + "session_pane_total": 0, + } + ) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "empty" in result + assert "~/.tmuxp/empty.yaml" in result + assert "[" not in result + + +# Spinner.success() with no args tests + + +def test_spinner_success_no_args_template_mode() -> None: + """success() with no args uses format_success when progress_format is set.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + workspace_path="~/.tmuxp/proj.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "proj", + "window_total": 1, + "session_pane_total": 2, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "main", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + spinner.success() + + output = stream.getvalue() + assert "✓" in output + assert "Loaded workspace:" in output + assert "proj" in output + assert "~/.tmuxp/proj.yaml" in output + assert "[1 win, 2 panes]" in output + + +def test_spinner_success_no_args_no_template() -> None: + """success() with no args and no progress_format falls back to _base_message.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading workspace: myapp", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + spinner.success() + + output = stream.getvalue() + assert "✓ Loading workspace: myapp" in output + + +def test_spinner_success_explicit_text_backward_compat() -> None: + """success('custom text') still works as before (backward compat).""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.success("custom done message") + + output = stream.getvalue() + assert "✓ custom done message" in output + + +# SUCCESS_TEMPLATE constant tests + + +def test_success_template_value() -> None: + """SUCCESS_TEMPLATE contains expected tokens.""" + assert "{session}" in SUCCESS_TEMPLATE + assert "{workspace_path}" in SUCCESS_TEMPLATE + assert "{summary}" in SUCCESS_TEMPLATE + assert "Loaded workspace:" in SUCCESS_TEMPLATE + + +def test_no_success_message_on_build_error( + server: libtmux.Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], +) -> None: + """Success message is not emitted when _dispatch_build returns None.""" + import yaml + + from tmuxp.cli._colors import Colors + from tmuxp.cli.load import load_workspace + + monkeypatch.delenv("TMUX", raising=False) + + config = {"session_name": "test-fail", "windows": [{"window_name": "main"}]} + config_file = tmp_path / "fail.yaml" + config_file.write_text(yaml.dump(config)) + + monkeypatch.setattr( + "tmuxp.cli.load._dispatch_build", + lambda *args, **kwargs: None, + ) + + result = load_workspace( + str(config_file), + socket_name=server.socket_name, + cli_colors=Colors(ColorMode.NEVER), + ) + + assert result is None + captured = capfd.readouterr() + assert "\u2713" not in captured.err + assert "Loaded workspace:" not in captured.err diff --git a/tests/cli/test_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..9e67266b86 --- /dev/null +++ b/tests/cli/test_search.py @@ -0,0 +1,867 @@ +"""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() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" + data = json.loads(captured.out) + assert len(data) == 1 + assert data[0]["name"] == "dev" + + +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() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" + lines = captured.out.strip().split("\n") + # Filter out human-readable lines + json_lines = [line for line in lines if line.startswith("{")] + 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 new file mode 100644 index 0000000000..da97afb237 --- /dev/null +++ b/tests/cli/test_shell.py @@ -0,0 +1,473 @@ +"""CLI tests for tmuxp shell.""" + +from __future__ import annotations + +import contextlib +import io +import subprocess +import typing as t + +import pytest + +from tmuxp import cli, exc + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.server import Server + from libtmux.session import Session + + +class CLIShellFixture(t.NamedTuple): + """Test fixture for tmuxp shell tests.""" + + # pytest (internal): Test fixture name + test_id: str + + # test params + cli_cmd: list[str] + cli_args: list[str] + inputs: list[t.Any] + env: dict[str, str] + expected_output: str + + +TEST_SHELL_FIXTURES: list[CLIShellFixture] = [ + # Regular shell command + CLIShellFixture( + test_id="print-socket-name", + cli_cmd=["shell"], + cli_args=["-L{SOCKET_NAME}", "-c", "print(str(server.socket_name))"], + inputs=[], + env={}, + expected_output="{SERVER_SOCKET_NAME}", + ), + CLIShellFixture( + test_id="print-session-name", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "-c", + "print(session.name)", + ], + inputs=[], + env={}, + expected_output="{SESSION_NAME}", + ), + CLIShellFixture( + test_id="print-has-session", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(server.has_session(session.name))", + ], + inputs=[], + env={}, + expected_output="True", + ), + CLIShellFixture( + test_id="print-window-name", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(window.name)", + ], + inputs=[], + env={}, + expected_output="{WINDOW_NAME}", + ), + CLIShellFixture( + test_id="print-pane-id", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(pane.id)", + ], + inputs=[], + env={}, + expected_output="{PANE_ID}", + ), + CLIShellFixture( + test_id="print-pane-id-obeys-tmux-pane-env-var", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "-c", + "print(pane.id)", + ], + inputs=[], + env={"TMUX_PANE": "{PANE_ID}"}, + expected_output="{PANE_ID}", + ), + # Shell with --pdb + CLIShellFixture( + test_id="print-socket-name-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=["-L{SOCKET_NAME}", "-c", "print(str(server.socket_name))"], + inputs=[], + env={}, + expected_output="{SERVER_SOCKET_NAME}", + ), + CLIShellFixture( + test_id="print-session-name-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "-c", + "print(session.name)", + ], + inputs=[], + env={}, + expected_output="{SESSION_NAME}", + ), + CLIShellFixture( + test_id="print-has-session-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(server.has_session(session.name))", + ], + inputs=[], + env={}, + expected_output="True", + ), + CLIShellFixture( + test_id="print-window-name-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(window.name)", + ], + inputs=[], + env={}, + expected_output="{WINDOW_NAME}", + ), + CLIShellFixture( + test_id="print-pane-id-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "{WINDOW_NAME}", + "-c", + "print(pane.id)", + ], + inputs=[], + env={}, + expected_output="{PANE_ID}", + ), + CLIShellFixture( + test_id="print-pane-id-obeys-tmux-pane-env-var-pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "-c", + "print(pane.id)", + ], + inputs=[], + env={"TMUX_PANE": "{PANE_ID}"}, + expected_output="{PANE_ID}", + ), +] + + +@pytest.mark.parametrize( + list(CLIShellFixture._fields), + TEST_SHELL_FIXTURES, + ids=[test.test_id for test in TEST_SHELL_FIXTURES], +) +def test_shell( + test_id: str, + cli_cmd: list[str], + cli_args: list[str], + inputs: list[t.Any], + env: dict[str, str], + expected_output: str, + server: Server, + session: Session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> 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() + + assert window.active_pane is not None + + template_ctx = { + "SOCKET_NAME": server.socket_name, + "SESSION_NAME": session.name, + "WINDOW_NAME": window_name, + "PANE_ID": window.active_pane.id, + "SERVER_SOCKET_NAME": server.socket_name, + } + + cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] + + for k, v in env.items(): + monkeypatch.setenv(k, v.format(**template_ctx)) + + monkeypatch.chdir(tmp_path) + + cli.cli(cli_args) + result = capsys.readouterr() + assert expected_output.format(**template_ctx) in result.out + + +class CLIShellTargetMissingFixture(t.NamedTuple): + """Test fixture for tmuxp shell target missing tests.""" + + test_id: str + cli_cmd: list[str] + cli_args: list[str] + inputs: list[t.Any] + env: dict[t.Any, t.Any] + template_ctx: dict[str, str] + exception: type[exc.TmuxpException | subprocess.CalledProcessError] + message: str + + +TEST_SHELL_TARGET_MISSING_FIXTURES: list[CLIShellTargetMissingFixture] = [ + # Regular shell command + CLIShellTargetMissingFixture( + test_id="nonexistent_socket", + cli_cmd=["shell"], + cli_args=["-LDoesNotExist", "-c", "print(str(server.socket_name))"], + inputs=[], + env={}, + template_ctx={}, + exception=subprocess.CalledProcessError, + message=r".*DoesNotExist.*", + ), + CLIShellTargetMissingFixture( + test_id="nonexistent_session", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "nonexistent_session", + "-c", + "print(str(server.socket_name))", + ], + inputs=[], + env={}, + template_ctx={"session_name": "nonexistent_session"}, + exception=exc.TmuxpException, + message="Session not found: nonexistent_session", + ), + CLIShellTargetMissingFixture( + test_id="nonexistent_window", + cli_cmd=["shell"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "nonexistent_window", + "-c", + "print(str(server.socket_name))", + ], + inputs=[], + env={}, + template_ctx={"window_name": "nonexistent_window"}, + exception=exc.TmuxpException, + message="Window not found: {WINDOW_NAME}", + ), + # Shell with --pdb + CLIShellTargetMissingFixture( + test_id="nonexistent_socket_pdb", + cli_cmd=["shell", "--pdb"], + cli_args=["-LDoesNotExist", "-c", "print(str(server.socket_name))"], + inputs=[], + env={}, + template_ctx={}, + exception=subprocess.CalledProcessError, + message=r".*DoesNotExist.*", + ), + CLIShellTargetMissingFixture( + test_id="nonexistent_session_pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "nonexistent_session", + "-c", + "print(str(server.socket_name))", + ], + inputs=[], + env={}, + template_ctx={"session_name": "nonexistent_session"}, + exception=exc.TmuxpException, + message="Session not found: nonexistent_session", + ), + CLIShellTargetMissingFixture( + test_id="nonexistent_window_pdb", + cli_cmd=["shell", "--pdb"], + cli_args=[ + "-L{SOCKET_NAME}", + "{SESSION_NAME}", + "nonexistent_window", + "-c", + "print(str(server.socket_name))", + ], + inputs=[], + env={}, + template_ctx={"window_name": "nonexistent_window"}, + exception=exc.TmuxpException, + message="Window not found: {WINDOW_NAME}", + ), +] + + +@pytest.mark.parametrize( + list(CLIShellTargetMissingFixture._fields), + TEST_SHELL_TARGET_MISSING_FIXTURES, + ids=[test.test_id for test in TEST_SHELL_TARGET_MISSING_FIXTURES], +) +def test_shell_target_missing( + test_id: str, + cli_cmd: list[str], + cli_args: list[str], + inputs: list[t.Any], + env: dict[t.Any, t.Any], + template_ctx: dict[str, str], + exception: type[exc.TmuxpException | subprocess.CalledProcessError], + message: str, + server: Server, + session: Session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI tests for tmuxp shell when target is not specified.""" + monkeypatch.setenv("HOME", str(tmp_path)) + window_name = "my_window" + window = session.new_window(window_name=window_name) + window.split() + + assert server.socket_name is not None + assert session.name is not None + + template_ctx.update( + { + "SOCKET_NAME": server.socket_name, + "SESSION_NAME": session.name, + "WINDOW_NAME": template_ctx.get("window_name", window_name), + }, + ) + cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] + + for k, v in env.items(): + monkeypatch.setenv(k, v.format(**template_ctx)) + + monkeypatch.chdir(tmp_path) + + if exception is not None: + with pytest.raises(exception, match=message.format(**template_ctx)): + cli.cli(cli_args) + else: + cli.cli(cli_args) + result = capsys.readouterr() + assert message.format(**template_ctx) in result.out + + +class CLIShellInteractiveFixture(t.NamedTuple): + """Test fixture for tmuxp shell interactive tests.""" + + test_id: str + cli_cmd: list[str] + cli_args: list[str] + inputs: list[t.Any] + env: dict[str, str] + message: str + + +TEST_SHELL_INTERACTIVE_FIXTURES: list[CLIShellInteractiveFixture] = [ + CLIShellInteractiveFixture( + test_id="basic_interactive", + cli_cmd=["shell", "--code"], + cli_args=[ + "-L{SOCKET_NAME}", + ], + inputs=[], + env={}, + message="(InteractiveConsole)", + ), + CLIShellInteractiveFixture( + test_id="interactive_with_pane_id", + cli_cmd=["shell", "--code"], + cli_args=[ + "-L{SOCKET_NAME}", + ], + inputs=[], + env={"PANE_ID": "{PANE_ID}"}, + message="(InteractiveConsole)", + ), +] + + +@pytest.mark.parametrize( + list(CLIShellInteractiveFixture._fields), + TEST_SHELL_INTERACTIVE_FIXTURES, + ids=[test.test_id for test in TEST_SHELL_INTERACTIVE_FIXTURES], +) +def test_shell_interactive( + test_id: str, + cli_cmd: list[str], + cli_args: list[str], + inputs: list[t.Any], + env: dict[str, str], + message: str, + server: Server, + session: Session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI tests for tmuxp shell when shell is specified.""" + monkeypatch.setenv("HOME", str(tmp_path)) + window_name = "my_window" + window = session.new_window(window_name=window_name) + window.split() + + assert window.active_pane is not None + + template_ctx = { + "SOCKET_NAME": server.socket_name, + "SESSION_NAME": session.name, + "WINDOW_NAME": window_name, + "PANE_ID": window.active_pane.id, + "SERVER_SOCKET_NAME": server.socket_name, + } + + cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] + + for k, v in env.items(): + monkeypatch.setenv(k, v.format(**template_ctx)) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sys.stdin", io.StringIO("exit()\r")) + with contextlib.suppress(SystemExit): + cli.cli(cli_args) + + result = capsys.readouterr() + assert message.format(**template_ctx) in result.err 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/conftest.py b/tests/conftest.py deleted file mode 100644 index da5d1713a0..0000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,110 +0,0 @@ -import getpass -import logging -import os -import pathlib - -import pytest - -from libtmux import exc -from libtmux.server import Server -from libtmux.test import TEST_SESSION_PREFIX, get_test_session_name, namer - -logger = logging.getLogger(__name__) - - -@pytest.fixture(autouse=True, scope="session") -def home_path(tmp_path_factory: pytest.TempPathFactory): - return tmp_path_factory.mktemp("home") - - -@pytest.fixture(autouse=True, scope="session") -def user_path(home_path: pathlib.Path): - p = home_path / getpass.getuser() - p.mkdir() - return p - - -@pytest.fixture(autouse=True) -def home_path_default(user_path: pathlib.Path): - os.environ["HOME"] = str(user_path) - - -@pytest.fixture(scope="function") -def monkeypatch_plugin_test_packages(monkeypatch): - paths = [ - "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/", - "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/", - "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/", - "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/", - "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/", - "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/", - ] - for path in paths: - monkeypatch.syspath_prepend(os.path.abspath(os.path.relpath(path))) - - -@pytest.fixture(scope="function") -def socket_name(request): - return "tmuxp_test%s" % next(namer) - - -@pytest.fixture(scope="function") -def server(request, socket_name): - t = Server() - t.socket_name = socket_name - - def fin(): - t.kill_server() - - request.addfinalizer(fin) - - return t - - -@pytest.fixture(scope="function") -def session(server): - session_name = "tmuxp" - - if not server.has_session(session_name): - server.cmd( - "-f", - "/dev/null", # use a blank config to reduce side effects - "new-session", - "-d", # detached - "-s", - session_name, - "/bin/sh", # use /bin/sh as a shell to reduce side effects - # normally, it'd be -c, but new-session is special - ) - - # find current sessions prefixed with tmuxp - old_test_sessions = [ - s.get("session_name") - for s in server._sessions - if s.get("session_name").startswith(TEST_SESSION_PREFIX) - ] - - TEST_SESSION_NAME = get_test_session_name(server=server) - - try: - session = server.new_session(session_name=TEST_SESSION_NAME) - except exc.LibTmuxException as e: - raise e - - """ - Make sure that tmuxp can :ref:`test_builder_visually` and switches to - the newly created session for that testcase. - """ - try: - server.switch_client(session.get("session_id")) - except exc.LibTmuxException: - # server.attach_session(session.get('session_id')) - pass - - for old_test_session in old_test_sessions: - logger.debug("Old test test session %s found. Killing it." % old_test_session) - server.kill_session(old_test_session) - assert TEST_SESSION_NAME == session.get("session_name") - assert TEST_SESSION_NAME != "tmuxp" - - return session diff --git a/tests/constants.py b/tests/constants.py index 9fa14e00f0..ee67d4c770 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -1,3 +1,7 @@ +"""Constant variables for tmuxp tests.""" + +from __future__ import annotations + import pathlib TESTS_PATH = pathlib.Path(__file__).parent diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index a81b5f2914..2f0841acd8 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -1 +1,5 @@ -from . import utils # noqa +"""Fixture test data for tmuxp.""" + +from __future__ import annotations + +from . import utils diff --git a/tests/fixtures/config/__init__.py b/tests/fixtures/config/__init__.py deleted file mode 100644 index ce1f06197e..0000000000 --- a/tests/fixtures/config/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from . import ( # noqa - expand1, - expand2, - expand_blank, - sampleconfig, - shell_command_before, - shell_command_before_session, - trickle, -) diff --git a/tests/fixtures/config/expand1.py b/tests/fixtures/config/expand1.py deleted file mode 100644 index 71e8e0704f..0000000000 --- a/tests/fixtures/config/expand1.py +++ /dev/null @@ -1,77 +0,0 @@ -import os - -before_config = { - "session_name": "sampleconfig", - "start_directory": "~", - "windows": [ - { - "window_name": "editor", - "panes": [ - {"shell_command": ["vim", "top"]}, - {"shell_command": ["vim"]}, - {"shell_command": 'cowsay "hey"'}, - ], - "layout": "main-verticle", - }, - { - "window_name": "logging", - "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], - }, - { - "start_directory": "/var/log", - "options": {"automatic-rename": True}, - "panes": [{"shell_command": "htop"}, "vim"], - }, - {"start_directory": "./", "panes": ["pwd"]}, - {"start_directory": "./asdf/", "panes": ["pwd"]}, - {"start_directory": "../", "panes": ["pwd"]}, - {"panes": ["top"]}, - ], -} - -after_config = { - "session_name": "sampleconfig", - "start_directory": os.path.expanduser("~"), - "windows": [ - { - "window_name": "editor", - "panes": [ - {"shell_command": [{"cmd": "vim"}, {"cmd": "top"}]}, - {"shell_command": [{"cmd": "vim"}]}, - {"shell_command": [{"cmd": 'cowsay "hey"'}]}, - ], - "layout": "main-verticle", - }, - { - "window_name": "logging", - "panes": [{"shell_command": [{"cmd": "tail -F /var/log/syslog"}]}], - }, - { - "start_directory": "/var/log", - "options": {"automatic-rename": True}, - "panes": [ - {"shell_command": [{"cmd": "htop"}]}, - {"shell_command": [{"cmd": "vim"}]}, - ], - }, - { - "start_directory": os.path.normpath( - os.path.join(os.path.expanduser("~"), "./") - ), - "panes": [{"shell_command": [{"cmd": "pwd"}]}], - }, - { - "start_directory": os.path.normpath( - os.path.join(os.path.expanduser("~"), "./asdf") - ), - "panes": [{"shell_command": [{"cmd": "pwd"}]}], - }, - { - "start_directory": os.path.normpath( - os.path.join(os.path.expanduser("~"), "../") - ), - "panes": [{"shell_command": [{"cmd": "pwd"}]}], - }, - {"panes": [{"shell_command": [{"cmd": "top"}]}]}, - ], -} diff --git a/tests/fixtures/config/expand2.py b/tests/fixtures/config/expand2.py deleted file mode 100644 index 4cd49336dd..0000000000 --- a/tests/fixtures/config/expand2.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - -from .. import utils as test_utils - -unexpanded_yaml = test_utils.read_config_file("config/expand2-unexpanded.yaml") -expanded_yaml = test_utils.read_config_file("config/expand2-expanded.yaml").format( - HOME=os.path.expanduser("~") -) diff --git a/tests/fixtures/config/shell_command_before.py b/tests/fixtures/config/shell_command_before.py deleted file mode 100644 index 0f5725e2ee..0000000000 --- a/tests/fixtures/config/shell_command_before.py +++ /dev/null @@ -1,154 +0,0 @@ -import os - -config_unexpanded = { # shell_command_before is string in some areas - "session_name": "sampleconfig", - "start_directory": "/", - "windows": [ - { - "window_name": "editor", - "start_directory": "~", - "shell_command_before": "source .venv/bin/activate", - "panes": [ - {"shell_command": ["vim"]}, - { - "shell_command_before": ["rbenv local 2.0.0-p0"], - "shell_command": ['cowsay "hey"'], - }, - ], - "layout": "main-verticle", - }, - { - "shell_command_before": "rbenv local 2.0.0-p0", - "window_name": "logging", - "panes": [{"shell_command": ["tail -F /var/log/syslog"]}, {}], - }, - { - "window_name": "shufu", - "panes": [ - { - "shell_command_before": ["rbenv local 2.0.0-p0"], - "shell_command": ["htop"], - } - ], - }, - {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, - {"panes": ["top"]}, - ], -} - -config_expanded = { # shell_command_before is string in some areas - "session_name": "sampleconfig", - "start_directory": "/", - "windows": [ - { - "window_name": "editor", - "start_directory": os.path.expanduser("~"), - "shell_command_before": { - "shell_command": [{"cmd": "source .venv/bin/activate"}] - }, - "panes": [ - {"shell_command": [{"cmd": "vim"}]}, - { - "shell_command_before": { - "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}] - }, - "shell_command": [{"cmd": 'cowsay "hey"'}], - }, - ], - "layout": "main-verticle", - }, - { - "shell_command_before": { - "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}] - }, - "window_name": "logging", - "panes": [ - {"shell_command": [{"cmd": "tail -F /var/log/syslog"}]}, - {"shell_command": []}, - ], - }, - { - "window_name": "shufu", - "panes": [ - { - "shell_command_before": { - "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}] - }, - "shell_command": [{"cmd": "htop"}], - } - ], - }, - { - "options": {"automatic-rename": True}, - "panes": [{"shell_command": [{"cmd": "htop"}]}], - }, - {"panes": [{"shell_command": [{"cmd": "top"}]}]}, - ], -} - -config_after = { # shell_command_before is string in some areas - "session_name": "sampleconfig", - "start_directory": "/", - "windows": [ - { - "window_name": "editor", - "start_directory": os.path.expanduser("~"), - "shell_command_before": { - "shell_command": [{"cmd": "source .venv/bin/activate"}] - }, - "panes": [ - { - "shell_command": [ - {"cmd": "source .venv/bin/activate"}, - {"cmd": "vim"}, - ] - }, - { - "shell_command_before": { - "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}] - }, - "shell_command": [ - {"cmd": "source .venv/bin/activate"}, - {"cmd": "rbenv local 2.0.0-p0"}, - {"cmd": 'cowsay "hey"'}, - ], - }, - ], - "layout": "main-verticle", - }, - { - "shell_command_before": { - "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}] - }, - "start_directory": "/", - "window_name": "logging", - "panes": [ - { - "shell_command": [ - {"cmd": "rbenv local 2.0.0-p0"}, - {"cmd": "tail -F /var/log/syslog"}, - ] - }, - {"shell_command": [{"cmd": "rbenv local 2.0.0-p0"}]}, - ], - }, - { - "start_directory": "/", - "window_name": "shufu", - "panes": [ - { - "shell_command_before": { - "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}] - }, - "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}, {"cmd": "htop"}], - } - ], - }, - { - "start_directory": "/", - "options": {"automatic-rename": True}, - "panes": [{"shell_command": [{"cmd": "htop"}]}], - }, - {"start_directory": "/", "panes": [{"shell_command": [{"cmd": "top"}]}]}, - ], -} diff --git a/tests/fixtures/config/shell_command_before_session.py b/tests/fixtures/config/shell_command_before_session.py deleted file mode 100644 index c4f260f7a0..0000000000 --- a/tests/fixtures/config/shell_command_before_session.py +++ /dev/null @@ -1,6 +0,0 @@ -from .. import utils as test_utils - -before = test_utils.read_config_file("config/shell_command_before_session.yaml") -expected = test_utils.read_config_file( - "config/shell_command_before_session-expected.yaml" -) diff --git a/tests/fixtures/config_teamocil/__init__.py b/tests/fixtures/config_teamocil/__init__.py deleted file mode 100644 index 7fe671395a..0000000000 --- a/tests/fixtures/config_teamocil/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import layouts, test1, test2, test3, test4 # noqa diff --git a/tests/fixtures/config_tmuxinator/__init__.py b/tests/fixtures/config_tmuxinator/__init__.py deleted file mode 100644 index bbdbf699ad..0000000000 --- a/tests/fixtures/config_tmuxinator/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import test1, test2, test3 # noqa diff --git a/tests/fixtures/import_teamocil/__init__.py b/tests/fixtures/import_teamocil/__init__.py new file mode 100644 index 0000000000..1ec7c59fd5 --- /dev/null +++ b/tests/fixtures/import_teamocil/__init__.py @@ -0,0 +1,5 @@ +"""Teamocil data fixtures for import_teamocil tests.""" + +from __future__ import annotations + +from . import layouts, test1, test2, test3, test4 diff --git a/tests/fixtures/config_teamocil/layouts.py b/tests/fixtures/import_teamocil/layouts.py similarity index 92% rename from tests/fixtures/config_teamocil/layouts.py rename to tests/fixtures/import_teamocil/layouts.py index c61373fd8a..6e3e06bf7f 100644 --- a/tests/fixtures/config_teamocil/layouts.py +++ b/tests/fixtures/import_teamocil/layouts.py @@ -1,6 +1,11 @@ -from .. import utils as test_utils +"""Teamocil data fixtures for import_teamocil tests, for layout testing.""" -teamocil_yaml = test_utils.read_config_file("config_teamocil/layouts.yaml") +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml_file = test_utils.get_workspace_file("import_teamocil/layouts.yaml") +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/layouts.yaml") teamocil_dict = { "two-windows": { @@ -23,7 +28,7 @@ {"cmd": "echo 'bar again'", "focus": True, "width": 50}, ], }, - ] + ], }, "two-windows-with-filters": { "windows": [ @@ -38,8 +43,8 @@ {"cmd": "echo 'foo'"}, {"cmd": "echo 'foo again'", "width": 50}, ], - } - ] + }, + ], }, "two-windows-with-custom-command-options": { "windows": [ @@ -65,7 +70,7 @@ {"cmd": "echo 'bar again'", "focus": True, "width": 50}, ], }, - ] + ], }, "three-windows-within-a-session": { "session": { @@ -75,7 +80,7 @@ {"name": "second window", "panes": [{"cmd": "echo 'foo'"}]}, {"name": "third window", "panes": [{"cmd": "echo 'foo'"}]}, ], - } + }, }, } @@ -125,7 +130,7 @@ {"shell_command": "echo 'foo'"}, {"shell_command": "echo 'foo again'"}, ], - } + }, ], } diff --git a/tests/fixtures/config_teamocil/layouts.yaml b/tests/fixtures/import_teamocil/layouts.yaml similarity index 100% rename from tests/fixtures/config_teamocil/layouts.yaml rename to tests/fixtures/import_teamocil/layouts.yaml diff --git a/tests/fixtures/config_teamocil/test1.py b/tests/fixtures/import_teamocil/test1.py similarity index 71% rename from tests/fixtures/config_teamocil/test1.py rename to tests/fixtures/import_teamocil/test1.py index ee329c0661..8e2065fec2 100644 --- a/tests/fixtures/config_teamocil/test1.py +++ b/tests/fixtures/import_teamocil/test1.py @@ -1,6 +1,10 @@ -from .. import utils as test_utils +"""Teamocil data fixtures for import_teamocil tests, 1st test.""" -teamocil_yaml = test_utils.read_config_file("config_teamocil/test1.yaml") +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test1.yaml") teamocil_conf = { "windows": [ { @@ -8,8 +12,8 @@ "root": "~/Code/sample/www", "layout": "even-horizontal", "panes": [{"cmd": ["pwd", "ls -la"]}, {"cmd": "rails server --port 3000"}], - } - ] + }, + ], } expected = { @@ -23,6 +27,6 @@ {"shell_command": ["pwd", "ls -la"]}, {"shell_command": "rails server --port 3000"}, ], - } + }, ], } diff --git a/tests/fixtures/config_teamocil/test1.yaml b/tests/fixtures/import_teamocil/test1.yaml similarity index 100% rename from tests/fixtures/config_teamocil/test1.yaml rename to tests/fixtures/import_teamocil/test1.yaml diff --git a/tests/fixtures/config_teamocil/test2.py b/tests/fixtures/import_teamocil/test2.py similarity index 72% rename from tests/fixtures/config_teamocil/test2.py rename to tests/fixtures/import_teamocil/test2.py index dd68433b05..0353a0edbf 100644 --- a/tests/fixtures/config_teamocil/test2.py +++ b/tests/fixtures/import_teamocil/test2.py @@ -1,6 +1,10 @@ -from .. import utils as test_utils +"""Teamocil data fixtures for import_teamocil tests, 2nd test.""" -teamocil_yaml = test_utils.read_config_file("config_teamocil/test2.yaml") +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test2.yaml") teamocil_dict = { "windows": [ { @@ -8,8 +12,8 @@ "root": "~/Code/sample/www", "layout": "tiled", "panes": [{"cmd": "pwd"}, {"cmd": "pwd"}, {"cmd": "pwd"}, {"cmd": "pwd"}], - } - ] + }, + ], } expected = { @@ -25,6 +29,6 @@ {"shell_command": "pwd"}, {"shell_command": "pwd"}, ], - } + }, ], } diff --git a/tests/fixtures/config_teamocil/test2.yaml b/tests/fixtures/import_teamocil/test2.yaml similarity index 100% rename from tests/fixtures/config_teamocil/test2.yaml rename to tests/fixtures/import_teamocil/test2.yaml diff --git a/tests/fixtures/config_teamocil/test3.py b/tests/fixtures/import_teamocil/test3.py similarity index 77% rename from tests/fixtures/config_teamocil/test3.py rename to tests/fixtures/import_teamocil/test3.py index 436e9e4a73..1bcd32fef1 100644 --- a/tests/fixtures/config_teamocil/test3.py +++ b/tests/fixtures/import_teamocil/test3.py @@ -1,6 +1,10 @@ -from .. import utils as test_utils +"""Teamocil data fixtures for import_teamocil tests, 3rd test.""" -teamocil_yaml = test_utils.read_config_file("config_teamocil/test3.yaml") +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test3.yaml") teamocil_dict = { "windows": [ @@ -17,8 +21,8 @@ {"cmd": "bundle exec rails server --port 40", "focus": True}, {"cmd": ["sudo service memcached start", "sudo service mongodb start"]}, ], - } - ] + }, + ], } expected = { @@ -29,7 +33,7 @@ "layout": "even-vertical", "start_directory": "~/Projects/foo-www", "shell_command_before": "rbenv local 2.0.0-p0", - "shell_command_after": ("echo " "'I am done initializing this pane.'"), + "shell_command_after": ("echo 'I am done initializing this pane.'"), "panes": [ {"shell_command": "git status"}, {"shell_command": "bundle exec rails server --port 40", "focus": True}, @@ -37,9 +41,9 @@ "shell_command": [ "sudo service memcached start", "sudo service mongodb start", - ] + ], }, ], - } + }, ], } diff --git a/tests/fixtures/config_teamocil/test3.yaml b/tests/fixtures/import_teamocil/test3.yaml similarity index 100% rename from tests/fixtures/config_teamocil/test3.yaml rename to tests/fixtures/import_teamocil/test3.yaml diff --git a/tests/fixtures/config_teamocil/test4.py b/tests/fixtures/import_teamocil/test4.py similarity index 61% rename from tests/fixtures/config_teamocil/test4.py rename to tests/fixtures/import_teamocil/test4.py index 293fb958fb..1837abf508 100644 --- a/tests/fixtures/config_teamocil/test4.py +++ b/tests/fixtures/import_teamocil/test4.py @@ -1,6 +1,10 @@ -from .. import utils as test_utils +"""Teamocil data fixtures for import_teamocil tests, 4th test.""" -teamocil_yaml = test_utils.read_config_file("config_teamocil/test4.yaml") +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test4.yaml") teamocil_dict = { "windows": [ @@ -8,8 +12,8 @@ "name": "erb-example", "root": "<%= ENV['MY_PROJECT_ROOT'] %>", "panes": [{"cmd": "pwd"}], - } - ] + }, + ], } expected = { @@ -19,6 +23,6 @@ "window_name": "erb-example", "start_directory": "<%= ENV['MY_PROJECT_ROOT'] %>", "panes": [{"shell_command": "pwd"}], - } + }, ], } diff --git a/tests/fixtures/config_teamocil/test4.yaml b/tests/fixtures/import_teamocil/test4.yaml similarity index 100% rename from tests/fixtures/config_teamocil/test4.yaml rename to tests/fixtures/import_teamocil/test4.yaml diff --git a/tests/fixtures/import_tmuxinator/__init__.py b/tests/fixtures/import_tmuxinator/__init__.py new file mode 100644 index 0000000000..84508e0405 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/__init__.py @@ -0,0 +1,5 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests.""" + +from __future__ import annotations + +from . import test1, test2, test3 diff --git a/tests/fixtures/config_tmuxinator/test1.py b/tests/fixtures/import_tmuxinator/test1.py similarity index 67% rename from tests/fixtures/config_tmuxinator/test1.py rename to tests/fixtures/import_tmuxinator/test1.py index b149971743..7e08f976d0 100644 --- a/tests/fixtures/config_tmuxinator/test1.py +++ b/tests/fixtures/import_tmuxinator/test1.py @@ -1,12 +1,16 @@ -from .. import utils as test_utils +"""Tmuxinator data fixtures for import_tmuxinator tests, 1st dataset.""" -tmuxinator_yaml = test_utils.read_config_file("config_tmuxinator/test1.yaml") +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test1.yaml") tmuxinator_dict = { "windows": [ {"editor": {"layout": "main-vertical", "panes": ["vim", "guard"]}}, {"server": "bundle exec rails s"}, {"logs": "tail -f logs/development.log"}, - ] + ], } expected = { diff --git a/tests/fixtures/config_tmuxinator/test1.yaml b/tests/fixtures/import_tmuxinator/test1.yaml similarity index 100% rename from tests/fixtures/config_tmuxinator/test1.yaml rename to tests/fixtures/import_tmuxinator/test1.yaml diff --git a/tests/fixtures/config_tmuxinator/test2.py b/tests/fixtures/import_tmuxinator/test2.py similarity index 86% rename from tests/fixtures/config_tmuxinator/test2.py rename to tests/fixtures/import_tmuxinator/test2.py index f90caa8cd9..97d923a912 100644 --- a/tests/fixtures/config_tmuxinator/test2.py +++ b/tests/fixtures/import_tmuxinator/test2.py @@ -1,6 +1,10 @@ -from .. import utils as test_utils +"""Tmuxinator data fixtures for import_tmuxinator tests, 2nd dataset.""" -tmuxinator_yaml = test_utils.read_config_file("config_tmuxinator/test2.yaml") +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test2.yaml") tmuxinator_dict = { "project_name": "sample", @@ -13,12 +17,12 @@ { "editor": { "pre": [ - 'echo "I get run in each pane, ' 'before each pane command!"', + 'echo "I get run in each pane, before each pane command!"', None, ], "layout": "main-vertical", "panes": ["vim", None, "top"], - } + }, }, {"shell": "git pull"}, { @@ -29,7 +33,7 @@ 'echo "Before each pane command!"', ], "panes": [None, None, None], - } + }, }, {"database": "bundle exec rails db"}, {"server": "bundle exec rails s"}, diff --git a/tests/fixtures/config_tmuxinator/test2.yaml b/tests/fixtures/import_tmuxinator/test2.yaml similarity index 100% rename from tests/fixtures/config_tmuxinator/test2.yaml rename to tests/fixtures/import_tmuxinator/test2.yaml diff --git a/tests/fixtures/config_tmuxinator/test3.py b/tests/fixtures/import_tmuxinator/test3.py similarity index 87% rename from tests/fixtures/config_tmuxinator/test3.py rename to tests/fixtures/import_tmuxinator/test3.py index ce8a8b93f4..86ebd22c16 100644 --- a/tests/fixtures/config_tmuxinator/test3.py +++ b/tests/fixtures/import_tmuxinator/test3.py @@ -1,6 +1,10 @@ -from .. import utils as test_utils +"""Tmuxinator data fixtures for import_tmuxinator tests, 3rd dataset.""" -tmuxinator_yaml = test_utils.read_config_file("config_tmuxinator/test3.yaml") +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test3.yaml") tmuxinator_dict = { "name": "sample", @@ -13,13 +17,13 @@ { "editor": { "pre": [ - 'echo "I get run in each pane, ' 'before each pane command!"', + 'echo "I get run in each pane, before each pane command!"', None, ], "layout": "main-vertical", "root": "~/test/editor", "panes": ["vim", None, "top"], - } + }, }, {"shell": ["git pull", "git merge"]}, { @@ -30,7 +34,7 @@ 'echo "Before each pane command!"', ], "panes": [None, None, None], - } + }, }, {"database": "bundle exec rails db"}, {"server": "bundle exec rails s"}, diff --git a/tests/fixtures/config_tmuxinator/test3.yaml b/tests/fixtures/import_tmuxinator/test3.yaml similarity index 100% rename from tests/fixtures/config_tmuxinator/test3.yaml rename to tests/fixtures/import_tmuxinator/test3.yaml diff --git a/tests/fixtures/pluginsystem/__init__.py b/tests/fixtures/pluginsystem/__init__.py index e69de29bb2..e4aa4bbf4d 100644 --- a/tests/fixtures/pluginsystem/__init__.py +++ b/tests/fixtures/pluginsystem/__init__.py @@ -0,0 +1 @@ +"""Test data for tmuxp plugin system.""" diff --git a/tests/fixtures/pluginsystem/partials/__init__.py b/tests/fixtures/pluginsystem/partials/__init__.py index e69de29bb2..793b523735 100644 --- a/tests/fixtures/pluginsystem/partials/__init__.py +++ b/tests/fixtures/pluginsystem/partials/__init__.py @@ -0,0 +1 @@ +"""Tmuxp tests for plugins.""" diff --git a/tests/fixtures/pluginsystem/partials/_types.py b/tests/fixtures/pluginsystem/partials/_types.py new file mode 100644 index 0000000000..cf5c5c0380 --- /dev/null +++ b/tests/fixtures/pluginsystem/partials/_types.py @@ -0,0 +1,46 @@ +"""Internal, :const:`typing.TYPE_CHECKING` scoped :term:`type annotations `. + +These are _not_ to be imported at runtime as `typing_extensions` is not +bundled with tmuxp. Usage example: + +>>> import typing as t + +>>> if t.TYPE_CHECKING: +... from tmuxp.fixtures.pluginsystem.partials._types import PluginConfigSchema +... +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from typing_extensions import NotRequired, TypedDict +else: + # Fallback for runtime, though this module should not be imported at runtime + try: + from typing_extensions import NotRequired, TypedDict + except ImportError: + # Create dummy classes for runtime + NotRequired = t.Any # type: ignore[misc, assignment] + TypedDict = type # type: ignore[assignment, misc] + + +class PluginTestConfigSchema(TypedDict): + """Same as PluginConfigSchema, but with tmux, libtmux, and tmuxp version.""" + + tmux_version: NotRequired[str] + libtmux_version: NotRequired[str] + tmuxp_version: NotRequired[str] + + # Normal keys + plugin_name: NotRequired[str] + tmux_min_version: NotRequired[str] + tmux_max_version: NotRequired[str] + tmux_version_incompatible: NotRequired[list[str]] + libtmux_min_version: NotRequired[str] + libtmux_max_version: NotRequired[str] + libtmux_version_incompatible: NotRequired[list[str]] + tmuxp_min_version: NotRequired[str] + tmuxp_max_version: NotRequired[str] + tmuxp_version_incompatible: NotRequired[list[str]] diff --git a/tests/fixtures/pluginsystem/partials/all_pass.py b/tests/fixtures/pluginsystem/partials/all_pass.py index 72c4116a42..293d70bef1 100644 --- a/tests/fixtures/pluginsystem/partials/all_pass.py +++ b/tests/fixtures/pluginsystem/partials/all_pass.py @@ -1,9 +1,20 @@ +"""Tmuxp test plugin with version constraints guaranteed to pass.""" + +from __future__ import annotations + +import typing as t + from .test_plugin_helpers import MyTestTmuxpPlugin +if t.TYPE_CHECKING: + from ._types import PluginTestConfigSchema + class AllVersionPassPlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin with config constraints guaranteed to validate.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "tmuxp-plugin-my-tmuxp-plugin", "tmux_min_version": "1.8", "tmux_max_version": "100.0", @@ -17,4 +28,4 @@ def __init__(self): "tmux_version": "3.0", "tmuxp_version": "1.7.0", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) diff --git a/tests/fixtures/pluginsystem/partials/libtmux_version_fail.py b/tests/fixtures/pluginsystem/partials/libtmux_version_fail.py index 80d992f415..e69efaa3f0 100644 --- a/tests/fixtures/pluginsystem/partials/libtmux_version_fail.py +++ b/tests/fixtures/pluginsystem/partials/libtmux_version_fail.py @@ -1,31 +1,46 @@ +"""Fixtures for tmuxp plugins for libtmux version exceptions.""" + +from __future__ import annotations + +import typing as t + from .test_plugin_helpers import MyTestTmuxpPlugin +if t.TYPE_CHECKING: + from ._types import PluginTestConfigSchema + class LibtmuxVersionFailMinPlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin that fails when libtmux below minimum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "libtmux-min-version-fail", "libtmux_min_version": "0.8.3", "libtmux_version": "0.7.0", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) class LibtmuxVersionFailMaxPlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin that fails when libtmux above maximum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "libtmux-max-version-fail", "libtmux_max_version": "3.0", "libtmux_version": "3.5", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) class LibtmuxVersionFailIncompatiblePlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin that fails when libtmux version constraint is invalid.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "libtmux-incompatible-version-fail", "libtmux_version_incompatible": ["0.7.1"], "libtmux_version": "0.7.1", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) diff --git a/tests/fixtures/pluginsystem/partials/test_plugin_helpers.py b/tests/fixtures/pluginsystem/partials/test_plugin_helpers.py index 1ca4ec5fa7..d73674cf3f 100644 --- a/tests/fixtures/pluginsystem/partials/test_plugin_helpers.py +++ b/tests/fixtures/pluginsystem/partials/test_plugin_helpers.py @@ -1,13 +1,35 @@ +"""Tmuxp test plugin for asserting version constraints.""" + +from __future__ import annotations + +import typing as t + from tmuxp.plugin import TmuxpPlugin +if t.TYPE_CHECKING: + from typing_extensions import Unpack + + from tmuxp._internal.types import PluginConfigSchema + + from ._types import PluginTestConfigSchema + class MyTestTmuxpPlugin(TmuxpPlugin): - def __init__(self, config): + """Base class for testing tmuxp plugins with version constraints.""" + + def __init__(self, **config: Unpack[PluginTestConfigSchema]) -> None: + assert isinstance(config, dict) tmux_version = config.pop("tmux_version", None) libtmux_version = config.pop("libtmux_version", None) tmuxp_version = config.pop("tmuxp_version", None) - TmuxpPlugin.__init__(self, **config) + t.cast("PluginConfigSchema", config) + + assert "tmux_version" not in config + + # tests/fixtures/pluginsystem/partials/test_plugin_helpers.py:24: error: Extra + # argument "tmux_version" from **args for "__init__" of "TmuxpPlugin" [misc] + super().__init__(**config) # type:ignore # WARNING! This should not be done in anything but a test if tmux_version: diff --git a/tests/fixtures/pluginsystem/partials/tmux_version_fail.py b/tests/fixtures/pluginsystem/partials/tmux_version_fail.py index 48b2f95f88..deaa89d2cf 100644 --- a/tests/fixtures/pluginsystem/partials/tmux_version_fail.py +++ b/tests/fixtures/pluginsystem/partials/tmux_version_fail.py @@ -1,32 +1,47 @@ +"""Fixtures for tmuxp plugins for tmux version exceptions.""" + +from __future__ import annotations + +import typing as t + from .test_plugin_helpers import MyTestTmuxpPlugin +if t.TYPE_CHECKING: + from ._types import PluginTestConfigSchema + class TmuxVersionFailMinPlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin that fails when tmux below minimum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "tmux-min-version-fail", "tmux_min_version": "1.8", "tmux_version": "1.7", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) class TmuxVersionFailMaxPlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin that fails when tmux above maximum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "tmux-max-version-fail", "tmux_max_version": "3.0", "tmux_version": "3.5", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) class TmuxVersionFailIncompatiblePlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin that fails when tmux version constraint is invalid.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "tmux-incompatible-version-fail", "tmux_version_incompatible": ["2.3"], "tmux_version": "2.3", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) diff --git a/tests/fixtures/pluginsystem/partials/tmuxp_version_fail.py b/tests/fixtures/pluginsystem/partials/tmuxp_version_fail.py index 861f1cfcb8..aa5b8e7783 100644 --- a/tests/fixtures/pluginsystem/partials/tmuxp_version_fail.py +++ b/tests/fixtures/pluginsystem/partials/tmuxp_version_fail.py @@ -1,31 +1,46 @@ +"""Fixtures for tmuxp plugins for tmuxp version exceptions.""" + +from __future__ import annotations + +import typing as t + from .test_plugin_helpers import MyTestTmuxpPlugin +if t.TYPE_CHECKING: + from ._types import PluginTestConfigSchema + class TmuxpVersionFailMinPlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin that fails when tmuxp below minimum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "tmuxp-min-version-fail", "tmuxp_min_version": "1.7.0", "tmuxp_version": "1.6.3", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) class TmuxpVersionFailMaxPlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin that fails when tmuxp above maximum version constraint.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "tmuxp-max-version-fail", "tmuxp_max_version": "2.0.0", "tmuxp_version": "2.5", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) class TmuxpVersionFailIncompatiblePlugin(MyTestTmuxpPlugin): - def __init__(self): - config = { + """Tmuxp plugin that fails when tmuxp version constraint is invalid.""" + + def __init__(self) -> None: + config: PluginTestConfigSchema = { "plugin_name": "tmuxp-incompatible-version-fail", "tmuxp_version_incompatible": ["1.5.0"], "tmuxp_version": "1.5.0", } - MyTestTmuxpPlugin.__init__(self, config) + MyTestTmuxpPlugin.__init__(self, **config) diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/pyproject.toml index c2f98acbcd..27beec81b1 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/pyproject.toml +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/pyproject.toml @@ -1,15 +1,15 @@ -[tool.poetry] +[project] name = "tmuxp_test_plugin_awf" version = "0.0.2" description = "A tmuxp plugin to test after_window_finished part of the tmuxp plugin system" -authors = ["Joseph Flinn "] - -[tool.poetry.dependencies] -python = "^3.6" -tmuxp = "^1.7.0" - -[tool.poetry.dev-dependencies] +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/__init__.py index e69de29bb2..9cbdb91f11 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/__init__.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin that runs after window creation completions.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/plugin.py index fb3ddc3572..a3b323dc65 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/plugin.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_awf/tmuxp_test_plugin_awf/plugin.py @@ -1,11 +1,23 @@ +"""Tmuxp example plugin for after_window_finished.""" + +from __future__ import annotations + +import typing as t + from tmuxp.plugin import TmuxpPlugin +if t.TYPE_CHECKING: + from libtmux.window import Window + class PluginAfterWindowFinished(TmuxpPlugin): - def __init__(self): - self.message = "[+] This is the Tmuxp Test Plugin" + """Tmuxp plugin that runs after window creation completes.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" - def after_window_finished(self, window): + def after_window_finished(self, window: Window) -> None: + """Run hook after window creation completed.""" if window.name == "editor": window.rename_window("plugin_test_awf") elif window.name == "awf_mw_test": @@ -14,5 +26,3 @@ def after_window_finished(self, window): window.rename_window("plugin_test_awf_mw_2") elif window.name == "mp_test_owc": window.rename_window("mp_test_awf") - else: - pass diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/pyproject.toml index 88a8085212..3e624b80f4 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/pyproject.toml +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/pyproject.toml @@ -1,15 +1,15 @@ -[tool.poetry] +[project] name = "tmuxp_test_plugin_bs" version = "0.0.2" description = "A tmuxp plugin to test before_script part of the tmuxp plugin system" -authors = ["Joseph Flinn "] - -[tool.poetry.dependencies] -python = "^3.6" -tmuxp = "^1.7.0" - -[tool.poetry.dev-dependencies] +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/__init__.py index e69de29bb2..4329e60aae 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/__init__.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin module that hooks in before_script, if declared.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/plugin.py index faacc0cd86..9bc64c9e37 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/plugin.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/tmuxp_test_plugin_bs/plugin.py @@ -1,9 +1,21 @@ +"""Tmux plugin that runs before_script, if it is declared in configuration.""" + +from __future__ import annotations + +import typing as t + from tmuxp.plugin import TmuxpPlugin +if t.TYPE_CHECKING: + from libtmux.session import Session + class PluginBeforeScript(TmuxpPlugin): - def __init__(self): - self.message = "[+] This is the Tmuxp Test Plugin" + """Tmuxp plugin that runs before_script.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" - def before_script(self, session): + def before_script(self, session: Session) -> None: + """Run hook during before_script, if it is declared.""" session.rename_session("plugin_test_bs") diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/pyproject.toml index 9fbf0d76a1..f469ebfb44 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/pyproject.toml +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/pyproject.toml @@ -1,15 +1,15 @@ -[tool.poetry] +[project] name = "tmuxp_test_plugin_bwb" version = "0.0.2" description = "A tmuxp plugin to test before_workspace_build part of the tmuxp plugin system" -authors = ["Joseph Flinn "] - -[tool.poetry.dependencies] -python = "^3.6" -tmuxp = "^1.7.0" - -[tool.poetry.dev-dependencies] +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/__init__.py index e69de29bb2..98ca0ca0fc 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/__init__.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin that runs before workspace builder inits.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/plugin.py index aa9cb1419e..b564ec5829 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/plugin.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/tmuxp_test_plugin_bwb/plugin.py @@ -1,9 +1,21 @@ +"""Tmuxp example plugin for before_worksplace_builder.""" + +from __future__ import annotations + +import typing as t + from tmuxp.plugin import TmuxpPlugin +if t.TYPE_CHECKING: + from libtmux.session import Session + class PluginBeforeWorkspaceBuilder(TmuxpPlugin): - def __init__(self): - self.message = "[+] This is the Tmuxp Test Plugin" + """Tmuxp plugin that runs before worksplace builder starts.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" - def before_workspace_builder(self, session): + def before_workspace_builder(self, session: Session) -> None: + """Run hook before workspace builder begins.""" session.rename_session("plugin_test_bwb") diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/pyproject.toml index 5c0f72184e..dad2701978 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/pyproject.toml +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/pyproject.toml @@ -1,15 +1,15 @@ -[tool.poetry] +[project] name = "tmuxp_test_plugin_fail" version = "0.1.0" description = "A test plugin designed to fail to test the cli" -authors = ["Joseph Flinn "] - -[tool.poetry.dependencies] -python = "^3.6" -tmuxp = "^1.7.0" - -[tool.poetry.dev-dependencies] +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/__init__.py index e69de29bb2..2afcb8fce8 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/__init__.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/__init__.py @@ -0,0 +1 @@ +"""Tmuxp plugin test, that is destined for failure.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/plugin.py index 4de06ee2e7..9be75a6e93 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/plugin.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_fail/tmuxp_test_plugin_fail/plugin.py @@ -1,9 +1,20 @@ +"""Tmuxp example plugin that fails on initialization.""" + +from __future__ import annotations + +import typing as t + from tmuxp.plugin import TmuxpPlugin +if t.TYPE_CHECKING: + from tmuxp._internal.types import PluginConfigSchema + class PluginFailVersion(TmuxpPlugin): - def __init__(self): - config = { + """A tmuxp plugin that is doomed to fail. DOOMED.""" + + def __init__(self) -> None: + config: PluginConfigSchema = { "plugin_name": "tmuxp-plugin-fail-version", "tmuxp_max_version": "0.0.0", } diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/pyproject.toml index cdb9a1ed50..485a644297 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/pyproject.toml +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/pyproject.toml @@ -1,15 +1,15 @@ -[tool.poetry] +[project] name = "tmuxp_test_plugin_owc" version = "0.0.2" description = "A tmuxp plugin to test on_window_create part of the tmuxp plugin system" -authors = ["Joseph Flinn "] - -[tool.poetry.dependencies] -python = "^3.6" -tmuxp = "^1.7.0" - -[tool.poetry.dev-dependencies] +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/__init__.py index e69de29bb2..f649064840 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/__init__.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin module for hooks on window creation.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/plugin.py index 0c6af1dc69..acb9268725 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/plugin.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_owc/tmuxp_test_plugin_owc/plugin.py @@ -1,11 +1,23 @@ +"""Tmuxp example plugin for on_window_create.""" + +from __future__ import annotations + +import typing as t + from tmuxp.plugin import TmuxpPlugin +if t.TYPE_CHECKING: + from libtmux.window import Window + class PluginOnWindowCreate(TmuxpPlugin): - def __init__(self): - self.message = "[+] This is the Tmuxp Test Plugin" + """Tmuxp plugin to test custom functionality on window creation.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" - def on_window_create(self, window): + def on_window_create(self, window: Window) -> None: + """Apply hook that runs for tmux on session reattach.""" if window.name == "editor": window.rename_window("plugin_test_owc") elif window.name == "owc_mw_test": @@ -14,5 +26,3 @@ def on_window_create(self, window): window.rename_window("plugin_test_owc_mw_2") elif window.name == "mp_test": window.rename_window("mp_test_owc") - else: - pass diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/pyproject.toml b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/pyproject.toml index ac1291b400..a212e10ad8 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/pyproject.toml +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/pyproject.toml @@ -1,15 +1,15 @@ -[tool.poetry] +[project] name = "tmuxp_test_plugin_r" version = "0.0.2" description = "A tmuxp plugin to test reattach part of the tmuxp plugin system" -authors = ["Joseph Flinn "] - -[tool.poetry.dependencies] -python = "^3.6" -tmuxp = "^1.7.0" - -[tool.poetry.dev-dependencies] +authors = [ + {name = "Joseph Flinn", email = "joseph.s.flinn@gmail.com"} +] +requires-python = ">=3.8,<4.0" +dependencies = [ + "tmuxp>=1.7.0" +] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/__init__.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/__init__.py index e69de29bb2..6e01504fd6 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/__init__.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/__init__.py @@ -0,0 +1 @@ +"""Example tmuxp plugin module for reattaching sessions.""" diff --git a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/plugin.py b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/plugin.py index 1cc6ccb8f0..396afabf5a 100644 --- a/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/plugin.py +++ b/tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_r/tmuxp_test_plugin_r/plugin.py @@ -1,9 +1,21 @@ +"""Tmuxp example plugin for reattaching session.""" + +from __future__ import annotations + +import typing as t + from tmuxp.plugin import TmuxpPlugin +if t.TYPE_CHECKING: + from libtmux.session import Session + class PluginReattach(TmuxpPlugin): - def __init__(self): - self.message = "[+] This is the Tmuxp Test Plugin" + """Tmuxp plugin to test renaming session on reattach.""" + + def __init__(self) -> None: + self.message: str = "[+] This is the Tmuxp Test Plugin" - def reattach(self, session): + def reattach(self, session: Session) -> None: + """Apply hook that runs for tmux on session reattach.""" session.rename_session("plugin_test_r") diff --git a/tests/fixtures/regressions/issue_800_default_size_many_windows.yaml b/tests/fixtures/regressions/issue_800_default_size_many_windows.yaml new file mode 100644 index 0000000000..e18607b380 --- /dev/null +++ b/tests/fixtures/regressions/issue_800_default_size_many_windows.yaml @@ -0,0 +1,12 @@ +session_name: many-windows-issue +windows: +- window_name: moo + layout: main-horizontal + panes: + - echo hello + - echo hello + - echo hello + - echo hello + - echo hello + - echo hello + - echo hello diff --git a/tests/fixtures/structures.py b/tests/fixtures/structures.py new file mode 100644 index 0000000000..5c7587e4ce --- /dev/null +++ b/tests/fixtures/structures.py @@ -0,0 +1,19 @@ +"""Typings / structures for tmuxp fixtures.""" + +from __future__ import annotations + +import dataclasses +import typing as t + + +@dataclasses.dataclass +class WorkspaceTestData: + """Workspace data fixtures for tmuxp tests.""" + + expand1: t.Any + expand2: t.Any + expand_blank: t.Any + sample_workspace: t.Any + shell_command_before: t.Any + shell_command_before_session: t.Any + trickle: t.Any diff --git a/tests/fixtures/tmux/tmux.conf b/tests/fixtures/tmux/tmux.conf new file mode 100644 index 0000000000..ed2abd7ef9 --- /dev/null +++ b/tests/fixtures/tmux/tmux.conf @@ -0,0 +1 @@ +display-message "Hello World" diff --git a/tests/fixtures/utils.py b/tests/fixtures/utils.py index 5d1ec63230..23c4ac26fd 100644 --- a/tests/fixtures/utils.py +++ b/tests/fixtures/utils.py @@ -1,15 +1,38 @@ +"""Utility functions for tmuxp fixtures.""" + +from __future__ import annotations + import pathlib -from ..constants import FIXTURE_PATH +from tests.constants import FIXTURE_PATH + + +def get_workspace_file( + file: str | pathlib.Path, +) -> pathlib.Path: + """Return fixture data, relative to __file__.""" + if isinstance(file, str): + file = pathlib.Path(file) + + return FIXTURE_PATH / file + +def read_workspace_file( + file: pathlib.Path | str, +) -> str: + """Return fixture data, relative to __file__.""" + if isinstance(file, str): + file = pathlib.Path(file) -def read_config_file(_file): # return fixture data, relative to __file__ - return open(FIXTURE_PATH / _file).read() + return get_workspace_file(file).open().read() def write_config( - config_path: pathlib.Path, filename: str, content: str + config_path: pathlib.Path, + filename: str, + content: str, ) -> pathlib.Path: + """Write configuration content to file.""" config = config_path / filename config.write_text(content, encoding="utf-8") return config diff --git a/tests/fixtures/workspace/__init__.py b/tests/fixtures/workspace/__init__.py new file mode 100644 index 0000000000..b097ef9cb6 --- /dev/null +++ b/tests/fixtures/workspace/__init__.py @@ -0,0 +1,13 @@ +"""Workspace data fixtures for tmuxp tests.""" + +from __future__ import annotations + +from . import ( + expand1, + expand2, + expand_blank, + sample_workspace, + shell_command_before, + shell_command_before_session, + trickle, +) diff --git a/tests/fixtures/workspacebuilder/config_script_completes.yaml b/tests/fixtures/workspace/builder/config_script_completes.yaml similarity index 65% rename from tests/fixtures/workspacebuilder/config_script_completes.yaml rename to tests/fixtures/workspace/builder/config_script_completes.yaml index baa539f06a..3772ee8aca 100644 --- a/tests/fixtures/workspacebuilder/config_script_completes.yaml +++ b/tests/fixtures/workspace/builder/config_script_completes.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace before_script: {script_complete} windows: - panes: diff --git a/tests/fixtures/workspacebuilder/config_script_fails.yaml b/tests/fixtures/workspace/builder/config_script_fails.yaml similarity index 66% rename from tests/fixtures/workspacebuilder/config_script_fails.yaml rename to tests/fixtures/workspace/builder/config_script_fails.yaml index 3a317d2495..25b3b899d4 100644 --- a/tests/fixtures/workspacebuilder/config_script_fails.yaml +++ b/tests/fixtures/workspace/builder/config_script_fails.yaml @@ -1,4 +1,4 @@ - session_name: sampleconfig + session_name: sample workspace before_script: {script_failed} windows: - panes: diff --git a/tests/fixtures/workspacebuilder/config_script_not_exists.yaml b/tests/fixtures/workspace/builder/config_script_not_exists.yaml similarity index 66% rename from tests/fixtures/workspacebuilder/config_script_not_exists.yaml rename to tests/fixtures/workspace/builder/config_script_not_exists.yaml index ca9f57c9dd..51c76226b3 100644 --- a/tests/fixtures/workspacebuilder/config_script_not_exists.yaml +++ b/tests/fixtures/workspace/builder/config_script_not_exists.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace before_script: {script_not_exists} windows: - panes: diff --git a/tests/fixtures/workspacebuilder/env_var_options.yaml b/tests/fixtures/workspace/builder/env_var_options.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/env_var_options.yaml rename to tests/fixtures/workspace/builder/env_var_options.yaml diff --git a/tests/fixtures/workspace/builder/environment_vars.yaml b/tests/fixtures/workspace/builder/environment_vars.yaml new file mode 100644 index 0000000000..1160dae67c --- /dev/null +++ b/tests/fixtures/workspace/builder/environment_vars.yaml @@ -0,0 +1,32 @@ +session_name: test env vars +start_directory: "~" +environment: + FOO: SESSION + PATH: /tmp +windows: +- window_name: no_overrides + panes: + - pane +- window_name: window_overrides + environment: + FOO: WINDOW + panes: + - pane +- window_name: pane_overrides + panes: + - environment: + FOO: PANE +- window_name: both_overrides + environment: + FOO: WINDOW + panes: + - pane + - environment: + FOO: PANE +# This test case it just needed for warnings issued in old versions of tmux. +- window_name: both_overrides_on_first_pane + environment: + FOO: WINDOW + panes: + - environment: + FOO: PANE diff --git a/tests/fixtures/workspace/builder/first_pane_start_directory.yaml b/tests/fixtures/workspace/builder/first_pane_start_directory.yaml new file mode 100644 index 0000000000..1f9ae7ed23 --- /dev/null +++ b/tests/fixtures/workspace/builder/first_pane_start_directory.yaml @@ -0,0 +1,5 @@ +session_name: sample workspace +windows: + - panes: + - start_directory: /usr + - start_directory: /etc diff --git a/tests/fixtures/workspacebuilder/focus_and_pane.yaml b/tests/fixtures/workspace/builder/focus_and_pane.yaml similarity index 94% rename from tests/fixtures/workspacebuilder/focus_and_pane.yaml rename to tests/fixtures/workspace/builder/focus_and_pane.yaml index d7e9b5d990..1dd456eb87 100644 --- a/tests/fixtures/workspacebuilder/focus_and_pane.yaml +++ b/tests/fixtures/workspace/builder/focus_and_pane.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' windows: - window_name: focused window diff --git a/tests/fixtures/workspacebuilder/global_options.yaml b/tests/fixtures/workspace/builder/global_options.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/global_options.yaml rename to tests/fixtures/workspace/builder/global_options.yaml diff --git a/tests/fixtures/workspacebuilder/pane_ordering.yaml b/tests/fixtures/workspace/builder/pane_ordering.yaml similarity index 75% rename from tests/fixtures/workspacebuilder/pane_ordering.yaml rename to tests/fixtures/workspace/builder/pane_ordering.yaml index 1d6d54418c..59858582de 100644 --- a/tests/fixtures/workspacebuilder/pane_ordering.yaml +++ b/tests/fixtures/workspace/builder/pane_ordering.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: {HOME} windows: - options: @@ -7,5 +7,5 @@ windows: panes: - cd /usr/bin - cd /usr - - cd /usr/sbin + - cd /etc - cd {HOME} diff --git a/tests/fixtures/workspacebuilder/plugin_awf.yaml b/tests/fixtures/workspace/builder/plugin_awf.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_awf.yaml rename to tests/fixtures/workspace/builder/plugin_awf.yaml diff --git a/tests/fixtures/workspacebuilder/plugin_awf_multiple_windows.yaml b/tests/fixtures/workspace/builder/plugin_awf_multiple_windows.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_awf_multiple_windows.yaml rename to tests/fixtures/workspace/builder/plugin_awf_multiple_windows.yaml diff --git a/tests/fixtures/workspacebuilder/plugin_bs.yaml b/tests/fixtures/workspace/builder/plugin_bs.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_bs.yaml rename to tests/fixtures/workspace/builder/plugin_bs.yaml diff --git a/tests/fixtures/workspacebuilder/plugin_bwb.yaml b/tests/fixtures/workspace/builder/plugin_bwb.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_bwb.yaml rename to tests/fixtures/workspace/builder/plugin_bwb.yaml diff --git a/tests/fixtures/workspacebuilder/plugin_missing_fail.yaml b/tests/fixtures/workspace/builder/plugin_missing_fail.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_missing_fail.yaml rename to tests/fixtures/workspace/builder/plugin_missing_fail.yaml diff --git a/tests/fixtures/workspacebuilder/plugin_multiple_plugins.yaml b/tests/fixtures/workspace/builder/plugin_multiple_plugins.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_multiple_plugins.yaml rename to tests/fixtures/workspace/builder/plugin_multiple_plugins.yaml diff --git a/tests/fixtures/workspacebuilder/plugin_owc.yaml b/tests/fixtures/workspace/builder/plugin_owc.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_owc.yaml rename to tests/fixtures/workspace/builder/plugin_owc.yaml diff --git a/tests/fixtures/workspacebuilder/plugin_owc_multiple_windows.yaml b/tests/fixtures/workspace/builder/plugin_owc_multiple_windows.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_owc_multiple_windows.yaml rename to tests/fixtures/workspace/builder/plugin_owc_multiple_windows.yaml diff --git a/tests/fixtures/workspacebuilder/plugin_r.yaml b/tests/fixtures/workspace/builder/plugin_r.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_r.yaml rename to tests/fixtures/workspace/builder/plugin_r.yaml diff --git a/tests/fixtures/workspacebuilder/plugin_versions_fail.yaml b/tests/fixtures/workspace/builder/plugin_versions_fail.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/plugin_versions_fail.yaml rename to tests/fixtures/workspace/builder/plugin_versions_fail.yaml diff --git a/tests/fixtures/workspacebuilder/regression_00132_dots.yaml b/tests/fixtures/workspace/builder/regression_00132_dots.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/regression_00132_dots.yaml rename to tests/fixtures/workspace/builder/regression_00132_dots.yaml diff --git a/tests/fixtures/workspacebuilder/session_options.yaml b/tests/fixtures/workspace/builder/session_options.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/session_options.yaml rename to tests/fixtures/workspace/builder/session_options.yaml diff --git a/tests/fixtures/workspacebuilder/start_directory.yaml b/tests/fixtures/workspace/builder/start_directory.yaml similarity index 97% rename from tests/fixtures/workspacebuilder/start_directory.yaml rename to tests/fixtures/workspace/builder/start_directory.yaml index e27f8be189..e5ed2ec5df 100644 --- a/tests/fixtures/workspacebuilder/start_directory.yaml +++ b/tests/fixtures/workspace/builder/start_directory.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '/usr' windows: - window_name: supposed to be /usr/bin diff --git a/tests/fixtures/workspacebuilder/start_directory_relative.yaml b/tests/fixtures/workspace/builder/start_directory_relative.yaml similarity index 85% rename from tests/fixtures/workspacebuilder/start_directory_relative.yaml rename to tests/fixtures/workspace/builder/start_directory_relative.yaml index 1b4ac7cdad..c6b0e59299 100644 --- a/tests/fixtures/workspacebuilder/start_directory_relative.yaml +++ b/tests/fixtures/workspace/builder/start_directory_relative.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: ./ windows: - window_name: supposed to be /usr/bin @@ -28,7 +28,7 @@ windows: - echo "hey" - shell_command: - echo "moo" -- window_name: inherit start_directory which is rel to config file +- window_name: inherit start_directory which is rel to workspace file panes: - shell_command: - echo hello @@ -36,7 +36,7 @@ windows: - echo "hey" - shell_command: - echo "moo3" -- window_name: cwd relative to config file +- window_name: cwd relative to workspace file start_directory: ./ panes: - shell_command: diff --git a/tests/fixtures/workspace/builder/start_directory_session_path.yaml b/tests/fixtures/workspace/builder/start_directory_session_path.yaml new file mode 100644 index 0000000000..5fd35b2f4e --- /dev/null +++ b/tests/fixtures/workspace/builder/start_directory_session_path.yaml @@ -0,0 +1,6 @@ +--- +session_name: sample_start_dir_session_path +start_directory: '/usr' +windows: + - panes: + - diff --git a/tests/fixtures/workspacebuilder/suppress_history.yaml b/tests/fixtures/workspace/builder/suppress_history.yaml similarity index 85% rename from tests/fixtures/workspacebuilder/suppress_history.yaml rename to tests/fixtures/workspace/builder/suppress_history.yaml index b480f5418a..04722f9f3c 100644 --- a/tests/fixtures/workspacebuilder/suppress_history.yaml +++ b/tests/fixtures/workspace/builder/suppress_history.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' suppress_history: false windows: diff --git a/tests/fixtures/workspacebuilder/three_pane.yaml b/tests/fixtures/workspace/builder/three_pane.yaml similarity index 86% rename from tests/fixtures/workspacebuilder/three_pane.yaml rename to tests/fixtures/workspace/builder/three_pane.yaml index 0899525325..860fcd9371 100644 --- a/tests/fixtures/workspacebuilder/three_pane.yaml +++ b/tests/fixtures/workspace/builder/three_pane.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' windows: - window_name: test diff --git a/tests/fixtures/workspacebuilder/three_windows.yaml b/tests/fixtures/workspace/builder/three_windows.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/three_windows.yaml rename to tests/fixtures/workspace/builder/three_windows.yaml diff --git a/tests/fixtures/workspacebuilder/two_pane.yaml b/tests/fixtures/workspace/builder/two_pane.yaml similarity index 90% rename from tests/fixtures/workspacebuilder/two_pane.yaml rename to tests/fixtures/workspace/builder/two_pane.yaml index 9bb96e70f3..216080feb2 100644 --- a/tests/fixtures/workspacebuilder/two_pane.yaml +++ b/tests/fixtures/workspace/builder/two_pane.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' windows: - layout: main-vertical diff --git a/tests/fixtures/workspacebuilder/two_windows.yaml b/tests/fixtures/workspace/builder/two_windows.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/two_windows.yaml rename to tests/fixtures/workspace/builder/two_windows.yaml diff --git a/tests/fixtures/workspacebuilder/window_automatic_rename.yaml b/tests/fixtures/workspace/builder/window_automatic_rename.yaml similarity index 72% rename from tests/fixtures/workspacebuilder/window_automatic_rename.yaml rename to tests/fixtures/workspace/builder/window_automatic_rename.yaml index 08329afcfe..2584363930 100644 --- a/tests/fixtures/workspacebuilder/window_automatic_rename.yaml +++ b/tests/fixtures/workspace/builder/window_automatic_rename.yaml @@ -1,13 +1,15 @@ session_name: test window options start_directory: '~' windows: -- layout: main-horizontal +- window_name: renamed_window + layout: main-horizontal options: automatic-rename: on panes: - shell_command: - - cmd: sh + - cmd: man ls start_directory: '~' + focus: true - shell_command: - cmd: echo "hey" - shell_command: diff --git a/tests/fixtures/workspacebuilder/window_index.yaml b/tests/fixtures/workspace/builder/window_index.yaml similarity index 83% rename from tests/fixtures/workspacebuilder/window_index.yaml rename to tests/fixtures/workspace/builder/window_index.yaml index 3f6c080dcd..dda2729859 100644 --- a/tests/fixtures/workspacebuilder/window_index.yaml +++ b/tests/fixtures/workspace/builder/window_index.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace windows: - window_name: zero panes: diff --git a/tests/fixtures/workspacebuilder/window_options.yaml b/tests/fixtures/workspace/builder/window_options.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/window_options.yaml rename to tests/fixtures/workspace/builder/window_options.yaml diff --git a/tests/fixtures/workspacebuilder/window_options_after.yaml b/tests/fixtures/workspace/builder/window_options_after.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/window_options_after.yaml rename to tests/fixtures/workspace/builder/window_options_after.yaml diff --git a/tests/fixtures/workspacebuilder/window_shell.yaml b/tests/fixtures/workspace/builder/window_shell.yaml similarity index 100% rename from tests/fixtures/workspacebuilder/window_shell.yaml rename to tests/fixtures/workspace/builder/window_shell.yaml diff --git a/tests/fixtures/workspace/expand1.py b/tests/fixtures/workspace/expand1.py new file mode 100644 index 0000000000..6ad0cbb9e3 --- /dev/null +++ b/tests/fixtures/workspace/expand1.py @@ -0,0 +1,79 @@ +"""Examples of expansion of tmuxp configurations from shorthand style.""" + +from __future__ import annotations + +import pathlib +import typing as t + +before_workspace = { + "session_name": "sample workspace", + "start_directory": "~", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": ["vim", "top"]}, + {"shell_command": ["vim"]}, + {"shell_command": 'cowsay "hey"'}, + ], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], + }, + { + "start_directory": "/var/log", + "options": {"automatic-rename": True}, + "panes": [{"shell_command": "htop"}, "vim"], + }, + {"start_directory": "./", "panes": ["pwd"]}, + {"start_directory": "./asdf/", "panes": ["pwd"]}, + {"start_directory": "../", "panes": ["pwd"]}, + {"panes": ["top"]}, + ], +} + + +def after_workspace() -> dict[str, t.Any]: + """After expansion of shorthand style.""" + return { + "session_name": "sample workspace", + "start_directory": str(pathlib.Path().home()), + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": [{"cmd": "vim"}, {"cmd": "top"}]}, + {"shell_command": [{"cmd": "vim"}]}, + {"shell_command": [{"cmd": 'cowsay "hey"'}]}, + ], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": [{"cmd": "tail -F /var/log/syslog"}]}], + }, + { + "start_directory": "/var/log", + "options": {"automatic-rename": True}, + "panes": [ + {"shell_command": [{"cmd": "htop"}]}, + {"shell_command": [{"cmd": "vim"}]}, + ], + }, + { + "start_directory": str(pathlib.Path().home()), + "panes": [{"shell_command": [{"cmd": "pwd"}]}], + }, + { + "start_directory": str(pathlib.Path().home() / "asdf"), + "panes": [{"shell_command": [{"cmd": "pwd"}]}], + }, + { + "start_directory": str(pathlib.Path().home().parent.resolve()), + "panes": [{"shell_command": [{"cmd": "pwd"}]}], + }, + {"panes": [{"shell_command": [{"cmd": "top"}]}]}, + ], + } diff --git a/tests/fixtures/config/expand2-expanded.yaml b/tests/fixtures/workspace/expand2-expanded.yaml similarity index 94% rename from tests/fixtures/config/expand2-expanded.yaml rename to tests/fixtures/workspace/expand2-expanded.yaml index e407146427..0809447aaa 100644 --- a/tests/fixtures/config/expand2-expanded.yaml +++ b/tests/fixtures/workspace/expand2-expanded.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: {HOME} windows: - window_name: focused window diff --git a/tests/fixtures/config/expand2-unexpanded.yaml b/tests/fixtures/workspace/expand2-unexpanded.yaml similarity index 94% rename from tests/fixtures/config/expand2-unexpanded.yaml rename to tests/fixtures/workspace/expand2-unexpanded.yaml index 6e70f42f89..6c1a49ed51 100644 --- a/tests/fixtures/config/expand2-unexpanded.yaml +++ b/tests/fixtures/workspace/expand2-unexpanded.yaml @@ -1,4 +1,4 @@ -session_name: sampleconfig +session_name: sample workspace start_directory: '~' windows: - window_name: focused window diff --git a/tests/fixtures/workspace/expand2.py b/tests/fixtures/workspace/expand2.py new file mode 100644 index 0000000000..7e8089bbbc --- /dev/null +++ b/tests/fixtures/workspace/expand2.py @@ -0,0 +1,19 @@ +"""YAML examples of expansion of tmuxp configurations from shorthand style.""" + +from __future__ import annotations + +import pathlib + +from tests.fixtures import utils as test_utils + + +def unexpanded_yaml() -> str: + """Return unexpanded, shorthand YAML tmuxp configuration.""" + return test_utils.read_workspace_file("workspace/expand2-unexpanded.yaml") + + +def expanded_yaml() -> str: + """Return expanded, verbose YAML tmuxp configuration.""" + return test_utils.read_workspace_file("workspace/expand2-expanded.yaml").format( + HOME=str(pathlib.Path().home()), + ) diff --git a/tests/fixtures/config/expand_blank.py b/tests/fixtures/workspace/expand_blank.py similarity index 90% rename from tests/fixtures/config/expand_blank.py rename to tests/fixtures/workspace/expand_blank.py index 4d0dad3b49..5137605297 100644 --- a/tests/fixtures/config/expand_blank.py +++ b/tests/fixtures/workspace/expand_blank.py @@ -1,3 +1,7 @@ +"""Expected expanded configuration for empty workspace panes.""" + +from __future__ import annotations + expected = { "session_name": "Blank pane test", "windows": [ diff --git a/tests/fixtures/workspacefreezer/sampleconfig.yaml b/tests/fixtures/workspace/freezer/sample_workspace.yaml similarity index 80% rename from tests/fixtures/workspacefreezer/sampleconfig.yaml rename to tests/fixtures/workspace/freezer/sample_workspace.yaml index fb8b11e3e2..ebd3ab3f9a 100644 --- a/tests/fixtures/workspacefreezer/sampleconfig.yaml +++ b/tests/fixtures/workspace/freezer/sample_workspace.yaml @@ -1,11 +1,11 @@ -session_name: sampleconfig -start_directory: '~' +session_name: sample workspace +start_directory: "~" windows: - layout: main-vertical panes: - shell_command: - cmd: vim - start_directory: '~' + start_directory: "~" - shell_command: - cmd: echo "hey" - cmd: cd ../ diff --git a/tests/fixtures/config/sampleconfig.py b/tests/fixtures/workspace/sample_workspace.py similarity index 72% rename from tests/fixtures/config/sampleconfig.py rename to tests/fixtures/workspace/sample_workspace.py index 46bc6f2d6f..e00b15113a 100644 --- a/tests/fixtures/config/sampleconfig.py +++ b/tests/fixtures/workspace/sample_workspace.py @@ -1,5 +1,9 @@ -sampleconfigdict = { - "session_name": "sampleconfig", +"""Example workspace fixture for tmuxp WorkspaceBuilder.""" + +from __future__ import annotations + +sample_workspace_dict = { + "session_name": "sample workspace", "start_directory": "~", "windows": [ { @@ -8,7 +12,7 @@ {"start_directory": "~", "shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}, ], - "layout": "main-verticle", + "layout": "main-vertical", }, { "window_name": "logging", @@ -16,7 +20,7 @@ { "shell_command": ["tail -F /var/log/syslog"], "start_directory": "/var/log", - } + }, ], }, {"options": {"automatic_rename": True}, "panes": [{"shell_command": ["htop"]}]}, diff --git a/tests/fixtures/workspace/shell_command_before.py b/tests/fixtures/workspace/shell_command_before.py new file mode 100644 index 0000000000..53f8e1587f --- /dev/null +++ b/tests/fixtures/workspace/shell_command_before.py @@ -0,0 +1,168 @@ +"""Test fixture for tmuxp to demonstrate shell_command_before.""" + +from __future__ import annotations + +import pathlib +import typing as t + +config_unexpanded = { # shell_command_before is string in some areas + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": "~", + "shell_command_before": "source .venv/bin/activate", + "panes": [ + {"shell_command": ["vim"]}, + { + "shell_command_before": ["rbenv local 2.0.0-p0"], + "shell_command": ['cowsay "hey"'], + }, + ], + "layout": "main-vertical", + }, + { + "shell_command_before": "rbenv local 2.0.0-p0", + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}, {}], + }, + { + "window_name": "shufu", + "panes": [ + { + "shell_command_before": ["rbenv local 2.0.0-p0"], + "shell_command": ["htop"], + }, + ], + }, + {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, + {"panes": ["top"]}, + ], +} + + +def config_expanded() -> dict[str, t.Any]: + """Return expanded configuration for shell_command_before example.""" + return { # shell_command_before is string in some areas + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": str(pathlib.Path().home()), + "shell_command_before": { + "shell_command": [{"cmd": "source .venv/bin/activate"}], + }, + "panes": [ + {"shell_command": [{"cmd": "vim"}]}, + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "shell_command": [{"cmd": 'cowsay "hey"'}], + }, + ], + "layout": "main-vertical", + }, + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "window_name": "logging", + "panes": [ + {"shell_command": [{"cmd": "tail -F /var/log/syslog"}]}, + {"shell_command": []}, + ], + }, + { + "window_name": "shufu", + "panes": [ + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "shell_command": [{"cmd": "htop"}], + }, + ], + }, + { + "options": {"automatic-rename": True}, + "panes": [{"shell_command": [{"cmd": "htop"}]}], + }, + {"panes": [{"shell_command": [{"cmd": "top"}]}]}, + ], + } + + +def config_after() -> dict[str, t.Any]: + """Return expected configuration for shell_command_before example.""" + return { # shell_command_before is string in some areas + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": str(pathlib.Path().home()), + "shell_command_before": { + "shell_command": [{"cmd": "source .venv/bin/activate"}], + }, + "panes": [ + { + "shell_command": [ + {"cmd": "source .venv/bin/activate"}, + {"cmd": "vim"}, + ], + }, + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "shell_command": [ + {"cmd": "source .venv/bin/activate"}, + {"cmd": "rbenv local 2.0.0-p0"}, + {"cmd": 'cowsay "hey"'}, + ], + }, + ], + "layout": "main-vertical", + }, + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "start_directory": "/", + "window_name": "logging", + "panes": [ + { + "shell_command": [ + {"cmd": "rbenv local 2.0.0-p0"}, + {"cmd": "tail -F /var/log/syslog"}, + ], + }, + {"shell_command": [{"cmd": "rbenv local 2.0.0-p0"}]}, + ], + }, + { + "start_directory": "/", + "window_name": "shufu", + "panes": [ + { + "shell_command_before": { + "shell_command": [{"cmd": "rbenv local 2.0.0-p0"}], + }, + "shell_command": [ + {"cmd": "rbenv local 2.0.0-p0"}, + {"cmd": "htop"}, + ], + }, + ], + }, + { + "start_directory": "/", + "options": {"automatic-rename": True}, + "panes": [{"shell_command": [{"cmd": "htop"}]}], + }, + {"start_directory": "/", "panes": [{"shell_command": [{"cmd": "top"}]}]}, + ], + } diff --git a/tests/fixtures/config/shell_command_before_session-expected.yaml b/tests/fixtures/workspace/shell_command_before_session-expected.yaml similarity index 100% rename from tests/fixtures/config/shell_command_before_session-expected.yaml rename to tests/fixtures/workspace/shell_command_before_session-expected.yaml diff --git a/tests/fixtures/workspace/shell_command_before_session.py b/tests/fixtures/workspace/shell_command_before_session.py new file mode 100644 index 0000000000..adfc86f183 --- /dev/null +++ b/tests/fixtures/workspace/shell_command_before_session.py @@ -0,0 +1,10 @@ +"""Tests shell_command_before configuration.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +before = test_utils.read_workspace_file("workspace/shell_command_before_session.yaml") +expected = test_utils.read_workspace_file( + "workspace/shell_command_before_session-expected.yaml", +) diff --git a/tests/fixtures/config/shell_command_before_session.yaml b/tests/fixtures/workspace/shell_command_before_session.yaml similarity index 100% rename from tests/fixtures/config/shell_command_before_session.yaml rename to tests/fixtures/workspace/shell_command_before_session.yaml diff --git a/tests/fixtures/config/trickle.py b/tests/fixtures/workspace/trickle.py similarity index 81% rename from tests/fixtures/config/trickle.py rename to tests/fixtures/workspace/trickle.py index 595ee56360..a1012be647 100644 --- a/tests/fixtures/config/trickle.py +++ b/tests/fixtures/workspace/trickle.py @@ -1,5 +1,9 @@ +"""Test data for tmuxp workspace fixture to demo object tree inheritance.""" + +from __future__ import annotations + before = { # shell_command_before is string in some areas - "session_name": "sampleconfig", + "session_name": "sample workspace", "start_directory": "/var", "windows": [ { @@ -9,7 +13,7 @@ {"shell_command": [{"cmd": "vim"}]}, {"shell_command": [{"cmd": 'cowsay "hey"'}]}, ], - "layout": "main-verticle", + "layout": "main-vertical", }, { "window_name": "logging", @@ -23,7 +27,7 @@ } expected = { # shell_command_before is string in some areas - "session_name": "sampleconfig", + "session_name": "sample workspace", "start_directory": "/var", "windows": [ { @@ -33,7 +37,7 @@ {"shell_command": [{"cmd": "vim"}]}, {"shell_command": [{"cmd": 'cowsay "hey"'}]}, ], - "layout": "main-verticle", + "layout": "main-vertical", }, { "start_directory": "~", diff --git a/tests/fixtures/workspace_builders/__init__.py b/tests/fixtures/workspace_builders/__init__.py new file mode 100644 index 0000000000..4599a8613e --- /dev/null +++ b/tests/fixtures/workspace_builders/__init__.py @@ -0,0 +1,3 @@ +"""Custom workspace builder fixtures for resolution tests.""" + +from __future__ import annotations diff --git a/tests/fixtures/workspace_builders/invalid.py b/tests/fixtures/workspace_builders/invalid.py new file mode 100644 index 0000000000..020839315f --- /dev/null +++ b/tests/fixtures/workspace_builders/invalid.py @@ -0,0 +1,19 @@ +"""Objects that are not valid workspace builders, used by resolution tests.""" + +from __future__ import annotations + +import typing as t + + +class NotABuilder: + """A class missing the required ``build`` method.""" + + +class MissingPluginsBuilder: + """A builder whose constructor omits the ``plugins`` parameter.""" + + def __init__(self, session_config: t.Any, server: t.Any) -> None: + self.session_config = session_config + + def build(self, session: t.Any = None, append: bool = False) -> None: + """No-op build for validation tests.""" diff --git a/tests/fixtures/workspace_builders/valid.py b/tests/fixtures/workspace_builders/valid.py new file mode 100644 index 0000000000..bb3fb154f9 --- /dev/null +++ b/tests/fixtures/workspace_builders/valid.py @@ -0,0 +1,9 @@ +"""A minimal valid custom workspace builder used by resolution tests.""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class CustomBuilder(ClassicWorkspaceBuilder): + """Trivial subclass that satisfies the workspace builder contract.""" diff --git a/tests/fixtures/workspacebuilder/environment_vars.yaml b/tests/fixtures/workspacebuilder/environment_vars.yaml deleted file mode 100644 index 1a3e6dddc4..0000000000 --- a/tests/fixtures/workspacebuilder/environment_vars.yaml +++ /dev/null @@ -1,9 +0,0 @@ -session_name: test env vars -start_directory: '~' -environment: - FOO: BAR - PATH: /tmp -windows: -- window_name: editor - panes: - - pane diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 2be26d0e23..0000000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,1337 +0,0 @@ -"""Test for tmuxp command line interface.""" -import json -import os -import pathlib -from unittest.mock import MagicMock - -import pytest - -import click -import kaptan -from click.testing import CliRunner - -import libtmux -from libtmux.common import has_lt_version -from libtmux.exc import LibTmuxException -from tmuxp import cli, config, exc -from tmuxp.cli.debug_info import command_debug_info -from tmuxp.cli.import_config import get_teamocil_dir, get_tmuxinator_dir -from tmuxp.cli.load import ( - _load_append_windows_to_current_session, - _load_attached, - _reattach, - load_plugins, - load_workspace, -) -from tmuxp.cli.ls import command_ls -from tmuxp.cli.utils import ( - ConfigPath, - _validate_choices, - get_abs_path, - get_config_dir, - is_pure_name, - scan_config, -) -from tmuxp.workspacebuilder import WorkspaceBuilder - -from .constants import FIXTURE_PATH -from .fixtures import utils as test_utils - - -def test_creates_config_dir_not_exists(tmp_path: pathlib.Path): - """cli.startup() creates config dir if not exists.""" - - cli.startup(tmp_path) - assert os.path.exists(tmp_path) - - -def test_in_dir_from_config_dir(tmp_path: pathlib.Path): - """config.in_dir() finds configs config dir.""" - - cli.startup(tmp_path) - yaml_config = tmp_path / "myconfig.yaml" - yaml_config.touch() - json_config = tmp_path / "myconfig.json" - json_config.touch() - configs_found = config.in_dir(tmp_path) - - assert len(configs_found) == 2 - - -def test_ignore_non_configs_from_current_dir(tmp_path: pathlib.Path): - """cli.in_dir() ignore non-config from config dir.""" - - cli.startup(tmp_path) - - junk_config = tmp_path / "myconfig.psd" - junk_config.touch() - conf = tmp_path / "watmyconfig.json" - conf.touch() - configs_found = config.in_dir(tmp_path) - assert len(configs_found) == 1 - - -def test_get_configs_cwd(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): - """config.in_cwd() find config in shell current working directory.""" - - confdir = tmp_path / "tmuxpconf2" - confdir.mkdir() - - monkeypatch.chdir(confdir) - config1 = open(".tmuxp.json", "w+b") - config1.close() - - configs_found = config.in_cwd() - assert len(configs_found) == 1 - assert ".tmuxp.json" in configs_found - - -@pytest.mark.parametrize( - "path,expect", - [ - (".", False), - ("./", False), - ("", False), - (".tmuxp.yaml", False), - ("../.tmuxp.yaml", False), - ("../", False), - ("/hello/world", False), - ("~/.tmuxp/hey", False), - ("~/work/c/tmux/", False), - ("~/work/c/tmux/.tmuxp.yaml", False), - ("myproject", True), - ], -) -def test_is_pure_name(path, expect): - assert is_pure_name(path) == expect - - -""" - scans for .tmuxp.{yaml,yml,json} in directory, returns first result - log warning if multiple found: - - - current directory: ., ./, noarg - - relative to cwd directory: ../, ./hello/, hello/, ./hello/ - - absolute directory: /path/to/dir, /path/to/dir/, ~/ - - no path, no ext, config_dir: projectname, tmuxp - - load file directly - - - - no directory (cwd): .tmuxp.yaml - - relative to cwd: ../.tmuxp.yaml, ./hello/.tmuxp.yaml - - absolute path: /path/to/file.yaml, ~/path/to/file/.tmuxp.yaml - - Any case where file is not found should return error. -""" - - -@pytest.fixture -def homedir(tmp_path: pathlib.Path): - home = tmp_path / "home" - home.mkdir() - return home - - -@pytest.fixture -def configdir(homedir): - conf = homedir / ".tmuxp" - conf.mkdir() - return conf - - -@pytest.fixture -def projectdir(homedir): - proj = homedir / "work" / "project" - proj.mkdir(parents=True) - return proj - - -def test_tmuxp_configdir_env_var(tmp_path: pathlib.Path, monkeypatch): - monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) - - assert get_config_dir() == str(tmp_path) - - -def test_tmuxp_configdir_xdg_config_dir(tmp_path: pathlib.Path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - tmux_dir = tmp_path / "tmuxp" - tmux_dir.mkdir() - - assert get_config_dir() == str(tmux_dir) - - -def test_resolve_dot( - tmp_path: pathlib.Path, - homedir: pathlib.Path, - configdir: pathlib.Path, - projectdir: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -): - monkeypatch.setenv("HOME", str(homedir)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(homedir / ".config")) - - tmuxp_conf_path = projectdir / ".tmuxp.yaml" - tmuxp_conf_path.touch() - user_config_name = "myconfig" - user_config = configdir / f"{user_config_name}.yaml" - user_config.touch() - - project_config = tmuxp_conf_path - - monkeypatch.chdir(projectdir) - - expect = str(project_config) - assert scan_config(".") == expect - assert scan_config("./") == expect - assert scan_config("") == expect - assert scan_config("../project") == expect - assert scan_config("../project/") == expect - assert scan_config(".tmuxp.yaml") == expect - assert scan_config("../../.tmuxp/%s.yaml" % user_config_name) == str(user_config) - assert scan_config("myconfig") == str(user_config) - assert scan_config("~/.tmuxp/myconfig.yaml") == str(user_config) - - with pytest.raises(Exception): - scan_config(".tmuxp.json") - with pytest.raises(Exception): - scan_config(".tmuxp.ini") - with pytest.raises(Exception): - scan_config("../") - with pytest.raises(Exception): - scan_config("mooooooo") - - monkeypatch.chdir(homedir) - - expect = str(project_config) - assert scan_config("work/project") == expect - assert scan_config("work/project/") == expect - assert scan_config("./work/project") == expect - assert scan_config("./work/project/") == expect - assert scan_config(".tmuxp/%s.yaml" % user_config_name) == str(user_config) - assert scan_config("./.tmuxp/%s.yaml" % user_config_name) == str(user_config) - assert scan_config("myconfig") == str(user_config) - assert scan_config("~/.tmuxp/myconfig.yaml") == str(user_config) - - with pytest.raises(Exception): - scan_config("") - with pytest.raises(Exception): - scan_config(".") - with pytest.raises(Exception): - scan_config(".tmuxp.yaml") - with pytest.raises(Exception): - scan_config("../") - with pytest.raises(Exception): - scan_config("mooooooo") - - monkeypatch.chdir(configdir) - - expect = str(project_config) - assert scan_config("../work/project") == expect - assert scan_config("../../home/work/project") == expect - assert scan_config("../work/project/") == expect - assert scan_config("%s.yaml" % user_config_name) == str(user_config) - assert scan_config("./%s.yaml" % user_config_name) == str(user_config) - assert scan_config("myconfig") == str(user_config) - assert scan_config("~/.tmuxp/myconfig.yaml") == str(user_config) - - with pytest.raises(Exception): - scan_config("") - with pytest.raises(Exception): - scan_config(".") - with pytest.raises(Exception): - scan_config(".tmuxp.yaml") - with pytest.raises(Exception): - scan_config("../") - with pytest.raises(Exception): - scan_config("mooooooo") - - monkeypatch.chdir(tmp_path) - - expect = str(project_config) - assert scan_config("home/work/project") == expect - assert scan_config("./home/work/project/") == expect - assert scan_config("home/.tmuxp/%s.yaml" % user_config_name) == str(user_config) - assert scan_config("./home/.tmuxp/%s.yaml" % user_config_name) == str(user_config) - assert scan_config("myconfig") == str(user_config) - assert scan_config("~/.tmuxp/myconfig.yaml") == str(user_config) - - with pytest.raises(Exception): - scan_config("") - with pytest.raises(Exception): - scan_config(".") - with pytest.raises(Exception): - scan_config(".tmuxp.yaml") - with pytest.raises(Exception): - scan_config("../") - with pytest.raises(Exception): - scan_config("mooooooo") - - -def test_scan_config_arg( - homedir, configdir, projectdir, monkeypatch: pytest.MonkeyPatch -): - runner = CliRunner() - - @click.command() - @click.argument("config", type=ConfigPath(exists=True), nargs=-1) - def config_cmd(config): - click.echo(config) - - monkeypatch.setenv("HOME", str(homedir)) - tmuxp_config_path = projectdir / ".tmuxp.yaml" - tmuxp_config_path.touch() - user_config_name = "myconfig" - user_config = configdir / f"{user_config_name}.yaml" - user_config.touch() - - project_config = projectdir / ".tmuxp.yaml" - - def check_cmd(config_arg): - return runner.invoke(config_cmd, [config_arg]).output - - monkeypatch.chdir(projectdir) - expect = str(project_config) - assert expect in check_cmd(".") - assert expect in check_cmd("./") - assert expect in check_cmd("") - assert expect in check_cmd("../project") - assert expect in check_cmd("../project/") - assert expect in check_cmd(".tmuxp.yaml") - assert str(user_config) in check_cmd("../../.tmuxp/%s.yaml" % user_config_name) - assert user_config.stem in check_cmd("myconfig") - assert str(user_config) in check_cmd("~/.tmuxp/myconfig.yaml") - - assert "file not found" in check_cmd(".tmuxp.json") - assert "file not found" in check_cmd(".tmuxp.ini") - assert "No tmuxp files found" in check_cmd("../") - assert "config not found in config dir" in check_cmd("moo") - - -def test_load_workspace(server, monkeypatch): - # this is an implementation test. Since this testsuite may be ran within - # a tmux session by the developer himself, delete the TMUX variable - # temporarily. - monkeypatch.delenv("TMUX", raising=False) - session_file = FIXTURE_PATH / "workspacebuilder" / "two_pane.yaml" - - # open it detached - session = load_workspace( - session_file, socket_name=server.socket_name, detached=True - ) - - assert isinstance(session, libtmux.Session) - assert session.name == "sampleconfig" - - -def test_load_workspace_named_session(server, monkeypatch): - # this is an implementation test. Since this testsuite may be ran within - # a tmux session by the developer himself, delete the TMUX variable - # temporarily. - monkeypatch.delenv("TMUX", raising=False) - session_file = FIXTURE_PATH / "workspacebuilder" / "two_pane.yaml" - - # open it detached - session = load_workspace( - session_file, - socket_name=server.socket_name, - new_session_name="tmuxp-new", - detached=True, - ) - - assert isinstance(session, libtmux.Session) - assert session.name == "tmuxp-new" - - -@pytest.mark.skipif( - has_lt_version("2.1"), reason="exact session name matches only tmux >= 2.1" -) -def test_load_workspace_name_match_regression_252( - tmp_path: pathlib.Path, server, monkeypatch -): - monkeypatch.delenv("TMUX", raising=False) - session_file = FIXTURE_PATH / "workspacebuilder" / "two_pane.yaml" - - # open it detached - session = load_workspace( - session_file, socket_name=server.socket_name, detached=True - ) - - assert isinstance(session, libtmux.Session) - assert session.name == "sampleconfig" - - projfile = tmp_path / "simple.yaml" - - projfile.write_text( - """ -session_name: sampleconfi -start_directory: './' -windows: -- panes: - - echo 'hey'""", - encoding="utf-8", - ) - - # open it detached - session = load_workspace( - str(projfile), socket_name=server.socket_name, detached=True - ) - assert session.name == "sampleconfi" - - -def test_load_symlinked_workspace(server, tmp_path, monkeypatch): - # this is an implementation test. Since this testsuite may be ran within - # a tmux session by the developer himself, delete the TMUX variable - # temporarily. - monkeypatch.delenv("TMUX", raising=False) - - realtemp = tmp_path / "myrealtemp" - realtemp.mkdir() - linktemp = tmp_path / "symlinktemp" - linktemp.symlink_to(realtemp) - projfile = linktemp / "simple.yaml" - - projfile.write_text( - """ -session_name: samplesimple -start_directory: './' -windows: -- panes: - - echo 'hey'""", - encoding="utf-8", - ) - - # open it detached - session = load_workspace( - str(projfile), socket_name=server.socket_name, detached=True - ) - pane = session.attached_window.attached_pane - - assert isinstance(session, libtmux.Session) - assert session.name == "samplesimple" - assert pane.current_path == str(realtemp) - - -def test_regression_00132_session_name_with_dots( - tmp_path: pathlib.Path, server, session -): - yaml_config = FIXTURE_PATH / "workspacebuilder" / "regression_00132_dots.yaml" - cli_args = [str(yaml_config)] - inputs = [] - runner = CliRunner() - result = runner.invoke( - cli.command_load, cli_args, input="".join(inputs), standalone_mode=False - ) - assert result.exception - assert isinstance(result.exception, libtmux.exc.BadSessionName) - - -@pytest.mark.parametrize("cli_args", [(["load", "."]), (["load", ".tmuxp.yaml"])]) -def test_load_zsh_autotitle_warning(cli_args, tmp_path, monkeypatch): - # create dummy tmuxp yaml so we don't get yelled at - yaml_config = tmp_path / ".tmuxp.yaml" - yaml_config.touch() - oh_my_zsh_path = tmp_path / ".oh-my-zsh" - oh_my_zsh_path.mkdir() - monkeypatch.setenv("HOME", str(tmp_path)) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - - monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) - monkeypatch.setenv("SHELL", "zsh") - result = runner.invoke(cli.cli, cli_args) - assert "Please set" in result.output - - monkeypatch.setenv("DISABLE_AUTO_TITLE", "false") - result = runner.invoke(cli.cli, cli_args) - assert "Please set" in result.output - - monkeypatch.setenv("DISABLE_AUTO_TITLE", "true") - result = runner.invoke(cli.cli, cli_args) - assert "Please set" not in result.output - - monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) - monkeypatch.setenv("SHELL", "sh") - result = runner.invoke(cli.cli, cli_args) - assert "Please set" not in result.output - - -@pytest.mark.parametrize( - "cli_args", - [ - (["load", ".", "--log-file", "log.txt"]), - ], -) -def test_load_log_file(cli_args, tmp_path, monkeypatch): - # create dummy tmuxp yaml that breaks to prevent actually loading tmux - tmuxp_config_path = tmp_path / ".tmuxp.yaml" - tmuxp_config_path.write_text( - """ -session_name: hello - """, - encoding="utf-8", - ) - oh_my_zsh_path = tmp_path / ".oh-my-zsh" - oh_my_zsh_path.mkdir() - monkeypatch.setenv("HOME", str(tmp_path)) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - - # If autoconfirm (-y) no need to prompt y - input_args = "y\ny\n" if "-y" not in cli_args else "" - - result = runner.invoke(cli.cli, cli_args, input=input_args) - log_file_path = tmp_path / "log.txt" - assert "Loading" in log_file_path.open().read() - assert result is not None - - -@pytest.mark.parametrize("cli_cmd", ["shell", ("shell", "--pdb")]) -@pytest.mark.parametrize( - "cli_args,inputs,env,expected_output", - [ - ( - ["-L{SOCKET_NAME}", "-c", "print(str(server.socket_name))"], - [], - {}, - "{SERVER_SOCKET_NAME}", - ), - ( - [ - "-L{SOCKET_NAME}", - "{SESSION_NAME}", - "-c", - "print(session.name)", - ], - [], - {}, - "{SESSION_NAME}", - ), - ( - [ - "-L{SOCKET_NAME}", - "{SESSION_NAME}", - "{WINDOW_NAME}", - "-c", - "print(server.has_session(session.name))", - ], - [], - {}, - "True", - ), - ( - [ - "-L{SOCKET_NAME}", - "{SESSION_NAME}", - "{WINDOW_NAME}", - "-c", - "print(window.name)", - ], - [], - {}, - "{WINDOW_NAME}", - ), - ( - [ - "-L{SOCKET_NAME}", - "{SESSION_NAME}", - "{WINDOW_NAME}", - "-c", - "print(pane.id)", - ], - [], - {}, - "{PANE_ID}", - ), - ( - [ - "-L{SOCKET_NAME}", - "-c", - "print(pane.id)", - ], - [], - {"TMUX_PANE": "{PANE_ID}"}, - "{PANE_ID}", - ), - ], -) -def test_shell( - cli_cmd, - cli_args, - inputs, - expected_output, - env, - tmp_path, - monkeypatch, - server, - session, -): - monkeypatch.setenv("HOME", str(tmp_path)) - window_name = "my_window" - window = session.new_window(window_name=window_name) - window.split_window() - - template_ctx = dict( - SOCKET_NAME=server.socket_name, - SOCKET_PATH=server.socket_path, - SESSION_NAME=session.name, - WINDOW_NAME=window_name, - PANE_ID=window.attached_pane.id, - SERVER_SOCKET_NAME=server.socket_name, - ) - - cli_cmd = list(cli_cmd) if isinstance(cli_cmd, (list, tuple)) else [cli_cmd] - cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] - - for k, v in env.items(): - monkeypatch.setenv(k, v.format(**template_ctx)) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - - result = runner.invoke( - cli.cli, cli_args, input="".join(inputs), catch_exceptions=False - ) - assert expected_output.format(**template_ctx) in result.output - - -@pytest.mark.parametrize( - "cli_cmd", - [ - "shell", - ("shell", "--pdb"), - ], -) -@pytest.mark.parametrize( - "cli_args,inputs,env,template_ctx,exception,message", - [ - ( - ["-LDoesNotExist", "-c", "print(str(server.socket_name))"], - [], - {}, - {}, - LibTmuxException, - r".*DoesNotExist.*", - ), - ( - [ - "-L{SOCKET_NAME}", - "nonexistant_session", - "-c", - "print(str(server.socket_name))", - ], - [], - {}, - {"session_name": "nonexistant_session"}, - exc.TmuxpException, - "Session not found: nonexistant_session", - ), - ( - [ - "-L{SOCKET_NAME}", - "{SESSION_NAME}", - "nonexistant_window", - "-c", - "print(str(server.socket_name))", - ], - [], - {}, - {"window_name": "nonexistant_window"}, - exc.TmuxpException, - "Window not found: {WINDOW_NAME}", - ), - ], -) -def test_shell_target_missing( - cli_cmd, - cli_args, - inputs, - env, - template_ctx, - exception, - message, - tmp_path, - monkeypatch, - socket_name, - server, - session, -): - monkeypatch.setenv("HOME", str(tmp_path)) - window_name = "my_window" - window = session.new_window(window_name=window_name) - window.split_window() - - template_ctx = dict( - SOCKET_NAME=server.socket_name, - SOCKET_PATH=server.socket_path, - SESSION_NAME=session.name, - WINDOW_NAME=template_ctx.get("window_name", window_name), - PANE_ID=template_ctx.get("pane_id"), - SERVER_SOCKET_NAME=server.socket_name, - ) - cli_cmd = list(cli_cmd) if isinstance(cli_cmd, (list, tuple)) else [cli_cmd] - cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] - - for k, v in env.items(): - monkeypatch.setenv(k, v.format(**template_ctx)) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - - if exception is not None: - with pytest.raises(exception, match=message.format(**template_ctx)): - result = runner.invoke( - cli.cli, cli_args, input="".join(inputs), catch_exceptions=False - ) - else: - result = runner.invoke( - cli.cli, cli_args, input="".join(inputs), catch_exceptions=False - ) - assert message.format(**template_ctx) in result.output - - -@pytest.mark.parametrize( - "cli_cmd", - [ - # 'shell', - # ('shell', '--pdb'), - ("shell", "--code"), - # ('shell', '--bpython'), - # ('shell', '--ptipython'), - # ('shell', '--ptpython'), - # ('shell', '--ipython'), - ], -) -@pytest.mark.parametrize( - "cli_args,inputs,env,message", - [ - ( - [ - "-L{SOCKET_NAME}", - ], - [], - {}, - "(InteractiveConsole)", - ), - ( - [ - "-L{SOCKET_NAME}", - ], - [], - {"PANE_ID": "{PANE_ID}"}, - "(InteractiveConsole)", - ), - ], -) -def test_shell_plus( - cli_cmd, - cli_args, - inputs, - env, - message, - tmp_path, - monkeypatch, - server, - session, -): - monkeypatch.setenv("HOME", str(tmp_path)) - window_name = "my_window" - window = session.new_window(window_name=window_name) - window.split_window() - - template_ctx = dict( - SOCKET_NAME=server.socket_name, - SOCKET_PATH=server.socket_path, - SESSION_NAME=session.name, - WINDOW_NAME=window_name, - PANE_ID=window.attached_pane.id, - SERVER_SOCKET_NAME=server.socket_name, - ) - - cli_cmd = list(cli_cmd) if isinstance(cli_cmd, (list, tuple)) else [cli_cmd] - cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] - - for k, v in env.items(): - monkeypatch.setenv(k, v.format(**template_ctx)) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - - result = runner.invoke( - cli.cli, cli_args, input="".join(inputs), catch_exceptions=True - ) - assert message.format(**template_ctx) in result.output - - -@pytest.mark.parametrize( - "cli_args", - [ - (["convert", "."]), - (["convert", ".tmuxp.yaml"]), - (["convert", ".tmuxp.yaml", "-y"]), - (["convert", ".tmuxp.yml"]), - (["convert", ".tmuxp.yml", "-y"]), - ], -) -def test_convert(cli_args, tmp_path, monkeypatch): - # create dummy tmuxp yaml so we don't get yelled at - filename = cli_args[1] - if filename == ".": - filename = ".tmuxp.yaml" - file_ext = filename.rsplit(".", 1)[-1] - assert file_ext in ["yaml", "yml"], file_ext - config_file_path = tmp_path / filename - config_file_path.write_text("\nsession_name: hello\n", encoding="utf-8") - oh_my_zsh_path = tmp_path / ".oh-my-zsh" - oh_my_zsh_path.mkdir() - monkeypatch.setenv("HOME", str(tmp_path)) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - - # If autoconfirm (-y) no need to prompt y - input_args = "y\ny\n" if "-y" not in cli_args else "" - - runner.invoke(cli.cli, cli_args, input=input_args) - tmuxp_json = tmp_path / ".tmuxp.json" - assert tmuxp_json.exists() - assert tmuxp_json.open().read() == json.dumps({"session_name": "hello"}, indent=2) - - -@pytest.mark.parametrize( - "cli_args", - [ - (["convert", "."]), - (["convert", ".tmuxp.json"]), - (["convert", ".tmuxp.json", "-y"]), - ], -) -def test_convert_json(cli_args, tmp_path, monkeypatch): - # create dummy tmuxp yaml so we don't get yelled at - json_config = tmp_path / ".tmuxp.json" - json_config.write_text('{"session_name": "hello"}', encoding="utf-8") - oh_my_zsh_path = tmp_path / ".oh-my-zsh" - oh_my_zsh_path.mkdir() - monkeypatch.setenv("HOME", str(tmp_path)) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - - # If autoconfirm (-y) no need to prompt y - input_args = "y\ny\n" if "-y" not in cli_args else "" - - runner.invoke(cli.cli, cli_args, input=input_args) - tmuxp_yaml = tmp_path / ".tmuxp.yaml" - assert tmuxp_yaml.exists() - assert tmuxp_yaml.open().read() == "session_name: hello\n" - - -@pytest.mark.parametrize("cli_args", [(["import"])]) -def test_import(cli_args, monkeypatch): - runner = CliRunner() - - result = runner.invoke(cli.cli, cli_args) - assert "tmuxinator" in result.output - assert "teamocil" in result.output - - -@pytest.mark.parametrize( - "cli_args", - [ - (["--help"]), - (["-h"]), - ], -) -def test_help(cli_args, monkeypatch): - runner = CliRunner() - - result = runner.invoke(cli.cli, cli_args) - assert "Usage: cli [OPTIONS] COMMAND [ARGS]..." in result.output - - -@pytest.mark.parametrize( - "cli_args,inputs", - [ - ( - ["import", "teamocil", "./.teamocil/config.yaml"], - ["\n", "y\n", "./la.yaml\n", "y\n"], - ), - ( - ["import", "teamocil", "./.teamocil/config.yaml"], - ["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], - ), - ( - ["import", "teamocil", "config"], - ["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], - ), - ], -) -def test_import_teamocil(cli_args, inputs, tmp_path, monkeypatch): - teamocil_config = test_utils.read_config_file("config_teamocil/test4.yaml") - - teamocil_path = tmp_path / ".teamocil" - teamocil_path.mkdir() - - teamocil_config_path = teamocil_path / "config.yaml" - teamocil_config_path.write_text(teamocil_config, encoding="utf-8") - - exists_yaml = tmp_path / "exists.yaml" - exists_yaml.touch() - - monkeypatch.setenv("HOME", str(tmp_path)) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - runner.invoke(cli.cli, cli_args, input="".join(inputs)) - - new_config_yaml = tmp_path / "la.yaml" - assert new_config_yaml.exists() - - -@pytest.mark.parametrize( - "cli_args,inputs", - [ - ( - ["import", "tmuxinator", "./.tmuxinator/config.yaml"], - ["\n", "y\n", "./la.yaml\n", "y\n"], - ), - ( - ["import", "tmuxinator", "./.tmuxinator/config.yaml"], - ["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], - ), - ( - ["import", "tmuxinator", "config"], - ["\n", "y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], - ), - ], -) -def test_import_tmuxinator(cli_args, inputs, tmp_path, monkeypatch): - tmuxinator_config = test_utils.read_config_file("config_tmuxinator/test3.yaml") - - tmuxinator_path = tmp_path / ".tmuxinator" - tmuxinator_path.mkdir() - - tmuxinator_config_path = tmuxinator_path / "config.yaml" - tmuxinator_config_path.write_text(tmuxinator_config, encoding="utf-8") - - exists_yaml = tmp_path / "exists.yaml" - exists_yaml.touch() - - monkeypatch.setenv("HOME", str(tmp_path)) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - out = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - print(out.output) - new_config_yaml = tmp_path / "la.yaml" - assert new_config_yaml.exists() - - -@pytest.mark.parametrize( - "cli_args,inputs", - [ - (["freeze", "myfrozensession"], ["y\n", "./la.yaml\n", "y\n"]), - ( # Exists - ["freeze", "myfrozensession"], - ["y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"], - ), - ( # Imply current session if not entered - ["freeze"], - ["y\n", "./la.yaml\n", "y\n"], - ), - (["freeze"], ["y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"]), # Exists - ], -) -def test_freeze(server, cli_args, inputs, tmp_path, monkeypatch): - monkeypatch.setenv("HOME", str(tmp_path)) - exists_yaml = tmp_path / "exists.yaml" - exists_yaml.touch() - - server.new_session(session_name="myfirstsession") - server.new_session(session_name="myfrozensession") - - # Assign an active pane to the session - second_session = server.list_sessions()[1] - first_pane_on_second_session_id = second_session.list_windows()[0].list_panes()[0][ - "pane_id" - ] - monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - # Use tmux server (socket name) used in the test - cli_args = cli_args + ["-L", server.socket_name] - out = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - print(out.output) - - yaml_config_path = tmp_path / "la.yaml" - assert yaml_config_path.exists() - - yaml_config = yaml_config_path.open().read() - frozen_config = kaptan.Kaptan(handler="yaml").import_config(yaml_config).get() - - assert frozen_config["session_name"] == "myfrozensession" - - -@pytest.mark.parametrize( - "cli_args,inputs", - [ - ( # Overwrite - ["freeze", "mysession", "--force"], - ["\n", "y\n", "./exists.yaml\n", "y\n"], - ), - ( # Imply current session if not entered - ["freeze", "--force"], - ["\n", "y\n", "./exists.yaml\n", "y\n"], - ), - ], -) -def test_freeze_overwrite(server, cli_args, inputs, tmp_path, monkeypatch): - monkeypatch.setenv("HOME", str(tmp_path)) - exists_yaml = tmp_path / "exists.yaml" - exists_yaml.touch() - - server.new_session(session_name="mysession") - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - # Use tmux server (socket name) used in the test - cli_args = cli_args + ["-L", server.socket_name] - out = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - print(out.output) - - yaml_config_path = tmp_path / "exists.yaml" - assert yaml_config_path.exists() - - -def test_get_abs_path(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): - expect = str(tmp_path) - monkeypatch.chdir(tmp_path) - get_abs_path("../") == os.path.dirname(expect) - get_abs_path(".") == expect - get_abs_path("./") == expect - get_abs_path(expect) == expect - - -def test_get_tmuxinator_dir(monkeypatch): - assert get_tmuxinator_dir() == os.path.expanduser("~/.tmuxinator/") - - monkeypatch.setenv("HOME", "/moo") - assert get_tmuxinator_dir() == "/moo/.tmuxinator/" - assert get_tmuxinator_dir() == os.path.expanduser("~/.tmuxinator/") - - -def test_get_teamocil_dir(monkeypatch: pytest.MonkeyPatch): - assert get_teamocil_dir() == os.path.expanduser("~/.teamocil/") - - monkeypatch.setenv("HOME", "/moo") - assert get_teamocil_dir() == "/moo/.teamocil/" - assert get_teamocil_dir() == os.path.expanduser("~/.teamocil/") - - -def test_validate_choices(): - validate = _validate_choices(["choice1", "choice2"]) - - assert validate("choice1") - assert validate("choice2") - - with pytest.raises(click.BadParameter): - assert validate("choice3") - - -def test_pass_config_dir_ClickPath( - tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch -): - - configdir = tmp_path / "myconfigdir" - configdir.mkdir() - user_config_name = "myconfig" - user_config = configdir / f"{user_config_name}.yaml" - user_config.touch() - - expect = str(user_config) - - runner = CliRunner() - - @click.command() - @click.argument( - "config", - type=ConfigPath(exists=True, config_dir=(str(configdir))), - nargs=-1, - ) - def config_cmd(config): - click.echo(config) - - def check_cmd(config_arg): - return runner.invoke(config_cmd, [config_arg]).output - - monkeypatch.chdir(configdir) - - assert expect in check_cmd("myconfig") - assert expect in check_cmd("myconfig.yaml") - assert expect in check_cmd("./myconfig.yaml") - assert str(user_config) in check_cmd(str(configdir / "myconfig.yaml")) - - assert "file not found" in check_cmd(".tmuxp.json") - - -def test_ls_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - - filenames = [ - ".git/", - ".gitignore/", - "session_1.yaml", - "session_2.yaml", - "session_3.json", - "session_4.txt", - ] - - # should ignore: - # - directories should be ignored - # - extensions not covered in VALID_CONFIG_DIR_FILE_EXTENSIONS - ignored_filenames = [".git/", ".gitignore/", "session_4.txt"] - stems = [os.path.splitext(f)[0] for f in filenames if f not in ignored_filenames] - - for filename in filenames: - location = tmp_path / f".tmuxp/{filename}" - if filename.endswith("/"): - location.mkdir(parents=True) - else: - location.touch() - - runner = CliRunner() - cli_output = runner.invoke(command_ls).output - assert cli_output == "\n".join(stems) + "\n" - - -def test_load_plugins(monkeypatch_plugin_test_packages): - from tmuxp_test_plugin_bwb.plugin import PluginBeforeWorkspaceBuilder - - plugins_config = test_utils.read_config_file("workspacebuilder/plugin_bwb.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(plugins_config).get() - sconfig = config.expand(sconfig) - - plugins = load_plugins(sconfig) - - assert len(plugins) == 1 - - test_plugin_class_types = [ - PluginBeforeWorkspaceBuilder().__class__, - ] - for plugin in plugins: - assert plugin.__class__ in test_plugin_class_types - - -@pytest.mark.skip("Not sure how to clean up the tmux session this makes") -@pytest.mark.parametrize( - "cli_args,inputs", - [ - ( - ["load", "tests/fixtures/workspacebuilder/plugin_versions_fail.yaml"], - ["y\n"], - ) - ], -) -def test_load_plugins_version_fail_skip( - monkeypatch_plugin_test_packages, cli_args, inputs -): - runner = CliRunner() - - results = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - assert "[Loading]" in results.output - - -@pytest.mark.parametrize( - "cli_args,inputs", - [ - ( - ["load", "tests/fixtures/workspacebuilder/plugin_versions_fail.yaml"], - ["n\n"], - ) - ], -) -def test_load_plugins_version_fail_no_skip( - monkeypatch_plugin_test_packages, cli_args, inputs -): - runner = CliRunner() - - results = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - assert "[Not Skipping]" in results.output - - -@pytest.mark.parametrize( - "cli_args", [(["load", "tests/fixtures/workspacebuilder/plugin_missing_fail.yaml"])] -) -def test_load_plugins_plugin_missing(monkeypatch_plugin_test_packages, cli_args): - runner = CliRunner() - - results = runner.invoke(cli.cli, cli_args) - assert "[Plugin Error]" in results.output - - -def test_plugin_system_before_script( - monkeypatch_plugin_test_packages, server, monkeypatch -): - # this is an implementation test. Since this testsuite may be ran within - # a tmux session by the developer himself, delete the TMUX variable - # temporarily. - monkeypatch.delenv("TMUX", raising=False) - session_file = FIXTURE_PATH / "workspacebuilder" / "plugin_bs.yaml" - - # open it detached - session = load_workspace( - session_file, socket_name=server.socket_name, detached=True - ) - - assert isinstance(session, libtmux.Session) - assert session.name == "plugin_test_bs" - - -def test_reattach_plugins(monkeypatch_plugin_test_packages, server): - config_plugins = test_utils.read_config_file("workspacebuilder/plugin_r.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(config_plugins).get() - sconfig = config.expand(sconfig) - - # open it detached - builder = WorkspaceBuilder( - sconf=sconfig, plugins=load_plugins(sconfig), server=server - ) - builder.build() - - try: - _reattach(builder) - except libtmux.exc.LibTmuxException: - pass - - proc = builder.session.cmd("display-message", "-p", "'#S'") - - assert proc.stdout[0] == "'plugin_test_r'" - - -def test_load_attached(server, monkeypatch): - # Load a session and attach from outside tmux - monkeypatch.delenv("TMUX", raising=False) - - attach_session_mock = MagicMock() - attach_session_mock.return_value.stderr = None - - monkeypatch.setattr("libtmux.session.Session.attach_session", attach_session_mock) - - yaml_config = test_utils.read_config_file("workspacebuilder/two_pane.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - - _load_attached(builder, False) - - assert builder.session.attach_session.call_count == 1 - - -def test_load_attached_detached(server, monkeypatch): - # Load a session but don't attach - monkeypatch.delenv("TMUX", raising=False) - - attach_session_mock = MagicMock() - attach_session_mock.return_value.stderr = None - - monkeypatch.setattr("libtmux.session.Session.attach_session", attach_session_mock) - - yaml_config = test_utils.read_config_file("workspacebuilder/two_pane.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - - _load_attached(builder, True) - - assert builder.session.attach_session.call_count == 0 - - -def test_load_attached_within_tmux(server, monkeypatch): - # Load a session and attach from within tmux - monkeypatch.setenv("TMUX", "/tmp/tmux-1234/default,123,0") - - switch_client_mock = MagicMock() - switch_client_mock.return_value.stderr = None - - monkeypatch.setattr("libtmux.session.Session.switch_client", switch_client_mock) - - yaml_config = test_utils.read_config_file("workspacebuilder/two_pane.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - - _load_attached(builder, False) - - assert builder.session.switch_client.call_count == 1 - - -def test_load_attached_within_tmux_detached(server, monkeypatch): - # Load a session and attach from within tmux - monkeypatch.setenv("TMUX", "/tmp/tmux-1234/default,123,0") - - switch_client_mock = MagicMock() - switch_client_mock.return_value.stderr = None - - monkeypatch.setattr("libtmux.session.Session.switch_client", switch_client_mock) - - yaml_config = test_utils.read_config_file("workspacebuilder/two_pane.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - - _load_attached(builder, True) - - assert builder.session.switch_client.call_count == 1 - - -def test_load_append_windows_to_current_session(server, monkeypatch): - yaml_config = test_utils.read_config_file("workspacebuilder/two_pane.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - builder.build() - - assert len(server.list_sessions()) == 1 - assert len(server._list_windows()) == 3 - - # Assign an active pane to the session - monkeypatch.setenv("TMUX_PANE", server._list_panes()[0]["pane_id"]) - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - _load_append_windows_to_current_session(builder) - - assert len(server.list_sessions()) == 1 - assert len(server._list_windows()) == 6 - - -def test_debug_info_cli(monkeypatch, tmp_path: pathlib.Path): - monkeypatch.setenv("SHELL", "/bin/bash") - - runner = CliRunner() - cli_output = runner.invoke(command_debug_info).output - assert "environment" in cli_output - assert "python version" in cli_output - assert "system PATH" in cli_output - assert "tmux version" in cli_output - assert "libtmux version" in cli_output - assert "tmuxp version" in cli_output - assert "tmux path" in cli_output - assert "tmuxp path" in cli_output - assert "shell" in cli_output - assert "tmux session" in cli_output - assert "tmux windows" in cli_output - assert "tmux panes" in cli_output - assert "tmux global options" in cli_output - assert "tmux window options" in cli_output diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index de776b35bf..0000000000 --- a/tests/test_config.py +++ /dev/null @@ -1,426 +0,0 @@ -"""Test for tmuxp configuration import, inlining, expanding and export.""" -import os -import pathlib -import typing -from typing import Union - -import pytest - -import kaptan - -from tmuxp import config, exc - -from .constants import EXAMPLE_PATH - -if typing.TYPE_CHECKING: - from .fixtures import config as ConfigFixture - - -@pytest.fixture -def config_fixture(): - """Deferred import of tmuxp.tests.fixtures.* - - pytest setup (conftest.py) patches os.environ["HOME"], delay execution of - os.path.expanduser until here. - """ - from .fixtures import config as config_fixture - - return config_fixture - - -def load_yaml(path: Union[str, pathlib.Path]) -> str: - return ( - kaptan.Kaptan(handler="yaml") - .import_config(str(path) if isinstance(path, pathlib.Path) else path) - .get() - ) - - -def load_config(path: Union[str, pathlib.Path]) -> str: - return ( - kaptan.Kaptan() - .import_config(str(path) if isinstance(path, pathlib.Path) else path) - .get() - ) - - -def test_export_json(tmp_path: pathlib.Path, config_fixture: "ConfigFixture"): - json_config_file = tmp_path / "config.json" - - configparser = kaptan.Kaptan() - configparser.import_config(config_fixture.sampleconfig.sampleconfigdict) - - json_config_data = configparser.export("json", indent=2) - - json_config_file.write_text(json_config_data, encoding="utf-8") - - new_config = kaptan.Kaptan() - new_config_data = new_config.import_config(str(json_config_file)).get() - assert config_fixture.sampleconfig.sampleconfigdict == new_config_data - - -def test_export_yaml(tmp_path: pathlib.Path, config_fixture: "ConfigFixture"): - yaml_config_file = tmp_path / "config.yaml" - - configparser = kaptan.Kaptan() - sampleconfig = config.inline(config_fixture.sampleconfig.sampleconfigdict) - configparser.import_config(sampleconfig) - - yaml_config_data = configparser.export("yaml", indent=2, default_flow_style=False) - - yaml_config_file.write_text(yaml_config_data, encoding="utf-8") - - new_config_data = load_config(str(yaml_config_file)) - assert config_fixture.sampleconfig.sampleconfigdict == new_config_data - - -def test_scan_config(tmp_path: pathlib.Path): - configs = [] - - garbage_file = tmp_path / "config.psd" - garbage_file.write_text("wat", encoding="utf-8") - - for r, d, f in os.walk(str(tmp_path)): - for filela in (x for x in f if x.endswith((".json", ".ini", "yaml"))): - configs.append(str(tmp_path / filela)) - - files = 0 - config_json = tmp_path / "config.json" - config_yaml = tmp_path / "config.yaml" - config_ini = tmp_path / "config.ini" - if config_json.exists(): - files += 1 - assert str(config_json) in configs - - if config_yaml.exists(): - files += 1 - assert str(config_yaml) in configs - - if config_ini.exists(): - files += 1 - assert str(config_ini) in configs - - assert len(configs) == files - - -def test_config_expand1(config_fixture: "ConfigFixture"): - """Expand shell commands from string to list.""" - test_config = config.expand(config_fixture.expand1.before_config) - assert test_config == config_fixture.expand1.after_config - - -def test_config_expand2(config_fixture: "ConfigFixture"): - """Expand shell commands from string to list.""" - unexpanded_dict = load_yaml(config_fixture.expand2.unexpanded_yaml) - expanded_dict = load_yaml(config_fixture.expand2.expanded_yaml) - assert config.expand(unexpanded_dict) == expanded_dict - - -"""Tests for :meth:`config.inline()`.""" - -ibefore_config = { # inline config - "session_name": "sampleconfig", - "start_directory": "~", - "windows": [ - { - "shell_command": ["top"], - "window_name": "editor", - "panes": [{"shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}], - "layout": "main-verticle", - }, - { - "window_name": "logging", - "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], - }, - {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, - ], -} - -iafter_config = { - "session_name": "sampleconfig", - "start_directory": "~", - "windows": [ - { - "shell_command": "top", - "window_name": "editor", - "panes": ["vim", 'cowsay "hey"'], - "layout": "main-verticle", - }, - {"window_name": "logging", "panes": ["tail -F /var/log/syslog"]}, - {"options": {"automatic-rename": True}, "panes": ["htop"]}, - ], -} - - -def test_inline_config(): - """:meth:`config.inline()` shell commands list to string.""" - - test_config = config.inline(ibefore_config) - assert test_config == iafter_config - - -"""Test config inheritance for the nested 'start_command'.""" - -inheritance_config_before = { - "session_name": "sampleconfig", - "start_directory": "/", - "windows": [ - { - "window_name": "editor", - "start_directory": "~", - "panes": [{"shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}], - "layout": "main-verticle", - }, - { - "window_name": "logging", - "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], - }, - {"window_name": "shufu", "panes": [{"shell_command": ["htop"]}]}, - {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, - ], -} - -inheritance_config_after = { - "session_name": "sampleconfig", - "start_directory": "/", - "windows": [ - { - "window_name": "editor", - "start_directory": "~", - "panes": [{"shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}], - "layout": "main-verticle", - }, - { - "window_name": "logging", - "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], - }, - {"window_name": "shufu", "panes": [{"shell_command": ["htop"]}]}, - {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, - ], -} - - -def test_inheritance_config(): - config = inheritance_config_before - - # TODO: Look at verifying window_start_directory - # if 'start_directory' in config: - # session_start_directory = config['start_directory'] - # else: - # session_start_directory = None - - # for windowconfitem in config['windows']: - # window_start_directory = None - # - # if 'start_directory' in windowconfitem: - # window_start_directory = windowconfitem['start_directory'] - # elif session_start_directory: - # window_start_directory = session_start_directory - # - # for paneconfitem in windowconfitem['panes']: - # if 'start_directory' in paneconfitem: - # pane_start_directory = paneconfitem['start_directory'] - # elif window_start_directory: - # paneconfitem['start_directory'] = window_start_directory - # elif session_start_directory: - # paneconfitem['start_directory'] = session_start_directory - - assert config == inheritance_config_after - - -def test_shell_command_before(config_fixture: "ConfigFixture"): - """Config inheritance for the nested 'start_command'.""" - test_config = config_fixture.shell_command_before.config_unexpanded - test_config = config.expand(test_config) - - assert test_config == config_fixture.shell_command_before.config_expanded - - test_config = config.trickle(test_config) - assert test_config == config_fixture.shell_command_before.config_after - - -def test_in_session_scope(config_fixture: "ConfigFixture"): - sconfig = load_yaml(config_fixture.shell_command_before_session.before) - - config.validate_schema(sconfig) - - assert config.expand(sconfig) == sconfig - assert config.expand(config.trickle(sconfig)) == load_yaml( - config_fixture.shell_command_before_session.expected - ) - - -def test_trickle_relative_start_directory(config_fixture: "ConfigFixture"): - test_config = config.trickle(config_fixture.trickle.before) - assert test_config == config_fixture.trickle.expected - - -def test_trickle_window_with_no_pane_config(): - test_yaml = """ - session_name: test_session - windows: - - window_name: test_1 - panes: - - shell_command: - - ls -l - - window_name: test_no_panes - """ - sconfig = load_yaml(test_yaml) - config.validate_schema(sconfig) - - assert config.expand(config.trickle(sconfig))["windows"][1]["panes"][0] == { - "shell_command": [] - } - - -def test_expands_blank_panes(config_fixture: "ConfigFixture"): - """Expand blank config into full form. - - Handle ``NoneType`` and 'blank':: - - # nothing, None, 'blank' - 'panes': [ - None, - 'blank' - ] - - # should be blank - 'panes': [ - 'shell_command': [] - ] - - Blank strings:: - - panes: [ - '' - ] - - # should output to: - panes: - 'shell_command': [''] - - """ - yaml_config_file = EXAMPLE_PATH / "blank-panes.yaml" - test_config = load_config(yaml_config_file) - assert config.expand(test_config) == config_fixture.expand_blank.expected - - -def test_no_session_name(): - yaml_config = """ - - window_name: editor - panes: - shell_command: - - tail -F /var/log/syslog - start_directory: /var/log - - window_name: logging - automatic-rename: true - panes: - - shell_command: - - htop - """ - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - with pytest.raises(exc.ConfigError) as excinfo: - config.validate_schema(sconfig) - assert excinfo.matches(r'requires "session_name"') - - -def test_no_windows(): - yaml_config = """ - session_name: test session - """ - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - with pytest.raises(exc.ConfigError) as excinfo: - config.validate_schema(sconfig) - assert excinfo.match(r'list of "windows"') - - -def test_no_window_name(): - yaml_config = """ - session_name: test session - windows: - - window_name: editor - panes: - shell_command: - - tail -F /var/log/syslog - start_directory: /var/log - - automatic-rename: true - panes: - - shell_command: - - htop - """ - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - with pytest.raises(exc.ConfigError) as excinfo: - config.validate_schema(sconfig) - assert excinfo.matches('missing "window_name"') - - -def test_replaces_env_variables(monkeypatch): - env_key = "TESTHEY92" - env_val = "HEYO1" - yaml_config = """ - start_directory: {TEST_VAR}/test - shell_command_before: {TEST_VAR}/test2 - before_script: {TEST_VAR}/test3 - session_name: hi - {TEST_VAR} - options: - default-command: {TEST_VAR}/lol - global_options: - default-shell: {TEST_VAR}/moo - windows: - - window_name: editor - panes: - - shell_command: - - tail -F /var/log/syslog - start_directory: /var/log - - window_name: logging @ {TEST_VAR} - automatic-rename: true - panes: - - shell_command: - - htop - """.format( - TEST_VAR="${%s}" % env_key - ) - - sconfig = load_yaml(yaml_config) - - monkeypatch.setenv(str(env_key), str(env_val)) - sconfig = config.expand(sconfig) - assert "%s/test" % env_val == sconfig["start_directory"] - assert ( - "%s/test2" % env_val - in sconfig["shell_command_before"]["shell_command"][0]["cmd"] - ) - assert "%s/test3" % env_val == sconfig["before_script"] - assert "hi - %s" % env_val == sconfig["session_name"] - assert "%s/moo" % env_val == sconfig["global_options"]["default-shell"] - assert "%s/lol" % env_val == sconfig["options"]["default-command"] - assert "logging @ %s" % env_val == sconfig["windows"][1]["window_name"] - - -def test_plugins(): - yaml_config = """ - session_name: test session - plugins: tmuxp-plugin-one.plugin.TestPluginOne - windows: - - window_name: editor - panes: - shell_command: - - tail -F /var/log/syslog - start_directory: /var/log - """ - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - with pytest.raises(exc.ConfigError) as excinfo: - config.validate_schema(sconfig) - assert excinfo.matches("only supports list type") diff --git a/tests/test_config_teamocil.py b/tests/test_config_teamocil.py deleted file mode 100644 index dc040261c3..0000000000 --- a/tests/test_config_teamocil.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Test for tmuxp teamocil configuration.""" -import pytest - -import kaptan - -from tmuxp import config - -from .fixtures import config_teamocil as fixtures - - -@pytest.mark.parametrize( - "teamocil_yaml,teamocil_dict,tmuxp_dict", - [ - ( - fixtures.test1.teamocil_yaml, - fixtures.test1.teamocil_conf, - fixtures.test1.expected, - ), - ( - fixtures.test2.teamocil_yaml, - fixtures.test2.teamocil_dict, - fixtures.test2.expected, - ), - ( - fixtures.test3.teamocil_yaml, - fixtures.test3.teamocil_dict, - fixtures.test3.expected, - ), - ( - fixtures.test4.teamocil_yaml, - fixtures.test4.teamocil_dict, - fixtures.test4.expected, - ), - ], -) -def test_config_to_dict(teamocil_yaml, teamocil_dict, tmuxp_dict): - configparser = kaptan.Kaptan(handler="yaml") - test_config = configparser.import_config(teamocil_yaml) - yaml_to_dict = test_config.get() - assert yaml_to_dict == teamocil_dict - - assert config.import_teamocil(teamocil_dict) == tmuxp_dict - - config.validate_schema(config.import_teamocil(teamocil_dict)) - - -@pytest.fixture(scope="module") -def multisession_config(): - """Return loaded multisession teamocil config as a dictionary. - - Also prevents re-running assertion the loads the yaml, since ordering of - deep list items like panes will be inconsistent.""" - teamocil_yaml = fixtures.layouts.teamocil_yaml - configparser = kaptan.Kaptan(handler="yaml") - test_config = configparser.import_config(teamocil_yaml) - teamocil_dict = fixtures.layouts.teamocil_dict - - assert test_config.get() == teamocil_dict - return teamocil_dict - - -@pytest.mark.parametrize( - "session_name,expected", - [ - ("two-windows", fixtures.layouts.two_windows), - ("two-windows-with-filters", fixtures.layouts.two_windows_with_filters), - ( - "two-windows-with-custom-command-options", - fixtures.layouts.two_windows_with_custom_command_options, - ), - ( - "three-windows-within-a-session", - fixtures.layouts.three_windows_within_a_session, - ), - ], -) -def test_multisession_config(session_name, expected, multisession_config): - # teamocil can fit multiple sessions in a config - assert config.import_teamocil(multisession_config[session_name]) == expected - - config.validate_schema(config.import_teamocil(multisession_config[session_name])) diff --git a/tests/test_config_tmuxinator.py b/tests/test_config_tmuxinator.py deleted file mode 100644 index ddcf34e68a..0000000000 --- a/tests/test_config_tmuxinator.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Test for tmuxp tmuxinator configuration.""" -import pytest - -import kaptan - -from tmuxp import config - -from .fixtures import config_tmuxinator as fixtures - - -@pytest.mark.parametrize( - "tmuxinator_yaml,tmuxinator_dict,tmuxp_dict", - [ - ( - fixtures.test1.tmuxinator_yaml, - fixtures.test1.tmuxinator_dict, - fixtures.test1.expected, - ), - ( - fixtures.test2.tmuxinator_yaml, - fixtures.test2.tmuxinator_dict, - fixtures.test2.expected, - ), # older vers use `tabs` instead of `windows` - ( - fixtures.test3.tmuxinator_yaml, - fixtures.test3.tmuxinator_dict, - fixtures.test3.expected, - ), # Test importing - ], -) -def test_config_to_dict(tmuxinator_yaml, tmuxinator_dict, tmuxp_dict): - configparser = kaptan.Kaptan(handler="yaml") - test_config = configparser.import_config(tmuxinator_yaml) - yaml_to_dict = test_config.get() - assert yaml_to_dict == tmuxinator_dict - - assert config.import_tmuxinator(tmuxinator_dict) == tmuxp_dict - - config.validate_schema(config.import_tmuxinator(tmuxinator_dict)) diff --git a/tests/test_docs_tmux_layout.py b/tests/test_docs_tmux_layout.py new file mode 100644 index 0000000000..b5f1b6b0d2 --- /dev/null +++ b/tests/test_docs_tmux_layout.py @@ -0,0 +1,224 @@ +"""Tests for the tmux-layout directive (``docs/_ext/tmux_layout.py``).""" + +from __future__ import annotations + +import importlib.util +import types +import typing as t +from pathlib import Path + +import pytest + +_EXT_PATH = Path(__file__).resolve().parent.parent / "docs" / "_ext" / "tmux_layout.py" +_spec = importlib.util.spec_from_file_location("tmux_layout", _EXT_PATH) +assert _spec is not None +assert _spec.loader is not None +tl = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(tl) + + +class ArrangeCase(t.NamedTuple): + """A named layout with a pane count and the geometry it should produce.""" + + test_id: str + layout: str + n: int + expect_count: int + + +_ARRANGE_CASES: list[ArrangeCase] = [ + ArrangeCase("even-vertical-3", "even-vertical", 3, 3), + ArrangeCase("even-horizontal-2", "even-horizontal", 2, 2), + ArrangeCase("main-vertical-3", "main-vertical", 3, 3), + ArrangeCase("main-horizontal-3", "main-horizontal", 3, 3), + ArrangeCase("tiled-4", "tiled", 4, 4), + ArrangeCase("tiled-3-spans-last-row", "tiled", 3, 3), + ArrangeCase("single-pane", "even-vertical", 1, 1), +] + + +@pytest.mark.parametrize( + "case", + _ARRANGE_CASES, + ids=[c.test_id for c in _ARRANGE_CASES], +) +def test_arrange_fills_screen(case: ArrangeCase) -> None: + """Each arrangement returns the right panes and tiles the screen exactly.""" + width, height = 80, 24 + rects = tl.arrange(case.layout, case.n, width, height) + assert len(rects) == case.expect_count + # Non-overlapping panes that fill the screen sum to its area. + area = sum(r.w * r.h for r in rects) + assert area == pytest.approx(width * height) + # Every pane stays within the screen. + for r in rects: + assert r.x >= -1e-6 + assert r.y >= -1e-6 + assert r.x + r.w <= width + 1e-6 + assert r.y + r.h <= height + 1e-6 + + +def test_arrange_unknown_layout_raises() -> None: + """An unknown layout name is a clear error.""" + with pytest.raises(tl.TmuxLayoutError, match="unknown layout"): + tl.arrange("spiral", 2, 80, 24) + + +class HighlightCase(t.NamedTuple): + """A shell command and the Pygments class its first token should carry.""" + + test_id: str + command: str + expect_class: str + + +_HIGHLIGHT_CASES: list[HighlightCase] = [ + HighlightCase("single-quoted-string", "echo 'hi there'", 'class="s1"'), + HighlightCase("comment", "# a note", 'class="c1"'), +] + + +@pytest.mark.parametrize( + "case", + _HIGHLIGHT_CASES, + ids=[c.test_id for c in _HIGHLIGHT_CASES], +) +def test_highlight_emits_pygments_classes(case: HighlightCase) -> None: + """``_highlight`` wraps shell tokens in Pygments-classed tspans.""" + out = tl._highlight(case.command) + assert case.expect_class in out + assert " None: + """A command builtin (echo/pwd) is left in the default colour, no nb class.""" + assert 'class="nb"' not in tl._highlight("echo hello") + assert 'class="nb"' not in tl._highlight("pwd") + + +def test_highlight_escapes_markup() -> None: + """Angle brackets in a command are HTML-escaped, not emitted as tags.""" + out = tl._highlight("echo ") + assert "" not in out + assert "<a>" in out + + +class SplitCase(t.NamedTuple): + """Directive content lines and the per-pane command lists they yield.""" + + test_id: str + content: list[str] + expect: list[list[str]] + + +_SPLIT_CASES: list[SplitCase] = [ + SplitCase("two-panes", ["echo a", "---", "echo b"], [["echo a"], ["echo b"]]), + SplitCase( + "multiline-pane", + ["echo a", "echo b", "---", "echo c"], + [["echo a", "echo b"], ["echo c"]], + ), + SplitCase( + "blank-lines-dropped", ["echo a", "", "---", "echo b"], [["echo a"], ["echo b"]] + ), +] + + +@pytest.mark.parametrize( + "case", + _SPLIT_CASES, + ids=[c.test_id for c in _SPLIT_CASES], +) +def test_split_panes(case: SplitCase) -> None: + """``_split_panes`` splits on ``---`` and keeps each pane's commands.""" + assert tl._split_panes(case.content) == case.expect + + +class SizeCase(t.NamedTuple): + """A ``:size:`` string and the (cols, rows) it parses to.""" + + test_id: str + size: str + expect: tuple[int, int] + + +_SIZE_CASES: list[SizeCase] = [ + SizeCase("plain", "64x18", (64, 18)), + SizeCase("uppercase-x", "80X24", (80, 24)), +] + + +@pytest.mark.parametrize( + "case", + _SIZE_CASES, + ids=[c.test_id for c in _SIZE_CASES], +) +def test_parse_size(case: SizeCase) -> None: + """``_parse_size`` reads a ``WxH`` screen size.""" + assert tl._parse_size(case.size) == case.expect + + +def test_parse_size_invalid_raises() -> None: + """A malformed size is a clear error.""" + with pytest.raises(tl.TmuxLayoutError, match="invalid size"): + tl._parse_size("not-a-size") + + +def test_render_layout_structure() -> None: + """The rendered SVG has one window, one rect per pane, and highlighting.""" + svg = tl.render_layout([["echo a"], ["echo b"]], "even-vertical", (40, 12)) + assert svg.startswith("") + + +def test_render_layout_clips_compact_pane_text_without_rescaling() -> None: + """Compact panes clip long text instead of shrinking the whole command.""" + svg = tl.render_layout( + [ + ["cd /var/log", "ls -al | grep \\.log"], + ["echo hello"], + ["echo hello"], + ["echo hello"], + ], + "tiled", + (30, 12), + ) + assert "ls<" in svg + assert ">grep<" in svg + assert "\\.log" in svg + + +def test_setup_registers_components() -> None: + """``setup`` registers the node, directive, css, and is parallel-safe.""" + recorded: dict[str, list[t.Any]] = {"nodes": [], "directives": [], "css": []} + + def add_node(node: object, **kwargs: object) -> None: + recorded["nodes"].append(node) + + def add_directive(name: str, cls: object) -> None: + recorded["directives"].append((name, cls)) + + def add_css_file(name: str, **kwargs: object) -> None: + recorded["css"].append(name) + + app = types.SimpleNamespace( + add_node=add_node, + add_directive=add_directive, + add_css_file=add_css_file, + ) + meta = tl.setup(t.cast("t.Any", app)) + + assert meta["parallel_read_safe"] is True + assert meta["parallel_write_safe"] is True + assert tl.tmux_layout in recorded["nodes"] + assert ("tmux-layout", tl.TmuxLayoutDirective) in recorded["directives"] + assert "css/gp-tmux-layout.css" in recorded["css"] diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000000..b916b37b94 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,105 @@ +"""Tests for tmuxp.log module.""" + +from __future__ import annotations + +import logging +import sys + +import pytest + +from tmuxp.log import ( + LEVEL_COLORS, + DebugLogFormatter, + LogFormatter, + tmuxp_echo, +) + + +def test_level_colors_no_colorama() -> None: + """LEVEL_COLORS must be raw ANSI escape strings, not colorama objects.""" + for level, code in LEVEL_COLORS.items(): + assert code.startswith("\033["), ( + f"LEVEL_COLORS[{level!r}] should start with ANSI ESC, got {code!r}" + ) + + +def test_log_formatter_format_plain_text() -> None: + """LogFormatter.format() produces plain text without ANSI when unstylized.""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "test message" in output + assert "\033[" not in output + + +def test_debug_log_formatter_format_smoke() -> None: + """DebugLogFormatter.format() runs without error.""" + formatter = DebugLogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.DEBUG, + pathname="", + lineno=42, + msg="debug message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "debug message" in output + + +def test_timestamp_format_has_minutes() -> None: + """Timestamp format must use %M (minutes), not %m (month).""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="ts check", + args=(), + exc_info=None, + ) + formatter.format(record) + # asctime is set during format(); if %m were used, seconds portion would + # show month (01-12) instead of minutes (00-59) — we can't easily + # distinguish that directly, so just verify the format string constant. + # Inspect the source: date_format in LogFormatter.format is "%H:%M:%S" + import inspect + + import tmuxp.log as log_module + + src = inspect.getsource(log_module.LogFormatter.format) + assert '"%H:%M:%S"' in src, "Timestamp format must be %H:%M:%S (M = minutes)" + + +def test_tmuxp_echo_default_stdout(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stdout by default.""" + tmuxp_echo("hello stdout") + captured = capsys.readouterr() + assert captured.out == "hello stdout\n" + assert captured.err == "" + + +def test_tmuxp_echo_to_stderr(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stderr when file=sys.stderr.""" + tmuxp_echo("hello stderr", file=sys.stderr) + captured = capsys.readouterr() + assert captured.err == "hello stderr\n" + assert captured.out == "" + + +def test_tmuxp_echo_none_is_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo(None) produces no output.""" + tmuxp_echo(None) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index adb47044cf..6d7fda7fd1 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,4 +1,9 @@ -"""Test for tmuxp plugin api.""" +"""Tests for tmuxp plugin API.""" + +from __future__ import annotations + +import logging + import pytest from tmuxp.exc import TmuxpPluginException @@ -22,63 +27,85 @@ @pytest.fixture(autouse=True) -def autopatch_sitedir(monkeypatch_plugin_test_packages): - pass +def autopatch_sitedir(monkeypatch_plugin_test_packages: None) -> None: + """Fixture automatically used that patches sitedir.""" -def test_all_pass(): +def test_all_pass() -> None: + """Plugin for tmuxp that loads successfully.""" AllVersionPassPlugin() -def test_tmux_version_fail_min(): +def test_tmux_version_fail_min() -> None: + """Plugin raises if tmux version is below minimum constraint.""" with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: TmuxVersionFailMinPlugin() assert "tmux-min-version-fail" in str(exc_info.value) -def test_tmux_version_fail_max(): +def test_tmux_version_fail_max() -> None: + """Plugin raises if tmux version is above maximum constraint.""" with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: TmuxVersionFailMaxPlugin() assert "tmux-max-version-fail" in str(exc_info.value) -def test_tmux_version_fail_incompatible(): +def test_tmux_version_fail_incompatible() -> None: + """Plugin raises if tmuxp version is incompatible.""" with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: TmuxVersionFailIncompatiblePlugin() assert "tmux-incompatible-version-fail" in str(exc_info.value) -def test_tmuxp_version_fail_min(): +def test_tmuxp_version_fail_min() -> None: + """Plugin raises if tmuxp version is below minimum constraint.""" with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: TmuxpVersionFailMinPlugin() assert "tmuxp-min-version-fail" in str(exc_info.value) -def test_tmuxp_version_fail_max(): +def test_tmuxp_version_fail_max() -> None: + """Plugin raises if tmuxp version is above max constraint.""" with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: TmuxpVersionFailMaxPlugin() assert "tmuxp-max-version-fail" in str(exc_info.value) -def test_tmuxp_version_fail_incompatible(): +def test_tmuxp_version_fail_incompatible() -> None: + """Plugin raises if libtmux version is below minimum constraint.""" with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: TmuxpVersionFailIncompatiblePlugin() assert "tmuxp-incompatible-version-fail" in str(exc_info.value) -def test_libtmux_version_fail_min(): +def test_libtmux_version_fail_min() -> None: + """Plugin raises if libtmux version is below minimum constraint.""" with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: LibtmuxVersionFailMinPlugin() assert "libtmux-min-version-fail" in str(exc_info.value) -def test_libtmux_version_fail_max(): +def test_libtmux_version_fail_max() -> None: + """Plugin raises if libtmux version is above max constraint.""" with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: LibtmuxVersionFailMaxPlugin() assert "libtmux-max-version-fail" in str(exc_info.value) -def test_libtmux_version_fail_incompatible(): +def test_libtmux_version_fail_incompatible() -> None: + """Plugin raises if libtmux version is incompatible.""" with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: LibtmuxVersionFailIncompatiblePlugin() assert "libtmux-incompatible-version-fail" in str(exc_info.value) + + +def test_plugin_version_check_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """_version_check() logs DEBUG with plugin name.""" + with caplog.at_level(logging.DEBUG, logger="tmuxp.plugin"): + AllVersionPassPlugin() + records = [ + r for r in caplog.records if r.msg == "checking version constraints for %s" + ] + assert len(records) >= 1 diff --git a/tests/test_shell.py b/tests/test_shell.py index ca6a97b265..d544439d60 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -1,12 +1,18 @@ +"""Tests for tmuxp shell module.""" + +from __future__ import annotations + from tmuxp import shell -def test_detect_best_shell(): +def test_detect_best_shell() -> None: + """detect_best_shell() returns a a string of the best shell.""" result = shell.detect_best_shell() assert isinstance(result, str) -def test_shell_detect(): +def test_shell_detect() -> None: + """Tests shell detection functions.""" assert isinstance(shell.has_bpython(), bool) assert isinstance(shell.has_ipython(), bool) assert isinstance(shell.has_ptpython(), bool) diff --git a/tests/test_util.py b/tests/test_util.py index 2dbe5c1904..098c8c212b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,14 +1,29 @@ -"""Tests for utility functions in tmux.""" +"""Tests for tmuxp's utility functions.""" + +from __future__ import annotations + +import logging +import os +import pathlib +import sys +import typing as t + import pytest from tmuxp import exc from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists -from tmuxp.util import get_session, run_before_script +from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script from .constants import FIXTURE_PATH +if t.TYPE_CHECKING: + import pathlib -def test_raise_BeforeLoadScriptNotExists_if_not_exists(): + from libtmux.server import Server + + +def test_run_before_script_raise_BeforeLoadScriptNotExists_if_not_exists() -> None: + """run_before_script() raises BeforeLoadScriptNotExists if script not found.""" script_file = FIXTURE_PATH / "script_noexists.sh" with pytest.raises(BeforeLoadScriptNotExists): @@ -18,22 +33,97 @@ def test_raise_BeforeLoadScriptNotExists_if_not_exists(): run_before_script(script_file) -def test_raise_BeforeLoadScriptError_if_retcode(): +def test_run_before_script_raise_BeforeLoadScriptError_if_retcode() -> None: + """run_before_script() raises BeforeLoadScriptNotExists if script fails.""" script_file = FIXTURE_PATH / "script_failed.sh" with pytest.raises(BeforeLoadScriptError): run_before_script(script_file) -def test_return_stdout_if_ok(capsys): +@pytest.fixture +def temp_script(tmp_path: pathlib.Path) -> pathlib.Path: + """Fixture of an example script that prints "Hello, world!".""" + script = tmp_path / "test_script.sh" + script.write_text( + """#!/bin/sh +echo "Hello, World!" +exit 0 +""", + ) + script.chmod(0o755) + return script + + +class TTYTestFixture(t.NamedTuple): + """Test fixture for isatty behavior verification.""" + + test_id: str + isatty_value: bool + expected_output: str + + +TTY_TEST_FIXTURES: list[TTYTestFixture] = [ + TTYTestFixture( + test_id="tty_enabled_shows_output", + isatty_value=True, + expected_output="Hello, World!", + ), + TTYTestFixture( + test_id="tty_disabled_suppresses_output", + isatty_value=False, + expected_output="", + ), +] + + +@pytest.mark.parametrize( + list(TTYTestFixture._fields), + TTY_TEST_FIXTURES, + ids=[test.test_id for test in TTY_TEST_FIXTURES], +) +def test_run_before_script_isatty( + temp_script: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + isatty_value: bool, + expected_output: str, +) -> None: + """Verify behavior of ``isatty()``, which we mock in `run_before_script()`.""" + # Mock sys.stdout.isatty() to return the desired value. + monkeypatch.setattr(sys.stdout, "isatty", lambda: isatty_value) + + # Run the script. + returncode = run_before_script(temp_script) + + # Assert that the script ran successfully. + assert returncode == 0 + + out, _err = capsys.readouterr() + + # In TTY mode, we expect the output; in non-TTY mode, we expect it to be suppressed. + assert expected_output in out + + +def test_return_stdout_if_ok( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_before_script() returns stdout if script succeeds.""" + # Simulate sys.stdout.isatty() + sys.stderr.isatty() + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(sys.stderr, "isatty", lambda: True) + script_file = FIXTURE_PATH / "script_complete.sh" run_before_script(script_file) - out, err = capsys.readouterr() + out, _err = capsys.readouterr() assert "hello" in out -def test_beforeload_returncode(): +def test_beforeload_returncode() -> None: + """run_before_script() returns returncode if script fails.""" script_file = FIXTURE_PATH / "script_failed.sh" with pytest.raises(exc.BeforeLoadScriptError) as excinfo: @@ -41,7 +131,8 @@ def test_beforeload_returncode(): assert excinfo.match(r"113") -def test_beforeload_returns_stderr_messages(): +def test_beforeload_returns_stderr_messages() -> None: + """run_before_script() returns stderr messages if script fails.""" script_file = FIXTURE_PATH / "script_failed.sh" with pytest.raises(exc.BeforeLoadScriptError) as excinfo: @@ -49,21 +140,97 @@ def test_beforeload_returns_stderr_messages(): assert excinfo.match(r"failed with returncode") -def test_get_session_should_default_to_local_attached_session(server, monkeypatch): +def test_get_session_should_default_to_local_attached_session( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """get_session() should launch current terminal's tmux session, if inside one.""" server.new_session(session_name="myfirstsession") second_session = server.new_session(session_name="mysecondsession") # Assign an active pane to the session - first_pane_on_second_session_id = second_session.list_windows()[0].list_panes()[0][ - "pane_id" - ] + first_pane_on_second_session_id = second_session.windows[0].panes[0].pane_id + assert first_pane_on_second_session_id is not None monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) assert get_session(server) == second_session -def test_get_session_should_return_first_session_if_no_active_session(server): +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") assert get_session(server) == first_session + + +def test_get_pane_logs_debug_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """get_pane() logs DEBUG with tmux_pane extra when pane lookup fails.""" + session = server.new_session(session_name="test_pane_log") + window = session.active_window + + # Make active_pane raise Exception to trigger the logging path + monkeypatch.setattr( + type(window), + "active_pane", + property(lambda self: (_ for _ in ()).throw(Exception("mock pane error"))), + ) + + with ( + caplog.at_level(logging.DEBUG, logger="tmuxp.util"), + pytest.raises(exc.PaneNotFound), + ): + get_pane(window, current_pane=None) + + debug_records = [ + r + for r in caplog.records + if hasattr(r, "tmux_pane") and r.levelno == logging.DEBUG + ] + assert len(debug_records) >= 1 + assert debug_records[0].tmux_pane == "" + + +def test_oh_my_zsh_auto_title_logs_warning( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + tmp_path: t.Any, +) -> None: + """oh_my_zsh_auto_title() logs WARNING when DISABLE_AUTO_TITLE not set.""" + monkeypatch.setenv("SHELL", "/bin/zsh") + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + + # Create fake ~/.oh-my-zsh directory + fake_home = tmp_path / "home" + fake_home.mkdir() + oh_my_zsh_dir = fake_home / ".oh-my-zsh" + oh_my_zsh_dir.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + # Patch os.path.exists to return True for ~/.oh-my-zsh + original_exists = os.path.exists + + def patched_exists(path: str) -> bool: + if path == str(pathlib.Path("~/.oh-my-zsh").expanduser()): + return True + return original_exists(path) + + monkeypatch.setattr(os.path, "exists", patched_exists) + + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + oh_my_zsh_auto_title() + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "DISABLE_AUTO_TITLE" in warning_records[0].message diff --git a/tests/test_workspacebuilder.py b/tests/test_workspacebuilder.py deleted file mode 100644 index 2ab04a9363..0000000000 --- a/tests/test_workspacebuilder.py +++ /dev/null @@ -1,1160 +0,0 @@ -"""Test for tmuxp workspacebuilder.""" -import os -import pathlib -import textwrap -import time - -import pytest - -import kaptan - -import libtmux -from libtmux import Window -from libtmux.common import has_gte_version -from libtmux.test import retry, temp_session -from tmuxp import config, exc -from tmuxp.cli.load import load_plugins -from tmuxp.workspacebuilder import WorkspaceBuilder - -from .constants import EXAMPLE_PATH, FIXTURE_PATH -from .fixtures import utils as test_utils - - -def test_split_windows(session): - yaml_config = test_utils.read_config_file("workspacebuilder/two_pane.yaml") - s = session - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(session._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - for p in builder.iter_create_panes(w, wconf): - w.select_layout("tiled") # fix glitch with pane size - p = p - assert len(s._windows) == window_count - assert isinstance(w, Window) - - assert len(s._windows) == window_count - window_count += 1 - - -def test_split_windows_three_pane(session): - yaml_config = test_utils.read_config_file("workspacebuilder/three_pane.yaml") - - s = session - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(s._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - for p in builder.iter_create_panes(w, wconf): - w.select_layout("tiled") # fix glitch with pane size - p = p - assert len(s._windows) == window_count - assert isinstance(w, Window) - - assert len(s._windows) == window_count - window_count += 1 - w.set_window_option("main-pane-height", 50) - w.select_layout(wconf["layout"]) - - -def test_focus_pane_index(session): - yaml_config = test_utils.read_config_file("workspacebuilder/focus_and_pane.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - builder.build(session=session) - - assert session.attached_window.name == "focused window" - - pane_base_index = int( - session.attached_window.show_window_option("pane-base-index", g=True) - ) - - if not pane_base_index: - pane_base_index = 0 - else: - pane_base_index = int(pane_base_index) - - # get the pane index for each pane - pane_base_indexes = [] - for pane in session.attached_window.panes: - pane_base_indexes.append(int(pane.index)) - - pane_indexes_should_be = [pane_base_index + x for x in range(0, 3)] - assert pane_indexes_should_be == pane_base_indexes - - w = session.attached_window - - assert w.name != "man" - - pane_path = "/usr" - - while retry(): - p = w.attached_pane - p.server._update_panes() - if p.current_path == pane_path: - break - - assert p.current_path == pane_path - - proc = session.cmd("show-option", "-gv", "base-index") - base_index = int(proc.stdout[0]) - session.server._update_windows() - - window3 = session.find_where({"window_index": str(base_index + 2)}) - assert isinstance(window3, Window) - - p = None - pane_path = "/" - - while retry(): - p = window3.attached_pane - p.server._update_panes() - if p.current_path == pane_path: - break - - assert p.current_path == pane_path - - -@pytest.mark.skip( - reason=""" -Test needs to be rewritten, assertion not reliable across platforms -and CI. See https://github.com/tmux-python/tmuxp/issues/310. - """.strip() -) -def test_suppress_history(session): - yaml_config = test_utils.read_config_file("workspacebuilder/suppress_history.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - inHistoryWindow = session.find_where({"window_name": "inHistory"}) - isMissingWindow = session.find_where({"window_name": "isMissing"}) - - def assertHistory(cmd, hist): - return "inHistory" in cmd and cmd.endswith(hist) - - def assertIsMissing(cmd, hist): - return "isMissing" in cmd and not cmd.endswith(hist) - - for w, window_name, assertCase in [ - (inHistoryWindow, "inHistory", assertHistory), - (isMissingWindow, "isMissing", assertIsMissing), - ]: - assert w.name == window_name - correct = False - w.select_window() - p = w.attached_pane - p.select_pane() - - # Print the last-in-history command in the pane - p.cmd("send-keys", " fc -ln -1") - p.cmd("send-keys", "Enter") - - buffer_name = "test" - while retry(): - # from v0.7.4 libtmux session.cmd adds target -t self.id by default - # show-buffer doesn't accept -t, use global cmd. - - # Get the contents of the pane - p.cmd("capture-pane", "-b", buffer_name) - - captured_pane = session.server.cmd("show-buffer", "-b", buffer_name) - session.server.cmd("delete-buffer", "-b", buffer_name) - - # Parse the sent and last-in-history commands - sent_cmd = captured_pane.stdout[0].strip() - history_cmd = captured_pane.stdout[-2].strip() - - if assertCase(sent_cmd, history_cmd): - correct = True - break - assert correct, f"Unknown sent command: [{sent_cmd}] in {assertCase}" - - -def test_session_options(session): - yaml_config = test_utils.read_config_file("workspacebuilder/session_options.yaml") - s = session - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert "/bin/sh" in s.show_option("default-shell") - assert "/bin/sh" in s.show_option("default-command") - - -def test_global_options(session): - yaml_config = test_utils.read_config_file("workspacebuilder/global_options.yaml") - s = session - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert "top" in s.show_option("status-position", _global=True) - assert 493 == s.show_option("repeat-time", _global=True) - - -def test_global_session_env_options(session, monkeypatch): - visual_silence = "on" - monkeypatch.setenv("VISUAL_SILENCE", str(visual_silence)) - repeat_time = 738 - monkeypatch.setenv("REPEAT_TIME", str(repeat_time)) - main_pane_height = 8 - monkeypatch.setenv("MAIN_PANE_HEIGHT", str(main_pane_height)) - - yaml_config = test_utils.read_config_file("workspacebuilder/env_var_options.yaml") - s = session - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert visual_silence in s.show_option("visual-silence", _global=True) - assert repeat_time == s.show_option("repeat-time") - assert main_pane_height == s.attached_window.show_window_option("main-pane-height") - - -def test_window_options(session): - yaml_config = test_utils.read_config_file("workspacebuilder/window_options.yaml") - s = session - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - if has_gte_version("2.3"): - sconfig["windows"][0]["options"]["pane-border-format"] = " #P " - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(session._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - for p in builder.iter_create_panes(w, wconf): - w.select_layout("tiled") # fix glitch with pane size - p = p - assert len(s._windows) == window_count - assert isinstance(w, Window) - assert w.show_window_option("main-pane-height") == 5 - if has_gte_version("2.3"): - assert w.show_window_option("pane-border-format") == " #P " - - assert len(s._windows) == window_count - window_count += 1 - w.select_layout(wconf["layout"]) - - -@pytest.mark.flaky(reruns=5) -def test_window_options_after(session): - yaml_config = test_utils.read_config_file( - "workspacebuilder/window_options_after.yaml" - ) - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - def assert_last_line(p, s): - correct = False - - while retry(): - pane_out = p.cmd("capture-pane", "-p", "-J").stdout - while not pane_out[-1].strip(): # delete trailing lines tmux 1.8 - pane_out.pop() - if len(pane_out) > 1 and pane_out[-2].strip() == s: - correct = True - break - - # Print output for easier debugging if assertion fails - if not correct: - print("\n".join(pane_out)) - - return correct - - for i, pane in enumerate(session.attached_window.panes): - assert assert_last_line( - pane, str(i) - ), "Initial command did not execute properly/" + str(i) - pane.cmd("send-keys", "Up") # Will repeat echo - pane.enter() # in each iteration - assert assert_last_line( - pane, str(i) - ), "Repeated command did not execute properly/" + str(i) - - session.cmd("send-keys", " echo moo") - session.cmd("send-keys", "Enter") - - for pane in session.attached_window.panes: - assert assert_last_line( - pane, "moo" - ), "Synchronized command did not execute properly" - - -def test_window_shell(session): - yaml_config = test_utils.read_config_file("workspacebuilder/window_shell.yaml") - s = session - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - for w, wconf in builder.iter_create_windows(s): - if "window_shell" in wconf: - assert wconf["window_shell"] == "top" - - while retry(): - session.server._update_windows() - if w["window_name"] != "top": - break - - assert w.name != "top" - - -def test_environment_variables(session): - yaml_config = test_utils.read_config_file("workspacebuilder/environment_vars.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session) - - assert session.show_environment("FOO") == "BAR" - assert session.show_environment("PATH") == "/tmp" - - -def test_automatic_rename_option(session): - """With option automatic-rename: on.""" - yaml_config = test_utils.read_config_file( - "workspacebuilder/window_automatic_rename.yaml" - ) - s = session - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(session._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - - for p in builder.iter_create_panes(w, wconf): - w.select_layout("tiled") # fix glitch with pane size - p = p - assert len(s._windows), window_count - assert isinstance(w, Window) - assert w.show_window_option("automatic-rename") == "on" - - assert len(s._windows) == window_count - - window_count += 1 - w.select_layout(wconf["layout"]) - - assert s.name != "tmuxp" - w = s.windows[0] - - while retry(): - session.server._update_windows() - if w.name != "sh": - break - - assert w.name != "sh" - - pane_base_index = w.show_window_option("pane-base-index", g=True) - w.select_pane(pane_base_index) - - while retry(): - session.server._update_windows() - if w.name == "sh": - break - - assert w.name == "sh" - - w.select_pane("-D") - - while retry(): - session.server._update_windows() - if w["window_name"] != "sh": - break - - assert w.name != "sh" - - -def test_blank_pane_count(session): - """:todo: Verify blank panes of various types build into workspaces.""" - yaml_config_file = EXAMPLE_PATH / "blank-panes.yaml" - test_config = kaptan.Kaptan().import_config(str(yaml_config_file)).get() - test_config = config.expand(test_config) - builder = WorkspaceBuilder(sconf=test_config) - builder.build(session=session) - - assert session == builder.session - - window1 = session.find_where({"window_name": "Blank pane test"}) - assert len(window1._panes) == 3 - - window2 = session.find_where({"window_name": "More blank panes"}) - assert len(window2._panes) == 3 - - window3 = session.find_where({"window_name": "Empty string (return)"}) - assert len(window3._panes) == 3 - - window4 = session.find_where({"window_name": "Blank with options"}) - assert len(window4._panes) == 2 - - -def test_start_directory(session, tmp_path: pathlib.Path): - yaml_config = test_utils.read_config_file("workspacebuilder/start_directory.yaml") - test_dir = tmp_path / "foo bar" - test_dir.mkdir() - test_config = yaml_config.format(TEST_DIR=test_dir) - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(test_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert session == builder.session - dirs = ["/usr/bin", "/dev", str(test_dir), "/usr", "/usr"] - - for path, window in zip(dirs, session.windows): - for p in window.panes: - while retry(): - p.server._update_panes() - pane_path = p.current_path - if pane_path is None: - pass - elif path in pane_path or pane_path == path: - result = path == pane_path or path in pane_path - break - - # handle case with OS X adding /private/ to /tmp/ paths - assert result - - -def test_start_directory_relative(session, tmp_path: pathlib.Path): - """Same as above test, but with relative start directory, mimicking - loading it from a location of project file. Like:: - - $ tmuxp load ~/workspace/myproject/.tmuxp.yaml - - instead of:: - - $ cd ~/workspace/myproject/.tmuxp.yaml - $ tmuxp load . - - """ - yaml_config = test_utils.read_config_file( - "workspacebuilder/start_directory_relative.yaml" - ) - - test_dir = tmp_path / "foo bar" - test_dir.mkdir() - config_dir = tmp_path / "testRelConfigDir" - config_dir.mkdir() - test_config = yaml_config.format(TEST_DIR=test_dir) - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(test_config).get() - # the second argument of os.getcwd() mimics the behavior - # the CLI loader will do, but it passes in the config file's location. - sconfig = config.expand(sconfig, config_dir) - - sconfig = config.trickle(sconfig) - - assert os.path.exists(config_dir) - assert os.path.exists(test_dir) - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - - assert session == builder.session - - dirs = ["/usr/bin", "/dev", str(test_dir), str(config_dir), str(config_dir)] - - for path, window in zip(dirs, session.windows): - for p in window.panes: - while retry(): - p.server._update_panes() - # Handle case where directories resolve to /private/ in OSX - pane_path = p.current_path - if pane_path is None: - pass - elif path in pane_path or pane_path == path: - result = path == pane_path or path in pane_path - break - - assert result - - -def test_pane_order(session): - """Pane ordering based on position in config and ``pane_index``. - - Regression test for https://github.com/tmux-python/tmuxp/issues/15. - - """ - - yaml_config = test_utils.read_config_file( - "workspacebuilder/pane_ordering.yaml" - ).format(HOME=os.path.realpath(os.path.expanduser("~"))) - - # test order of `panes` (and pane_index) above against pane_dirs - pane_paths = [ - "/usr/bin", - "/usr", - "/usr/sbin", - os.path.realpath(os.path.expanduser("~")), - ] - - s = session - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - window_count = len(session._windows) # current window count - assert len(s._windows) == window_count - for w, wconf in builder.iter_create_windows(s): - for p in builder.iter_create_panes(w, wconf): - w.select_layout("tiled") # fix glitch with pane size - p = p - assert len(s._windows) == window_count - - assert isinstance(w, Window) - - assert len(s._windows) == window_count - window_count += 1 - - for w in session.windows: - pane_base_index = w.show_window_option("pane-base-index", g=True) - for p_index, p in enumerate(w.list_panes(), start=pane_base_index): - assert int(p_index) == int(p.index) - - # pane-base-index start at base-index, pane_paths always start - # at 0 since python list. - pane_path = pane_paths[p_index - pane_base_index] - - while retry(): - p.server._update_panes() - if p.current_path == pane_path: - break - - assert p.current_path, pane_path - - -def test_window_index(session): - yaml_config = test_utils.read_config_file("workspacebuilder/window_index.yaml") - proc = session.cmd("show-option", "-gv", "base-index") - base_index = int(proc.stdout[0]) - name_index_map = {"zero": 0 + base_index, "one": 1 + base_index, "five": 5} - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - for window, _ in builder.iter_create_windows(session): - expected_index = name_index_map[window["window_name"]] - assert int(window["window_index"]) == expected_index - - -def test_before_load_throw_error_if_retcode_error(server): - config_script_fails = test_utils.read_config_file( - "workspacebuilder/config_script_fails.yaml" - ) - sconfig = kaptan.Kaptan(handler="yaml") - yaml = config_script_fails.format( - script_failed=FIXTURE_PATH / "script_failed.sh", - ) - - sconfig = sconfig.import_config(yaml).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - with temp_session(server) as sess: - session_name = sess.name - - with pytest.raises(exc.BeforeLoadScriptError): - builder.build(session=sess) - - result = server.has_session(session_name) - assert not result, "Kills session if before_script exits with errcode" - - -def test_before_load_throw_error_if_file_not_exists(server): - config_script_not_exists = test_utils.read_config_file( - "workspacebuilder/config_script_not_exists.yaml" - ) - sconfig = kaptan.Kaptan(handler="yaml") - yaml = config_script_not_exists.format( - script_not_exists=FIXTURE_PATH / "script_not_exists.sh", - ) - sconfig = sconfig.import_config(yaml).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - with temp_session(server) as sess: - session_name = sess.name - temp_session_exists = server.has_session(sess.name) - assert temp_session_exists - with pytest.raises((exc.BeforeLoadScriptNotExists, OSError)) as excinfo: - builder.build(session=sess) - excinfo.match(r"No such file or directory") - result = server.has_session(session_name) - assert not result, "Kills session if before_script doesn't exist" - - -def test_before_load_true_if_test_passes(server): - config_script_completes = test_utils.read_config_file( - "workspacebuilder/config_script_completes.yaml" - ) - script_complete_sh = FIXTURE_PATH / "script_complete.sh" - assert script_complete_sh.exists() - sconfig = kaptan.Kaptan(handler="yaml") - yaml = config_script_completes.format( - script_complete=script_complete_sh, - ) - - sconfig = sconfig.import_config(yaml).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - with temp_session(server) as session: - builder.build(session=session) - - -def test_before_load_true_if_test_passes_with_args(server): - config_script_completes = test_utils.read_config_file( - "workspacebuilder/config_script_completes.yaml" - ) - script_complete_sh = FIXTURE_PATH / "script_complete.sh" - assert script_complete_sh.exists() - sconfig = kaptan.Kaptan(handler="yaml") - yaml = config_script_completes.format(script_complete=script_complete_sh) - - sconfig = sconfig.import_config(yaml).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig) - - with temp_session(server) as session: - builder.build(session=session) - - -def test_plugin_system_before_workspace_builder( - monkeypatch_plugin_test_packages, session -): - config_plugins = test_utils.read_config_file("workspacebuilder/plugin_bwb.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(config_plugins).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig, plugins=load_plugins(sconfig)) - assert len(builder.plugins) > 0 - - builder.build(session=session) - - proc = session.cmd("display-message", "-p", "'#S'") - assert proc.stdout[0] == "'plugin_test_bwb'" - - -def test_plugin_system_on_window_create(monkeypatch_plugin_test_packages, session): - config_plugins = test_utils.read_config_file("workspacebuilder/plugin_owc.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(config_plugins).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig, plugins=load_plugins(sconfig)) - assert len(builder.plugins) > 0 - - builder.build(session=session) - - proc = session.cmd("display-message", "-p", "'#W'") - assert proc.stdout[0] == "'plugin_test_owc'" - - -def test_plugin_system_after_window_finished(monkeypatch_plugin_test_packages, session): - config_plugins = test_utils.read_config_file("workspacebuilder/plugin_awf.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(config_plugins).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig, plugins=load_plugins(sconfig)) - assert len(builder.plugins) > 0 - - builder.build(session=session) - - proc = session.cmd("display-message", "-p", "'#W'") - assert proc.stdout[0] == "'plugin_test_awf'" - - -def test_plugin_system_on_window_create_multiple_windows(session): - config_plugins = test_utils.read_config_file( - "workspacebuilder/plugin_owc_multiple_windows.yaml" - ) - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(config_plugins).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig, plugins=load_plugins(sconfig)) - assert len(builder.plugins) > 0 - - builder.build(session=session) - - proc = session.cmd("list-windows", "-F", "'#W'") - assert "'plugin_test_owc_mw'" in proc.stdout - assert "'plugin_test_owc_mw_2'" in proc.stdout - - -def test_plugin_system_after_window_finished_multiple_windows( - monkeypatch_plugin_test_packages, session -): - config_plugins = test_utils.read_config_file( - "workspacebuilder/plugin_awf_multiple_windows.yaml" - ) - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(config_plugins).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig, plugins=load_plugins(sconfig)) - assert len(builder.plugins) > 0 - - builder.build(session=session) - - proc = session.cmd("list-windows", "-F", "'#W'") - assert "'plugin_test_awf_mw'" in proc.stdout - assert "'plugin_test_awf_mw_2'" in proc.stdout - - -def test_plugin_system_multiple_plugins(monkeypatch_plugin_test_packages, session): - config_plugins = test_utils.read_config_file( - "workspacebuilder/plugin_multiple_plugins.yaml" - ) - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(config_plugins).get() - sconfig = config.expand(sconfig) - - builder = WorkspaceBuilder(sconf=sconfig, plugins=load_plugins(sconfig)) - assert len(builder.plugins) > 0 - - builder.build(session=session) - - # Drop through to the before_script plugin hook - proc = session.cmd("display-message", "-p", "'#S'") - assert proc.stdout[0] == "'plugin_test_bwb'" - - # Drop through to the after_window_finished. This won't succeed - # unless on_window_create succeeds because of how the test plugin - # override methods are currently written - proc = session.cmd("display-message", "-p", "'#W'") - assert proc.stdout[0] == "'mp_test_awf'" - - -def test_load_configs_same_session(server): - yaml_config = test_utils.read_config_file("workspacebuilder/three_windows.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - builder.build() - - assert len(server.sessions) == 1 - assert len(server.sessions[0]._windows) == 3 - - yaml_config = test_utils.read_config_file("workspacebuilder/two_windows.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - builder.build() - assert len(server.sessions) == 2 - assert len(server.sessions[1]._windows) == 2 - - yaml_config = test_utils.read_config_file("workspacebuilder/two_windows.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - builder.build(server.sessions[1], True) - - assert len(server.sessions) == 2 - assert len(server.sessions[1]._windows) == 4 - - -def test_load_configs_separate_sessions(server): - yaml_config = test_utils.read_config_file("workspacebuilder/three_windows.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - builder.build() - - assert len(server.sessions) == 1 - assert len(server.sessions[0]._windows) == 3 - - yaml_config = test_utils.read_config_file("workspacebuilder/two_windows.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - builder.build() - - assert len(server.sessions) == 2 - assert len(server.sessions[0]._windows) == 3 - assert len(server.sessions[1]._windows) == 2 - - -def test_find_current_active_pane(server, monkeypatch): - yaml_config = test_utils.read_config_file("workspacebuilder/three_windows.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - builder.build() - - yaml_config = test_utils.read_config_file("workspacebuilder/two_windows.yaml") - - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - builder.build() - - assert len(server.list_sessions()) == 2 - - # Assign an active pane to the session - second_session = server.list_sessions()[1] - first_pane_on_second_session_id = second_session.list_windows()[0].list_panes()[0][ - "pane_id" - ] - monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) - - builder = WorkspaceBuilder(sconf=sconfig, server=server) - - assert builder.find_current_attached_session() == second_session - - -@pytest.mark.parametrize( - "yaml,output,should_see", - [ - [ - textwrap.dedent( - """ -session_name: Should not execute -windows: -- panes: - - shell_command: echo "___$((1 + 3))___" - enter: false - """ - ), - "___4___", - False, - ], - [ - textwrap.dedent( - """ -session_name: Should not execute -windows: -- panes: - - shell_command: - - echo "___$((1 + 3))___" - enter: false - """ - ), - "___4___", - False, - ], - [ - textwrap.dedent( - """ -session_name: Should execute -windows: -- panes: - - shell_command: echo "___$((1 + 3))___" - """ - ), - "___4___", - True, - ], - [ - textwrap.dedent( - """ -session_name: Should execute -windows: -- panes: - - shell_command: - - echo "___$((1 + 3))___" - """ - ), - "___4___", - True, - ], - [ - textwrap.dedent( - """ -session_name: Should not execute -windows: -- panes: - - shell_command: - - cmd: echo "___$((1 + 3))___" - enter: false - """ - ), - "___4___", - False, - ], - [ - textwrap.dedent( - """ -session_name: Should not execute -windows: -- panes: - - shell_command: - - cmd: echo "___$((1 + 3))___" - enter: false - """ - ), - "___4___", - False, - ], - [ - textwrap.dedent( - """ -session_name: Should execute -windows: -- panes: - - shell_command: echo "___$((1 + 3))___" - """ - ), - "___4___", - True, - ], - [ - textwrap.dedent( - """ -session_name: Should execute -windows: -- panes: - - shell_command: - - cmd: echo "other command" - - cmd: echo "___$((1 + 3))___" - """ - ), - "___4___", - True, - ], - ], - ids=[ - "pane_enter_false_shortform", - "pane_enter_false_longform", - "pane_enter_default_shortform", - "pane_enter_default_longform", - "pane_command_enter_false_shortform", - "pane_command_enter_false_longform", - "pane_command_enter_default_shortform", - "pane_command_enter_default_longform", - ], -) -def test_load_workspace_enter( - tmp_path: pathlib.Path, - server: libtmux.Server, - monkeypatch: pytest.MonkeyPatch, - yaml, - output, - should_see, -): - yaml_config = tmp_path / "simple.yaml" - yaml_config.write_text( - yaml, - encoding="utf-8", - ) - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(str(yaml_config)).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - builder = WorkspaceBuilder(sconf=sconfig, server=server) - builder.build() - - time.sleep(1) - - session = builder.session - pane = session.attached_pane - - captured_pane = "\n".join(pane.capture_pane()) - - if should_see: - assert output in captured_pane - else: - assert output not in captured_pane - - -@pytest.mark.parametrize( - "yaml,sleep,output", - [ - [ - textwrap.dedent( - """ -session_name: Should not execute -windows: -- panes: - - shell_command: - - cmd: echo "___$((1 + 5))___" - sleep_before: 2 - - cmd: echo "___$((1 + 3))___" - sleep_before: 1 - """ - ), - 1.5, - "___4___", - ], - [ - textwrap.dedent( - """ -session_name: Should not execute -windows: -- panes: - - shell_command: - - cmd: echo "___$((1 + 5))___" - sleep_before: 2 - - cmd: echo "___$((1 + 3))___" - sleep_before: 1 - """ - ), - 3, - "___4___", - ], - [ - textwrap.dedent( - """ -session_name: Should not execute -windows: -- panes: - - shell_command: - - cmd: echo "___$((1 + 3))___" - sleep_before: 2 - """ - ), - 2, - "___4___", - ], - [ - textwrap.dedent( - """ -session_name: Should not execute -windows: -- panes: - - shell_command: - - cmd: echo "___$((1 + 3))___" - sleep_before: 2 - """ - ), - 2, - "___4___", - ], - [ - textwrap.dedent( - """ -session_name: Should not execute -shell_command_before: - - cmd: echo "sleeping before" - sleep_before: 2 -windows: -- panes: - - echo "___$((1 + 3))___" - """ - ), - 2, - "___4___", - ], - ], - ids=[ - "command_level_sleep_3_shortform", - "command_level_pane_sleep_3_longform", - "pane_sleep_2_shortform", - "pane_sleep_2_longform", - "shell_before_before_command_level", - ], -) -@pytest.mark.flaky(reruns=3) -def test_load_workspace_sleep( - tmp_path: pathlib.Path, - server: libtmux.Server, - monkeypatch: pytest.MonkeyPatch, - yaml, - sleep: int, - output, -): - yaml_config = tmp_path / "simple.yaml" - yaml_config.write_text( - yaml, - encoding="utf-8", - ) - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(str(yaml_config)).get() - sconfig = config.expand(sconfig) - sconfig = config.trickle(sconfig) - builder = WorkspaceBuilder(sconf=sconfig, server=server) - - t = time.process_time() - - builder.build() - time.sleep(0.5) - session = builder.session - pane = session.attached_pane - - while (time.process_time() - t) * 1000 < sleep: - captured_pane = "\n".join(pane.capture_pane()) - - assert output not in captured_pane - time.sleep(0.1) - captured_pane = "\n".join(pane.capture_pane()) - assert output in captured_pane diff --git a/tests/test_workspacefreezer.py b/tests/test_workspacefreezer.py deleted file mode 100644 index 659295ae84..0000000000 --- a/tests/test_workspacefreezer.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Test for tmuxp workspacefreezer.""" -import time - -import kaptan - -from tmuxp import config -from tmuxp.workspacebuilder import WorkspaceBuilder, freeze - -from .fixtures import utils as test_utils - - -def test_freeze_config(session): - yaml_config = test_utils.read_config_file("workspacefreezer/sampleconfig.yaml") - sconfig = kaptan.Kaptan(handler="yaml") - sconfig = sconfig.import_config(yaml_config).get() - - builder = WorkspaceBuilder(sconf=sconfig) - builder.build(session=session) - assert session == builder.session - - time.sleep(0.50) - - session = session - sconf = freeze(session) - - config.validate_schema(sconf) - - sconf = config.inline(sconf) - - kaptanconf = kaptan.Kaptan() - kaptanconf = kaptanconf.import_config(sconf) - kaptanconf.export("json", indent=2) - kaptanconf.export("yaml", indent=2, default_flow_style=False, safe=True) diff --git a/tests/tests/__init__.py b/tests/tests/__init__.py index e69de29bb2..f887a3d06e 100644 --- a/tests/tests/__init__.py +++ b/tests/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for tmuxp's pytest helpers.""" diff --git a/tests/tests/test_helpers.py b/tests/tests/test_helpers.py index 6418cce579..aa04cee8c0 100644 --- a/tests/tests/test_helpers.py +++ b/tests/tests/test_helpers.py @@ -1,10 +1,19 @@ -"""Tests for .'s helper and utility functions.""" +"""Tests for tmuxp's helper and utility functions.""" + +from __future__ import annotations + +import typing as t + import pytest +from libtmux.test.random import get_test_session_name +from libtmux.test.temporary import temp_session -from libtmux.test import get_test_session_name, temp_session +if t.TYPE_CHECKING: + from libtmux.server import Server -def test_kills_session(server): +def test_temp_session_kills_session_on_exit(server: Server) -> None: + """Test temp_session() context manager kills session on exit.""" server = server session_name = get_test_session_name(server=server) @@ -16,14 +25,12 @@ def test_kills_session(server): @pytest.mark.flaky(reruns=5) -def test_if_session_killed_before(server): - """Handles situation where session already closed within context""" - +def test_temp_session_if_session_killed_before_exit(server: Server) -> None: + """Handles situation where session already closed within context.""" server = server session_name = get_test_session_name(server=server) with temp_session(server=server, session_name=session_name): - # an error or an exception within a temp_session kills the session server.kill_session(session_name) diff --git a/tests/workspace/__init__.py b/tests/workspace/__init__.py new file mode 100644 index 0000000000..ec8ccc92b4 --- /dev/null +++ b/tests/workspace/__init__.py @@ -0,0 +1 @@ +"""Workspace tests for tmuxp.""" diff --git a/tests/workspace/conftest.py b/tests/workspace/conftest.py new file mode 100644 index 0000000000..403189710a --- /dev/null +++ b/tests/workspace/conftest.py @@ -0,0 +1,27 @@ +"""Pytest configuration for tmuxp workspace tests.""" + +from __future__ import annotations + +import types + +import pytest + +from tests.fixtures.structures import WorkspaceTestData + + +@pytest.fixture +def config_fixture() -> WorkspaceTestData: + """Deferred import of tmuxp.tests.fixtures.*. + + pytest setup (conftest.py) patches os.environ["HOME"], delay execution of + os.path.expanduser until here. + """ + from tests.fixtures import workspace as test_workspace_data + + return WorkspaceTestData( + **{ + k: v + for k, v in test_workspace_data.__dict__.items() + if isinstance(v, types.ModuleType) + }, + ) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py new file mode 100644 index 0000000000..32cbdffcf4 --- /dev/null +++ b/tests/workspace/test_builder.py @@ -0,0 +1,1930 @@ +"""Test for tmuxp workspace builder.""" + +from __future__ import annotations + +import functools +import logging +import os +import pathlib +import textwrap +import time +import typing as t + +import libtmux +import pytest +from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.exc import LibTmuxException +from libtmux.pane import Pane +from libtmux.session import Session +from libtmux.test.retry import retry_until +from libtmux.test.temporary import temp_session +from libtmux.window import Window + +from tests.constants import EXAMPLE_PATH, FIXTURE_PATH +from tests.fixtures import utils as test_utils +from tmuxp import exc +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.cli.load import load_plugins +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder, classic as builder_classic +from tmuxp.workspace.builder.classic import _wait_for_pane_ready + +if t.TYPE_CHECKING: + from libtmux.server import Server + + class AssertCallbackProtocol(t.Protocol): + """Assertion callback type protocol.""" + + def __call__(self, cmd: str, hist: str) -> bool: + """Run function code for testing assertion.""" + ... + + +def test_split_windows(session: Session) -> None: + """Test workspace builder splits windows in a tmux session.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/two_pane.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + window_count = len(session.windows) # current window count + assert len(session.windows) == window_count + for w, wconf in builder.iter_create_windows(session): + for p in builder.iter_create_panes(w, wconf): + w.select_layout("tiled") # fix glitch with pane size + p = p + assert len(session.windows) == window_count + assert isinstance(w, Window) + + assert len(session.windows) == window_count + window_count += 1 + + +def test_split_windows_three_pane(session: Session) -> None: + """Test workspace builder splits windows in a tmux session.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/three_pane.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + window_count = len(session.windows) # current window count + assert len(session.windows) == window_count + for w, wconf in builder.iter_create_windows(session): + for p in builder.iter_create_panes(w, wconf): + w.select_layout("tiled") # fix glitch with pane size + p = p + assert len(session.windows) == window_count + assert isinstance(w, Window) + + assert len(session.windows) == window_count + window_count += 1 + w.set_option("main-pane-height", 50) + w.select_layout(wconf["layout"]) + + +def test_focus_pane_index(session: Session) -> None: + """Test focus of pane by index works correctly, including with pane-base-index.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/focus_and_pane.yaml"), + ) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + builder.build(session=session) + + assert session.active_window.name == "focused window" + + pane_base_index_ = session.active_window.show_option( + "pane-base-index", + global_=True, + ) + assert isinstance(pane_base_index_, int) + pane_base_index = int(pane_base_index_) + + pane_base_index = 0 if not pane_base_index else int(pane_base_index) + + # get the pane index for each pane + pane_base_indexes = [ + int(pane.index) + for pane in session.active_window.panes + if pane is not None and pane.index is not None + ] + + pane_indexes_should_be = [pane_base_index + x for x in range(3)] + assert pane_indexes_should_be == pane_base_indexes + + w = session.active_window + + assert w.name != "man" + + pane_path = "/usr" + p = None + + def f_check() -> bool: + nonlocal p + p = w.active_pane + assert p is not None + return p.pane_current_path == pane_path + + assert retry_until(f_check) + + assert p is not None + assert p.pane_current_path == pane_path + + proc = session.cmd("show-option", "-gv", "base-index") + base_index = int(proc.stdout[0]) + + window3 = session.windows.get(window_index=str(base_index + 2)) + assert isinstance(window3, Window) + + p = None + pane_path = "/" + + def f_check_again() -> bool: + nonlocal p + p = window3.active_pane + assert p is not None + return p.pane_current_path == pane_path + + assert retry_until(f_check_again) + + assert p is not None + assert p.pane_current_path is not None + assert isinstance(p.pane_current_path, str) + assert p.pane_current_path == pane_path + + +@pytest.mark.skip( + reason=""" +Test needs to be rewritten, assertion not reliable across platforms +and CI. See https://github.com/tmux-python/tmuxp/issues/310. + """.strip(), +) +def test_suppress_history(session: Session) -> None: + """Test suppression of command history.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/suppress_history.yaml"), + ) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + inHistoryWindow = session.windows.get(window_name="inHistory") + assert inHistoryWindow is not None + isMissingWindow = session.windows.get(window_name="isMissing") + assert isMissingWindow is not None + + def assertHistory(cmd: str, hist: str) -> bool: + return "inHistory" in cmd and cmd.endswith(hist) + + def assertIsMissing(cmd: str, hist: str) -> bool: + return "isMissing" in cmd and not cmd.endswith(hist) + + for w, window_name, assertCase in [ + (inHistoryWindow, "inHistory", assertHistory), + (isMissingWindow, "isMissing", assertIsMissing), + ]: + assert w.name == window_name + w.select() + p = w.active_pane + assert p is not None + p.select() + + # Print the last-in-history command in the pane + p.cmd("send-keys", " fc -ln -1") + p.cmd("send-keys", "Enter") + + buffer_name = "test" + sent_cmd = None + + def f(p: Pane, buffer_name: str, assertCase: AssertCallbackProtocol) -> bool: + # from v0.7.4 libtmux session.cmd adds target -t self.id by default + # show-buffer doesn't accept -t, use global cmd. + + # Get the contents of the pane + p.cmd("capture-pane", "-b", buffer_name) + + captured_pane = session.server.cmd("show-buffer", "-b", buffer_name) + session.server.cmd("delete-buffer", "-b", buffer_name) + + # Parse the sent and last-in-history commands + sent_cmd = captured_pane.stdout[0].strip() + history_cmd = captured_pane.stdout[-2].strip() + + return assertCase(sent_cmd, history_cmd) + + f_ = functools.partial(f, p=p, buffer_name=buffer_name, assertCase=assertCase) + + assert retry_until(f_), f"Unknown sent command: [{sent_cmd}] in {assertCase}" + + +def test_session_options(session: Session) -> None: + """Test setting of options to session scope.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/session_options.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + default_shell = session.show_option("default-shell") + assert isinstance(default_shell, str) + assert "/bin/sh" in default_shell + + default_command = session.show_option("default-command") + assert isinstance(default_command, str) + assert "/bin/sh" in default_command + + +def test_global_options(session: Session) -> None: + """Test setting of global options.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/global_options.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + status_position = session.show_option("status-position", global_=True) + assert isinstance(status_position, str) + assert "top" in status_position + assert session.show_option("repeat-time", global_=True) == 493 + + +def test_global_session_env_options( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test setting of global option variables.""" + visual_silence = "on" + monkeypatch.setenv("VISUAL_SILENCE", str(visual_silence)) + repeat_time = 738 + monkeypatch.setenv("REPEAT_TIME", str(repeat_time)) + main_pane_height = 8 + monkeypatch.setenv("MAIN_PANE_HEIGHT", str(main_pane_height)) + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/env_var_options.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + visual_silence_ = session.show_option("visual-silence", global_=True) + assert isinstance(visual_silence_, bool) + assert visual_silence_ is True + assert repeat_time == session.show_option("repeat-time") + assert main_pane_height == session.active_window.show_option( + "main-pane-height", + ) + + +def test_window_options( + session: Session, +) -> None: + """Test setting of window options.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_options.yaml"), + ) + workspace = loader.expand(workspace) + + workspace["windows"][0]["options"]["pane-border-format"] = " #P " + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + window_count = len(session.windows) # current window count + assert len(session.windows) == window_count + for w, wconf in builder.iter_create_windows(session): + for p in builder.iter_create_panes(w, wconf): + w.select_layout("tiled") # fix glitch with pane size + p = p + assert len(session.windows) == window_count + assert isinstance(w, Window) + assert w.show_option("main-pane-height") == 5 + assert w.show_option("pane-border-format") == " #P " + + assert len(session.windows) == window_count + window_count += 1 + w.select_layout(wconf["layout"]) + + +@pytest.mark.flaky(reruns=5) +def test_window_options_after( + session: Session, +) -> None: + """Test setting window options via options_after (WorkspaceBuilder.after_window).""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_options_after.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + def assert_last_line(p: Pane, s: str) -> bool: + def f() -> bool: + pane_out = p.cmd("capture-pane", "-p", "-J").stdout + while not pane_out[-1].strip(): # delete trailing lines tmux 1.8 + pane_out.pop() + return len(pane_out) > 1 and pane_out[-2].strip() == s + + # Print output for easier debugging if assertion fails + return retry_until(f, raises=False) + + for i, pane in enumerate(session.active_window.panes): + assert assert_last_line(pane, str(i)), ( + "Initial command did not execute properly/" + str(i) + ) + pane.cmd("send-keys", "Up") # Will repeat echo + pane.enter() # in each iteration + assert assert_last_line(pane, str(i)), ( + "Repeated command did not execute properly/" + str(i) + ) + + session.cmd("send-keys", " echo moo") + session.cmd("send-keys", "Enter") + + for pane in session.active_window.panes: + assert assert_last_line( + pane, + "moo", + ), "Synchronized command did not execute properly" + + +def test_window_shell( + session: Session, +) -> None: + """Test execution of commands via tmuxp configuration.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_shell.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + for w, wconf in builder.iter_create_windows(session): + if "window_shell" in wconf: + assert wconf["window_shell"] == "top" + + def f(w: Window) -> bool: + return w.window_name != "top" + + f_ = functools.partial(f, w=w) + + retry_until(f_) + + assert w.name != "top" + + +def test_environment_variables( + session: Session, +) -> None: + """Test setting of environmental variables in tmux via workspace builder.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/environment_vars.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session) + # Give slow shells some time to settle as otherwise tests might fail. + time.sleep(0.3) + + assert session.getenv("FOO") == "SESSION" + assert session.getenv("PATH") == "/tmp" + + no_overrides_win = session.windows[0] + pane = no_overrides_win.panes[0] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "SESSION" + + window_overrides_win = session.windows[1] + pane = window_overrides_win.panes[0] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "WINDOW" + + pane_overrides_win = session.windows[2] + pane = pane_overrides_win.panes[0] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "PANE" + + both_overrides_win = session.windows[3] + pane = both_overrides_win.panes[0] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "WINDOW" + pane = both_overrides_win.panes[1] + pane.send_keys("echo $FOO") + assert pane.capture_pane()[1] == "PANE" + + +def test_automatic_rename_option( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test workspace builder with automatic renaming enabled.""" + monkeypatch.setenv("DISABLE_AUTO_TITLE", "true") + monkeypatch.setenv("ROWS", "36") + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_automatic_rename.yaml"), + ) + + # This should be a command guaranteed to be terminal name across systems + portable_command = workspace["windows"][0]["panes"][0]["shell_command"][0]["cmd"] + # If a command is like "man ls", get the command base name, "ls" + if " " in portable_command: + portable_command = portable_command.split(" ")[0] + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + assert builder.session is not None + session: Session = builder.session + w: Window = session.windows[0] + assert len(session.windows) == 1 + + assert w.name != "renamed_window" + + def check_window_name_mismatch() -> bool: + return bool(w.name != portable_command) + + assert retry_until(check_window_name_mismatch, 5, interval=0.25) + + def check_window_name_match() -> bool: + assert w.show_option("automatic-rename") is True + return w.name in { + pathlib.Path(os.getenv("SHELL", "bash")).name, + portable_command, + } + + assert retry_until( + check_window_name_match, + 4, + interval=0.05, + ), f"Window name {w.name} should be {portable_command}" + + w.select_pane("-D") + + assert retry_until(check_window_name_mismatch, 2, interval=0.25) + + +def test_blank_pane_spawn( + session: Session, +) -> None: + """Test various ways of spawning blank panes from a tmuxp configuration. + + :todo: Verify blank panes of various types build into workspaces. + """ + yaml_workspace_file = EXAMPLE_PATH / "blank-panes.yaml" + test_config = ConfigReader._from_file(yaml_workspace_file) + + test_config = loader.expand(test_config) + builder = WorkspaceBuilder(session_config=test_config, server=session.server) + builder.build(session=session) + + assert session == builder.session + + window1 = session.windows.get(window_name="Blank pane test") + assert window1 is not None + assert len(window1.panes) == 3 + + window2 = session.windows.get(window_name="More blank panes") + assert window2 is not None + assert len(window2.panes) == 3 + + window3 = session.windows.get(window_name="Empty string (return)") + assert window3 is not None + assert len(window3.panes) == 3 + + window4 = session.windows.get(window_name="Blank with options") + assert window4 is not None + assert len(window4.panes) == 2 + + +def test_start_directory(session: Session, tmp_path: pathlib.Path) -> None: + """Test workspace builder setting start_directory relative to current directory.""" + test_dir = tmp_path / "foo bar" + test_dir.mkdir() + + yaml_workspace = test_utils.read_workspace_file( + "workspace/builder/start_directory.yaml", + ) + test_config = yaml_workspace.format(TEST_DIR=test_dir) + + workspace = ConfigReader._load(fmt="yaml", content=test_config) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session == builder.session + dirs = ["/usr/bin", "/dev", str(test_dir), "/usr", "/usr"] + + for path, window in zip(dirs, session.windows, strict=False): + for p in window.panes: + + def f(path: str, p: Pane) -> bool: + pane_path = p.pane_current_path + return ( + pane_path is not None and path in pane_path + ) or pane_path == path + + f_ = functools.partial(f, path=path, p=p) + + # handle case with OS X adding /private/ to /tmp/ paths + assert retry_until(f_) + + +def test_start_directory_relative(session: Session, tmp_path: pathlib.Path) -> None: + """Test workspace builder setting start_directory relative to project file. + + Same as above test, but with relative start directory, mimicking + loading it from a location of project file. Like:: + + $ tmuxp load ~/workspace/myproject/.tmuxp.yaml + + instead of:: + + $ cd ~/workspace/myproject/.tmuxp.yaml + $ tmuxp load . + + """ + yaml_workspace = test_utils.read_workspace_file( + "workspace/builder/start_directory_relative.yaml", + ) + + test_dir = tmp_path / "foo bar" + test_dir.mkdir() + config_dir = tmp_path / "testRelConfigDir" + config_dir.mkdir() + + test_config = yaml_workspace.format(TEST_DIR=test_dir) + workspace = ConfigReader._load(fmt="yaml", content=test_config) + # the second argument of os.getcwd() mimics the behavior + # the CLI loader will do, but it passes in the workspace file's location. + workspace = loader.expand(workspace, config_dir) + + workspace = loader.trickle(workspace) + + assert config_dir.exists() + assert test_dir.exists() + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session == builder.session + + dirs = ["/usr/bin", "/dev", str(test_dir), str(config_dir), str(config_dir)] + + for path, window in zip(dirs, session.windows, strict=False): + for p in window.panes: + + def f(path: str, p: Pane) -> bool: + pane_path = p.pane_current_path + return ( + pane_path is not None and path in pane_path + ) or pane_path == path + + f_ = functools.partial(f, path=path, p=p) + + # handle case with OS X adding /private/ to /tmp/ paths + assert retry_until(f_) + + +def test_start_directory_sets_session_path(server: Server) -> None: + """Test start_directory setting path in session_path.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file( + "workspace/builder/start_directory_session_path.yaml", + ), + ) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + session = builder.session + expected = f"{session.id}|/usr" + + cmd = server.cmd("list-sessions", "-F", "#{session_id}|#{session_path}") + assert expected in cmd.stdout + + +def test_pane_order(session: Session) -> None: + """Pane ordering based on position in config and ``pane_index``. + + Regression test for https://github.com/tmux-python/tmuxp/issues/15. + """ + yaml_workspace = test_utils.read_workspace_file( + "workspace/builder/pane_ordering.yaml", + ).format(HOME=str(pathlib.Path().home().resolve())) + + # test order of `panes` (and pane_index) above against pane_dirs + pane_paths = [ + "/usr/bin", + "/usr", + "/etc", + str(pathlib.Path().home().resolve()), + ] + + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + window_count = len(session.windows) # current window count + assert len(session.windows) == window_count + + for w, wconf in builder.iter_create_windows(session): + for _ in builder.iter_create_panes(w, wconf): + w.select_layout("tiled") # fix glitch with pane size + assert len(session.windows) == window_count + + assert isinstance(w, Window) + + assert len(session.windows) == window_count + window_count += 1 + + for w in session.windows: + pane_base_index = w.show_option("pane-base-index", global_=True) + assert pane_base_index is not None + pane_base_index = int(pane_base_index) + for p_index, p in enumerate(w.panes, start=pane_base_index): + assert p.index is not None + assert int(p_index) == int(p.index) + + # pane-base-index start at base-index, pane_paths always start + # at 0 since python list. + pane_path = pane_paths[p_index - pane_base_index] + + def f(pane_path: str, p: Pane) -> bool: + p.refresh() + return p.pane_current_path == pane_path + + f_ = functools.partial(f, pane_path=pane_path, p=p) + + assert retry_until(f_) + + +def test_window_index( + session: Session, +) -> None: + """Test window_index respected by workspace builder.""" + proc = session.cmd("show-option", "-gv", "base-index") + base_index = int(proc.stdout[0]) + name_index_map = {"zero": 0 + base_index, "one": 1 + base_index, "five": 5} + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/window_index.yaml"), + ) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + + for window, _ in builder.iter_create_windows(session): + expected_index = name_index_map[window.window_name] + assert int(window.window_index) == expected_index + + +def test_before_script_throw_error_if_retcode_error( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test tmuxp configuration before_script when command fails.""" + config_script_fails = test_utils.read_workspace_file( + "workspace/builder/config_script_fails.yaml", + ) + yaml_workspace = config_script_fails.format( + script_failed=FIXTURE_PATH / "script_failed.sh", + ) + + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with temp_session(server) as sess: + session_name = sess.name + assert session_name is not None + + with ( + caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), + pytest.raises(exc.BeforeLoadScriptError), + ): + builder.build(session=sess) + + result = server.has_session(session_name) + assert not result, "Kills session if before_script exits with errcode" + + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) >= 1 + assert error_records[0].msg == "before script failed" + assert hasattr(error_records[0], "tmux_session") + + +def test_before_script_throw_error_if_file_not_exists( + server: Server, +) -> None: + """Test tmuxp configuration before_script when script does not exist.""" + config_script_not_exists = test_utils.read_workspace_file( + "workspace/builder/config_script_not_exists.yaml", + ) + yaml_workspace = config_script_not_exists.format( + script_not_exists=FIXTURE_PATH / "script_not_exists.sh", + ) + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with temp_session(server) as session: + session_name = session.name + + assert session_name is not None + temp_session_exists = server.has_session(session_name) + assert temp_session_exists + with pytest.raises((exc.BeforeLoadScriptNotExists, OSError)) as excinfo: + builder.build(session=session) + excinfo.match(r"No such file or directory") + result = server.has_session(session_name) + assert not result, "Kills session if before_script doesn't exist" + + +def test_before_script_true_if_test_passes( + server: Server, +) -> None: + """Test tmuxp configuration before_script when command succeeds.""" + config_script_completes = test_utils.read_workspace_file( + "workspace/builder/config_script_completes.yaml", + ) + script_complete_sh = FIXTURE_PATH / "script_complete.sh" + assert script_complete_sh.exists() + + yaml_workspace = config_script_completes.format(script_complete=script_complete_sh) + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with temp_session(server) as session: + builder.build(session=session) + + +def test_before_script_true_if_test_passes_with_args( + server: Server, +) -> None: + """Test tmuxp configuration before_script when command passes w/ args.""" + config_script_completes = test_utils.read_workspace_file( + "workspace/builder/config_script_completes.yaml", + ) + script_complete_sh = FIXTURE_PATH / "script_complete.sh" + assert script_complete_sh.exists() + + yaml_workspace = config_script_completes.format(script_complete=script_complete_sh) + + workspace = ConfigReader._load(fmt="yaml", content=yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with temp_session(server) as session: + builder.build(session=session) + + +def test_plugin_system_before_workspace_builder( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp configuration plugin hook before workspace builder starts.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/plugin_bwb.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("display-message", "-p", "'#S'") + assert proc.stdout[0] == "'plugin_test_bwb'" + + +def test_plugin_system_on_window_create( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp configuration plugin hooks work on window creation.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/plugin_owc.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("display-message", "-p", "'#W'") + assert proc.stdout[0] == "'plugin_test_owc'" + + +def test_plugin_system_after_window_finished( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp configuration plugin hooks work after windows created.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/plugin_awf.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("display-message", "-p", "'#W'") + assert proc.stdout[0] == "'plugin_test_awf'" + + +def test_plugin_system_on_window_create_multiple_windows( + session: Session, +) -> None: + """Test tmuxp configuration plugin hooks work on windows creation.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file( + "workspace/builder/plugin_owc_multiple_windows.yaml", + ), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("list-windows", "-F", "'#W'") + assert "'plugin_test_owc_mw'" in proc.stdout + assert "'plugin_test_owc_mw_2'" in proc.stdout + + +def test_plugin_system_after_window_finished_multiple_windows( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp configuration plugin hooks work after windows created.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file( + "workspace/builder/plugin_awf_multiple_windows.yaml", + ), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + proc = session.cmd("list-windows", "-F", "'#W'") + assert "'plugin_test_awf_mw'" in proc.stdout + assert "'plugin_test_awf_mw_2'" in proc.stdout + + +def test_plugin_system_multiple_plugins( + monkeypatch_plugin_test_packages: None, + session: Session, +) -> None: + """Test tmuxp plugin system works with multiple plugins.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file( + "workspace/builder/plugin_multiple_plugins.yaml", + ), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=load_plugins(workspace), + server=session.server, + ) + assert len(builder.plugins) > 0 + + builder.build(session=session) + + # Drop through to the before_script plugin hook + proc = session.cmd("display-message", "-p", "'#S'") + assert proc.stdout[0] == "'plugin_test_bwb'" + + # Drop through to the after_window_finished. This won't succeed + # unless on_window_create succeeds because of how the test plugin + # override methods are currently written + proc = session.cmd("display-message", "-p", "'#W'") + assert proc.stdout[0] == "'mp_test_awf'" + + +def test_load_configs_same_session( + server: Server, +) -> None: + """Test tmuxp configuration can be loaded into same session.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/three_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert len(server.sessions) == 1 + assert len(server.sessions[0].windows) == 3 + + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/two_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + assert len(server.sessions) == 2 + assert len(server.sessions[1].windows) == 2 + + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/two_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build(server.sessions[1], True) + + assert len(server.sessions) == 2 + assert len(server.sessions[1].windows) == 4 + + +def test_load_configs_separate_sessions( + server: Server, +) -> None: + """Test workspace builder can load configuration in separate session.""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/three_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert len(server.sessions) == 1 + assert len(server.sessions[0].windows) == 3 + + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/two_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert len(server.sessions) == 2 + assert len(server.sessions[0].windows) == 3 + assert len(server.sessions[1].windows) == 2 + + +def test_find_current_active_pane( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests workspace builder can find the current active pane (and session).""" + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/three_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + workspace = ConfigReader._from_file( + path=test_utils.get_workspace_file("workspace/builder/two_windows.yaml"), + ) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert len(server.sessions) == 2 + + # Assign an active pane to the session + second_session = server.sessions[1] + first_pane_on_second_session_id = second_session.windows[0].panes[0].pane_id + + assert first_pane_on_second_session_id is not None + monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + assert builder.find_current_attached_session() == second_session + + +class WorkspaceEnterFixture(t.NamedTuple): + """Test fixture for workspace enter behavior verification.""" + + test_id: str + yaml: str + output: str + should_see: bool + + +WORKSPACE_ENTER_FIXTURES: list[WorkspaceEnterFixture] = [ + WorkspaceEnterFixture( + test_id="pane_enter_false_shortform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: echo "___$((1 + 3))___" + enter: false + """, + ), + output="___4___", + should_see=False, + ), + WorkspaceEnterFixture( + test_id="pane_enter_false_longform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - echo "___$((1 + 3))___" + enter: false + """, + ), + output="___4___", + should_see=False, + ), + WorkspaceEnterFixture( + test_id="pane_enter_default_shortform", + yaml=textwrap.dedent( + """ +session_name: Should execute +windows: +- panes: + - shell_command: echo "___$((1 + 3))___" + """, + ), + output="___4___", + should_see=True, + ), + WorkspaceEnterFixture( + test_id="pane_enter_default_longform", + yaml=textwrap.dedent( + """ +session_name: Should execute +windows: +- panes: + - shell_command: + - echo "___$((1 + 3))___" + """, + ), + output="___4___", + should_see=True, + ), + WorkspaceEnterFixture( + test_id="pane_command_enter_false_shortform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 3))___" + enter: false + """, + ), + output="___4___", + should_see=False, + ), + WorkspaceEnterFixture( # NOQA: PT014 RUF100 + test_id="pane_command_enter_false_longform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 3))___" + enter: false + """, + ), + output="___4___", + should_see=False, + ), + WorkspaceEnterFixture( # NOQA: PT014 RUF100 + test_id="pane_command_enter_default_shortform", + yaml=textwrap.dedent( + """ +session_name: Should execute +windows: +- panes: + - shell_command: echo "___$((1 + 3))___" + """, + ), + output="___4___", + should_see=True, + ), + WorkspaceEnterFixture( + test_id="pane_command_enter_default_longform", + yaml=textwrap.dedent( + """ +session_name: Should execute +windows: +- panes: + - shell_command: + - cmd: echo "other command" + - cmd: echo "___$((1 + 3))___" + """, + ), + output="___4___", + should_see=True, + ), +] + + +@pytest.mark.parametrize( + list(WorkspaceEnterFixture._fields), + WORKSPACE_ENTER_FIXTURES, + ids=[test.test_id for test in WORKSPACE_ENTER_FIXTURES], +) +def test_load_workspace_enter( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + yaml: str, + output: str, + should_see: bool, +) -> None: + """Test workspace enters commands to panes in tmuxp configuration.""" + yaml_workspace = tmp_path / "simple.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + session = builder.session + assert isinstance(session, Session) + pane = session.active_pane + assert isinstance(pane, Pane) + + def fn() -> bool: + captured_pane = "\n".join(pane.capture_pane()) + + if should_see: + return output in captured_pane + return output not in captured_pane + + assert retry_until( + fn, + 1, + ), f"Should{' ' if should_see else 'not '} output in captured pane" + + +class WorkspaceSleepFixture(t.NamedTuple): + """Test fixture for workspace sleep behavior verification.""" + + test_id: str + yaml: str + sleep: float + output: str + + +WORKSPACE_SLEEP_FIXTURES: list[WorkspaceSleepFixture] = [ + WorkspaceSleepFixture( + test_id="command_level_sleep_shortform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 5))___" + sleep_before: .15 + - cmd: echo "___$((1 + 3))___" + sleep_before: .35 + """, + ), + sleep=0.5, + output="___4___", + ), + WorkspaceSleepFixture( + test_id="command_level_pane_sleep_longform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 5))___" + sleep_before: 1 + - cmd: echo "___$((1 + 3))___" + sleep_before: .25 + """, + ), + sleep=1.25, + output="___4___", + ), + WorkspaceSleepFixture( + test_id="pane_sleep_shortform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 3))___" + sleep_before: .5 + """, + ), + sleep=0.5, + output="___4___", + ), + WorkspaceSleepFixture( + test_id="pane_sleep_longform", + yaml=textwrap.dedent( + """ +session_name: Should not execute +windows: +- panes: + - shell_command: + - cmd: echo "___$((1 + 3))___" + sleep_before: 1 + """, + ), + sleep=1, + output="___4___", + ), + WorkspaceSleepFixture( + test_id="shell_before_before_command_level", + yaml=textwrap.dedent( + """ +session_name: Should not execute +shell_command_before: + - cmd: echo "sleeping before" + sleep_before: .5 +windows: +- panes: + - echo "___$((1 + 3))___" + """, + ), + sleep=0.5, + output="___4___", + ), +] + + +@pytest.mark.parametrize( + list(WorkspaceSleepFixture._fields), + WORKSPACE_SLEEP_FIXTURES, + ids=[test.test_id for test in WORKSPACE_SLEEP_FIXTURES], +) +@pytest.mark.flaky(reruns=3) +def test_load_workspace_sleep( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + yaml: str, + sleep: float, + output: str, +) -> None: + """Test sleep commands in tmuxp configuration.""" + yaml_workspace = tmp_path / "simple.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=server) + + start_time = time.process_time() + + builder.build() + time.sleep(0.5) + session = builder.session + assert isinstance(builder.session, Session) + assert session is not None + pane = session.active_pane + assert isinstance(pane, Pane) + + assert pane is not None + + assert not isinstance(pane.capture_pane, str) + assert callable(pane.capture_pane) + + while (time.process_time() - start_time) * 1000 < sleep: + captured_pane = "\n".join(pane.capture_pane()) + + assert output not in captured_pane + time.sleep(0.1) + + captured_pane = "\n".join(pane.capture_pane()) + assert output in captured_pane + + +def test_first_pane_start_directory(session: Session, tmp_path: pathlib.Path) -> None: + """Test the first pane start_directory sticks.""" + yaml_workspace = test_utils.get_workspace_file( + "workspace/builder/first_pane_start_directory.yaml", + ) + + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session == builder.session + dirs = ["/usr", "/etc"] + + assert session.windows + window = session.windows[0] + for path, p in zip(dirs, window.panes, strict=False): + + def f(path: str, p: Pane) -> bool: + pane_path = p.pane_current_path + return (pane_path is not None and path in pane_path) or pane_path == path + + f_ = functools.partial(f, path=path, p=p) + + # handle case with OS X adding /private/ to /tmp/ paths + assert retry_until(f_) + + +def test_layout_main_horizontal(session: Session) -> None: + """Test that tmux's main-horizontal layout is used when specified.""" + yaml_workspace = test_utils.get_workspace_file("workspace/builder/three_pane.yaml") + workspace = ConfigReader._from_file(path=yaml_workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session.windows + window = session.windows[0] + + assert len(window.panes) == 3 + main_horizontal_pane, *panes = window.panes + + def height(p: Pane) -> int: + return int(p.pane_height) if p.pane_height is not None else 0 + + def width(p: Pane) -> int: + return int(p.pane_width) if p.pane_width is not None else 0 + + main_horizontal_pane_height = height(main_horizontal_pane) + pane_heights = [height(pane) for pane in panes] + # TODO: When libtmux has new pane formatters added, use that to detect top / bottom + assert all( + main_horizontal_pane_height != pane_height for pane_height in pane_heights + ), "The top row should not be the same size as the bottom row (even though it can)" + assert all(pane_heights[0] == pane_height for pane_height in pane_heights), ( + "The bottom row should be uniform height" + ) + assert width(main_horizontal_pane) > width(panes[0]) + + def is_almost_equal(x: int, y: int) -> bool: + return abs(x - y) <= 1 + + assert is_almost_equal(height(panes[0]), height(panes[1])) + assert is_almost_equal(width(panes[0]), width(panes[1])) + + +class DefaultSizeNamespaceFixture(t.NamedTuple): + """Pytest fixture default-size option in tmuxp workspace builder.""" + + # pytest parametrize needs a unique id for each fixture + test_id: str + + # test params + TMUXP_DEFAULT_SIZE: str | None + raises: bool + confoverrides: dict[str, t.Any] + + +DEFAULT_SIZE_FIXTURES = [ + DefaultSizeNamespaceFixture( + test_id="default-behavior", + TMUXP_DEFAULT_SIZE=None, + raises=False, + confoverrides={}, + ), + DefaultSizeNamespaceFixture( + test_id="v1.13.1 default-size-breaks", + TMUXP_DEFAULT_SIZE=None, + raises=True, + confoverrides={"options": {"default-size": "80x24"}}, + ), + DefaultSizeNamespaceFixture( + test_id="v1.13.1-option-workaround", + TMUXP_DEFAULT_SIZE=None, + raises=False, + confoverrides={"options": {"default-size": "800x600"}}, + ), +] + + +@pytest.mark.parametrize( + DefaultSizeNamespaceFixture._fields, + DEFAULT_SIZE_FIXTURES, + ids=[f.test_id for f in DEFAULT_SIZE_FIXTURES], +) +def test_issue_800_default_size_many_windows( + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + TMUXP_DEFAULT_SIZE: str | None, + raises: bool, + confoverrides: dict[str, t.Any], +) -> None: + """Recreate default-size issue. + + v1.13.1 added a default-size, but this can break building workspaces with + a lot of panes. + + See also: https://github.com/tmux-python/tmuxp/issues/800 + + 2024-04-07: This test isn't being used as of this date, as default-size is totally + unused in builder.py. + """ + monkeypatch.setenv("ROWS", "36") + + yaml_workspace = test_utils.get_workspace_file( + "regressions/issue_800_default_size_many_windows.yaml", + ) + + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + if isinstance(confoverrides, dict): + for k, v in confoverrides.items(): + workspace[k] = v + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + if raises: + with pytest.raises( + ( + LibTmuxException, + exc.TmuxpException, + exc.EmptyWorkspaceException, + ObjectDoesNotExist, + ), + ): + builder.build() + + assert builder is not None + assert builder.session is not None + assert isinstance(builder.session, Session) + assert callable(builder.session.kill) + builder.session.kill() + + # tmux 3.7 reworded this error from "no space for new pane" to + # "size or position no space for a new pane"; the optional "a " + # matches both wordings. + with pytest.raises( + libtmux.exc.LibTmuxException, + match=r"no space for (a )?new pane", + ): + builder.build() + return + + builder.build() + assert len(server.sessions) == 1 + + +def test_wait_for_pane_ready_returns_true(session: Session) -> None: + """Verify _wait_for_pane_ready detects shell prompt.""" + pane = session.active_window.active_pane + assert pane is not None + result = _wait_for_pane_ready(pane, timeout=2.0) + assert result is True + + +def test_wait_for_pane_ready_timeout(session: Session) -> None: + """Verify _wait_for_pane_ready returns False on timeout for non-shell.""" + window = session.active_window + assert window.active_pane is not None + new_pane = window.active_pane.split(shell="sleep 999") + assert new_pane is not None + result = _wait_for_pane_ready(new_pane, timeout=0.2) + assert result is False + + +class PaneReadinessFixture(t.NamedTuple): + """Test fixture for pane readiness call count verification.""" + + test_id: str + yaml: str + expected_wait_count: int + + +PANE_READINESS_FIXTURES: list[PaneReadinessFixture] = [ + PaneReadinessFixture( + test_id="waits_for_pane_with_commands", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +workspace_builder_options: + pane_readiness: always +windows: +- panes: + - shell_command: + - cmd: echo hello + - shell_command: + - cmd: echo world +""", + ), + expected_wait_count=2, + ), + PaneReadinessFixture( + test_id="waits_for_pane_without_commands", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +workspace_builder_options: + pane_readiness: always +windows: +- panes: + - shell_command: + - cmd: echo hello + - shell_command: [] +""", + ), + expected_wait_count=2, + ), + PaneReadinessFixture( + test_id="skips_pane_with_custom_shell", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +workspace_builder_options: + pane_readiness: always +windows: +- panes: + - shell_command: + - cmd: echo hello + - shell: sleep 999 + shell_command: + - cmd: echo world +""", + ), + expected_wait_count=1, + ), + PaneReadinessFixture( + test_id="skips_all_panes_with_window_shell", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +workspace_builder_options: + pane_readiness: always +windows: +- window_shell: top + panes: + - shell_command: [] + - shell_command: [] +""", + ), + expected_wait_count=0, + ), +] + + +@pytest.mark.parametrize( + list(PaneReadinessFixture._fields), + PANE_READINESS_FIXTURES, + ids=[t.test_id for t in PANE_READINESS_FIXTURES], +) +def test_pane_readiness_call_count( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + yaml: str, + expected_wait_count: int, +) -> None: + """Verify _wait_for_pane_ready is called only for appropriate panes.""" + call_count = 0 + original = builder_classic._wait_for_pane_ready + + def counting_wait( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, + ) -> bool: + nonlocal call_count + call_count += 1 + return original(pane, timeout=timeout, interval=interval) + + monkeypatch.setattr(builder_classic, "_wait_for_pane_ready", counting_wait) + + yaml_workspace = tmp_path / "readiness.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + assert call_count == expected_wait_count + + +def _count_readiness_waits( + *, + server: Server, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + yaml: str, + shell: str | None = None, +) -> int: + """Build a workspace from ``yaml`` and count ``_wait_for_pane_ready`` calls. + + When ``shell`` is given, the resolved session shell is forced so ``auto`` + detection is deterministic regardless of the shell the suite runs under. + """ + call_count = 0 + original = builder_classic._wait_for_pane_ready + + def counting_wait( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, + ) -> bool: + nonlocal call_count + call_count += 1 + return original(pane, timeout=timeout, interval=interval) + + monkeypatch.setattr(builder_classic, "_wait_for_pane_ready", counting_wait) + if shell is not None: + monkeypatch.setattr( + builder_classic, + "resolve_session_shell", + lambda session, env=None: shell, + ) + + yaml_workspace = tmp_path / "readiness.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = loader.trickle(loader.expand(ConfigReader._from_file(yaml_workspace))) + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + return call_count + + +_TWO_DEFAULT_PANES = """\ +- panes: + - shell_command: + - cmd: echo hello + - shell_command: + - cmd: echo world +""" + + +def _readiness_yaml(windows: str, policy: str | None = None) -> str: + """Compose a readiness workspace with an optional pane_readiness policy.""" + catalog = ( + f"workspace_builder_options:\n pane_readiness: {policy}\n" if policy else "" + ) + return f"session_name: readiness-test\n{catalog}windows:\n{windows}" + + +def test_pane_readiness_auto_waits_for_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wait for default-shell panes under auto when the session shell is zsh.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES), + shell="/usr/bin/zsh", + ) + assert count == 2 + + +def test_pane_readiness_auto_skips_non_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skip the wait under auto for non-zsh shells (the intended speedup).""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES), + shell="/bin/bash", + ) + assert count == 0 + + +def test_pane_readiness_always_waits_non_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wait for default-shell panes under always even for non-zsh shells.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES, policy="always"), + shell="/bin/bash", + ) + assert count == 2 + + +def test_pane_readiness_never_skips_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skip the wait under never even for zsh.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES, policy="never"), + shell="/usr/bin/zsh", + ) + assert count == 0 + + +def test_pane_readiness_custom_shell_skips_under_always( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A window_shell pane skips the wait even when policy is always.""" + windows = textwrap.dedent( + """\ +- window_shell: top + panes: + - shell_command: [] + - shell_command: [] +""", + ) + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(windows, policy="always"), + ) + assert count == 0 + + +def test_select_layout_not_called_after_yield( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify select_layout is called once per pane, not duplicated in build().""" + call_count = 0 + original_select_layout = Window.select_layout + + def counting_layout(self: Window, layout: str | None = None) -> Window: + nonlocal call_count + call_count += 1 + return original_select_layout(self, layout) + + monkeypatch.setattr(Window, "select_layout", counting_layout) + + yaml_config = textwrap.dedent( + """\ +session_name: layout-test +windows: +- layout: main-vertical + panes: + - shell_command: [] + - shell_command: [] + - shell_command: [] +""", + ) + + yaml_workspace = tmp_path / "layout.yaml" + yaml_workspace.write_text(yaml_config, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + # 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6 + assert call_count == 3 + + +def test_builder_logs_session_created( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder.build() logs INFO with tmux_session extra.""" + workspace = { + "session_name": "test_log_session", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + session_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_session") and r.msg == "session created" + ] + assert len(session_logs) >= 1 + assert session_logs[0].tmux_session == "test_log_session" + + # Verify workspace built log + built_logs = [r for r in caplog.records if r.msg == "workspace built"] + assert len(built_logs) >= 1 + + builder.session.kill() + + +def test_builder_logs_window_and_pane_creation( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder logs DEBUG with tmux_window and tmux_pane extra.""" + workspace = { + "session_name": "test_log_wp", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": [{"cmd": "echo hello"}]}, + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + window_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_window") and r.msg == "window created" + ] + assert len(window_logs) >= 1 + assert window_logs[0].tmux_window == "editor" + + pane_logs = [ + r for r in caplog.records if hasattr(r, "tmux_pane") and r.msg == "pane created" + ] + assert len(pane_logs) >= 1 + + cmd_logs = [r for r in caplog.records if r.msg == "sent command %s"] + assert len(cmd_logs) >= 1 + + builder.session.kill() diff --git a/tests/workspace/test_builder_registry.py b/tests/workspace/test_builder_registry.py new file mode 100644 index 0000000000..09dce0b143 --- /dev/null +++ b/tests/workspace/test_builder_registry.py @@ -0,0 +1,188 @@ +"""Tests for workspace builder resolution (:mod:`tmuxp.workspace.builder.registry`).""" + +from __future__ import annotations + +import sys +import textwrap +import typing as t + +import pytest + +from tmuxp import exc +from tmuxp.workspace import loader +from tmuxp.workspace.builder import registry +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.server import Server + +VALID = "tests.fixtures.workspace_builders.valid" +INVALID = "tests.fixtures.workspace_builders.invalid" + + +def test_resolve_default_returns_classic() -> None: + """An absent workspace_builder resolves to the classic builder.""" + assert registry.resolve_builder_class({}) is ClassicWorkspaceBuilder + + +def test_resolve_entry_point_classic() -> None: + """The 'classic' entry point resolves to the classic builder.""" + resolved = registry.resolve_builder_class({"workspace_builder": "classic"}) + assert resolved is ClassicWorkspaceBuilder + + +def test_resolve_dotted_object_reference() -> None: + """A ``module:attr`` reference resolves and validates.""" + from tests.fixtures.workspace_builders.valid import CustomBuilder + + resolved = registry.resolve_builder_class( + {"workspace_builder": f"{VALID}:CustomBuilder"}, + ) + assert resolved is CustomBuilder + + +def test_resolve_dotted_path_reference() -> None: + """A dotted ``module.attr`` path resolves and validates.""" + from tests.fixtures.workspace_builders.valid import CustomBuilder + + resolved = registry.resolve_builder_class( + {"workspace_builder": f"{VALID}.CustomBuilder"}, + ) + assert resolved is CustomBuilder + + +def test_resolve_compat_alias_path() -> None: + """The historical ``tmuxp.workspace.builder:WorkspaceBuilder`` still resolves.""" + resolved = registry.resolve_builder_class( + {"workspace_builder": "tmuxp.workspace.builder:WorkspaceBuilder"}, + ) + assert resolved is ClassicWorkspaceBuilder + + +def test_resolve_not_found_bare_name() -> None: + """An unknown bare name raises WorkspaceBuilderNotFound.""" + with pytest.raises(exc.WorkspaceBuilderNotFound): + registry.resolve_builder_class({"workspace_builder": "nonexistent-builder"}) + + +def test_resolve_import_error() -> None: + """An unimportable dotted path raises WorkspaceBuilderImportError.""" + with pytest.raises(exc.WorkspaceBuilderImportError): + registry.resolve_builder_class( + {"workspace_builder": "tmuxp.does_not_exist:Thing"}, + ) + + +def test_resolve_invalid_builder() -> None: + """A class without a build method raises InvalidWorkspaceBuilder.""" + with pytest.raises(exc.InvalidWorkspaceBuilder): + registry.resolve_builder_class( + {"workspace_builder": f"{INVALID}:NotABuilder"}, + ) + + +def test_resolve_rejects_constructor_without_plugins() -> None: + """A builder whose __init__ lacks plugins is rejected before instantiation. + + The CLI always calls the builder with ``plugins=...``, so such a constructor + would otherwise raise a raw TypeError instead of a styled error. + """ + with pytest.raises(exc.InvalidWorkspaceBuilder): + registry.resolve_builder_class( + {"workspace_builder": f"{INVALID}:MissingPluginsBuilder"}, + ) + + +def test_available_builders_includes_classic() -> None: + """The classic entry point is discoverable.""" + assert "classic" in registry.available_builders() + + +def test_resolve_builder_paths_absent() -> None: + """No workspace_builder_paths resolves to an empty list.""" + assert registry.resolve_builder_paths({}, None) == [] + + +def test_resolve_builder_paths_requires_directory(tmp_path: pathlib.Path) -> None: + """A missing directory raises WorkspaceBuilderPathError.""" + with pytest.raises(exc.WorkspaceBuilderPathError): + registry.resolve_builder_paths( + {"workspace_builder_paths": [str(tmp_path / "missing")]}, + None, + ) + + +def test_resolve_builder_paths_relative_to_workspace_file( + tmp_path: pathlib.Path, +) -> None: + """Relative entries resolve against the workspace file's directory.""" + builders_dir = tmp_path / "builders" + builders_dir.mkdir() + workspace_file = tmp_path / "workspace.yaml" + workspace_file.write_text("session_name: x\n", encoding="utf-8") + + resolved = registry.resolve_builder_paths( + {"workspace_builder_paths": ["builders"]}, + workspace_file, + ) + assert resolved == [builders_dir.resolve()] + + +def test_prepended_sys_path_restores(tmp_path: pathlib.Path) -> None: + """The sandbox restores sys.path exactly on exit.""" + before = list(sys.path) + with registry.prepended_sys_path([tmp_path]): + assert sys.path[0] == str(tmp_path) + assert sys.path == before + + +def test_trusted_path_enables_import(tmp_path: pathlib.Path) -> None: + """A builder in a trusted path imports only while the path is active.""" + sys.modules.pop("ext_builder_trusted", None) + module = tmp_path / "ext_builder_trusted.py" + module.write_text( + textwrap.dedent( + '''\ +"""External builder for trusted-path tests.""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class ExternalBuilder(ClassicWorkspaceBuilder): + """External custom builder.""" +''', + ), + encoding="utf-8", + ) + config = {"workspace_builder": "ext_builder_trusted:ExternalBuilder"} + + with pytest.raises(exc.WorkspaceBuilderImportError): + registry.resolve_builder_class(config) + + paths = registry.resolve_builder_paths( + {"workspace_builder_paths": [str(tmp_path)]}, + None, + ) + with registry.prepended_sys_path(paths): + resolved = registry.resolve_builder_class(config) + assert resolved.__name__ == "ExternalBuilder" + + +def test_resolve_and_build_selected_builder(server: Server) -> None: + """A config selecting a builder builds through the resolved class.""" + config = loader.expand( + { + "session_name": "registry-build", + "workspace_builder": "classic", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + builder_cls = registry.resolve_builder_class(config) + builder = builder_cls(session_config=config, server=server) + builder.build() + assert builder.session.name == "registry-build" + builder.session.kill() diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py new file mode 100644 index 0000000000..fc6d5ccd5b --- /dev/null +++ b/tests/workspace/test_config.py @@ -0,0 +1,379 @@ +"""Test for tmuxp configuration import, inlining, expanding and export.""" + +from __future__ import annotations + +import logging +import pathlib +import typing as t + +import pytest + +from tests.constants import EXAMPLE_PATH +from tmuxp import exc +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import loader, validation + +if t.TYPE_CHECKING: + from tests.fixtures.structures import WorkspaceTestData + + +def load_workspace(path: str | pathlib.Path) -> dict[str, t.Any]: + """Load tmuxp workspace configuration from file.""" + return ConfigReader._from_file( + pathlib.Path(path) if isinstance(path, str) else path, + ) + + +def test_export_json( + tmp_path: pathlib.Path, + config_fixture: WorkspaceTestData, +) -> None: + """Test exporting configuration dictionary to JSON.""" + json_workspace_file = tmp_path / "config.json" + + configparser = ConfigReader(config_fixture.sample_workspace.sample_workspace_dict) + + json_workspace_data = configparser.dump("json", indent=2) + + json_workspace_file.write_text(json_workspace_data, encoding="utf-8") + + new_workspace_data = ConfigReader._from_file(path=json_workspace_file) + assert config_fixture.sample_workspace.sample_workspace_dict == new_workspace_data + + +def test_workspace_expand1(config_fixture: WorkspaceTestData) -> None: + """Expand shell commands from string to list.""" + test_workspace = loader.expand(config_fixture.expand1.before_workspace) + assert test_workspace == config_fixture.expand1.after_workspace() + + +def test_workspace_expand2(config_fixture: WorkspaceTestData) -> None: + """Expand shell commands from string to list.""" + unexpanded_dict = ConfigReader._load( + fmt="yaml", + content=config_fixture.expand2.unexpanded_yaml(), + ) + expanded_dict = ConfigReader._load( + fmt="yaml", + content=config_fixture.expand2.expanded_yaml(), + ) + assert loader.expand(unexpanded_dict) == expanded_dict + + +"""Test config inheritance for the nested 'start_command'.""" + +inheritance_workspace_before = { + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": "~", + "panes": [{"shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], + }, + {"window_name": "shufu", "panes": [{"shell_command": ["htop"]}]}, + {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, + ], +} + +inheritance_workspace_after = { + "session_name": "sample workspace", + "start_directory": "/", + "windows": [ + { + "window_name": "editor", + "start_directory": "~", + "panes": [{"shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], + }, + {"window_name": "shufu", "panes": [{"shell_command": ["htop"]}]}, + {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, + ], +} + + +def test_inheritance_workspace() -> None: + """TODO: Create a test to verify workspace config inheritance to object tree.""" + workspace = inheritance_workspace_before + + # TODO: Look at verifying window_start_directory + # if 'start_directory' in workspace: + # session_start_directory = workspace['start_directory'] + # else: + # session_start_directory = None + + # for windowconfitem in workspace['windows']: + # window_start_directory = None + # + # if 'start_directory' in windowconfitem: + # window_start_directory = windowconfitem['start_directory'] + # elif session_start_directory: + # window_start_directory = session_start_directory + # + # for paneconfitem in windowconfitem['panes']: + # if 'start_directory' in paneconfitem: + # pane_start_directory = paneconfitem['start_directory'] + # elif window_start_directory: + # paneconfitem['start_directory'] = window_start_directory + # elif session_start_directory: + # paneconfitem['start_directory'] = session_start_directory + + assert workspace == inheritance_workspace_after + + +def test_shell_command_before(config_fixture: WorkspaceTestData) -> None: + """Config inheritance for the nested 'start_command'.""" + test_workspace = config_fixture.shell_command_before.config_unexpanded + test_workspace = loader.expand(test_workspace) + + assert test_workspace == config_fixture.shell_command_before.config_expanded() + + test_workspace = loader.trickle(test_workspace) + assert test_workspace == config_fixture.shell_command_before.config_after() + + +def test_in_session_scope(config_fixture: WorkspaceTestData) -> None: + """Verify shell_command before_session is in session scope.""" + sconfig = ConfigReader._load( + fmt="yaml", + content=config_fixture.shell_command_before_session.before, + ) + + validation.validate_schema(sconfig) + + assert loader.expand(sconfig) == sconfig + assert loader.expand(loader.trickle(sconfig)) == ConfigReader._load( + fmt="yaml", + content=config_fixture.shell_command_before_session.expected, + ) + + +def test_trickle_relative_start_directory(config_fixture: WorkspaceTestData) -> None: + """Verify tmuxp config proliferates relative start directory to descendants.""" + test_workspace = loader.trickle(config_fixture.trickle.before) + assert test_workspace == config_fixture.trickle.expected + + +def test_trickle_window_with_no_pane_workspace() -> None: + """Verify tmuxp window config automatically infers a single pane.""" + test_yaml = """ + session_name: test_session + windows: + - window_name: test_1 + panes: + - shell_command: + - ls -l + - window_name: test_no_panes + """ + sconfig = ConfigReader._load(fmt="yaml", content=test_yaml) + validation.validate_schema(sconfig) + + assert loader.expand(loader.trickle(sconfig))["windows"][1]["panes"][0] == { + "shell_command": [], + } + + +def test_expands_blank_panes(config_fixture: WorkspaceTestData) -> None: + """Expand blank config into full form. + + Handle ``NoneType`` and 'blank':: + + # nothing, None, 'blank' + 'panes': [ + None, + 'blank' + ] + + # should be blank + 'panes': [ + 'shell_command': [] + ] + + Blank strings:: + + panes: [ + '' + ] + + # should output to: + panes: + 'shell_command': [''] + + """ + yaml_workspace_file = EXAMPLE_PATH / "blank-panes.yaml" + test_workspace = load_workspace(yaml_workspace_file) + assert loader.expand(test_workspace) == config_fixture.expand_blank.expected + + +def test_no_session_name() -> None: + """Verify exception raised when tmuxp configuration has no session name.""" + yaml_workspace = """ + - window_name: editor + panes: + shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + - window_name: logging + automatic-rename: true + panes: + - shell_command: + - htop + """ + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + with pytest.raises(exc.WorkspaceError) as excinfo: + validation.validate_schema(sconfig) + assert excinfo.match(r'requires "session_name"') + + +def test_no_windows() -> None: + """Verify exception raised when tmuxp configuration has no windows.""" + yaml_workspace = """ + session_name: test session + """ + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + with pytest.raises(exc.WorkspaceError) as excinfo: + validation.validate_schema(sconfig) + assert excinfo.match(r'list of "windows"') + + +def test_no_window_name() -> None: + """Verify exception raised when tmuxp config missing window name.""" + yaml_workspace = """ + session_name: test session + windows: + - window_name: editor + panes: + shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + - automatic-rename: true + panes: + - shell_command: + - htop + """ + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + with pytest.raises(exc.WorkspaceError) as excinfo: + validation.validate_schema(sconfig) + assert excinfo.match('missing "window_name"') + + +def test_replaces_env_variables(monkeypatch: pytest.MonkeyPatch) -> None: + """Test loading configuration resolves environmental variables.""" + env_key = "TESTHEY92" + env_val = "HEYO1" + yaml_workspace = """ + start_directory: {TEST_VAR}/test + shell_command_before: {TEST_VAR}/test2 + before_script: {TEST_VAR}/test3 + session_name: hi - {TEST_VAR} + options: + default-command: {TEST_VAR}/lol + global_options: + default-shell: {TEST_VAR}/moo + windows: + - window_name: editor + panes: + - shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + - window_name: logging @ {TEST_VAR} + automatic-rename: true + panes: + - shell_command: + - htop + """.format(TEST_VAR=f"${{{env_key}}}") + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + monkeypatch.setenv(str(env_key), str(env_val)) + sconfig = loader.expand(sconfig) + assert f"{env_val}/test" == sconfig["start_directory"] + assert ( + f"{env_val}/test2" in sconfig["shell_command_before"]["shell_command"][0]["cmd"] + ) + assert f"{env_val}/test3" == sconfig["before_script"] + assert f"hi - {env_val}" == sconfig["session_name"] + assert f"{env_val}/moo" == sconfig["global_options"]["default-shell"] + assert f"{env_val}/lol" == sconfig["options"]["default-command"] + assert f"logging @ {env_val}" == sconfig["windows"][1]["window_name"] + + +def test_validate_plugins() -> None: + """Test validation of plugins loading via tmuxp configuration file.""" + yaml_workspace = """ + session_name: test session + plugins: tmuxp-plugin-one.plugin.TestPluginOne + windows: + - window_name: editor + panes: + shell_command: + - tail -F /var/log/syslog + start_directory: /var/log + """ + + sconfig = ConfigReader._load(fmt="yaml", content=yaml_workspace) + + with pytest.raises(exc.WorkspaceError) as excinfo: + validation.validate_schema(sconfig) + assert excinfo.match("only supports list type") + + +def test_expand_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """expand() logs DEBUG with tmux_session extra.""" + workspace = {"session_name": "test_expand", "windows": [{"window_name": "main"}]} + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.expand(workspace, cwd=str(tmp_path)) + records = [r for r in caplog.records if r.msg == "expanding workspace config"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_expand" + + +def test_trickle_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """trickle() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_trickle", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.trickle(workspace) + records = [ + r for r in caplog.records if r.msg == "trickling down workspace defaults" + ] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_trickle" + + +def test_validate_schema_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """validate_schema() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_validate", + "windows": [{"window_name": "main"}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.validation"): + validation.validate_schema(workspace) + records = [r for r in caplog.records if r.msg == "validating workspace schema"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_validate" diff --git a/tests/workspace/test_finder.py b/tests/workspace/test_finder.py new file mode 100644 index 0000000000..ab9a69dba4 --- /dev/null +++ b/tests/workspace/test_finder.py @@ -0,0 +1,568 @@ +"""Test config file searching for tmuxp.""" + +from __future__ import annotations + +import argparse +import logging +import pathlib +import typing as t + +import pytest + +from tmuxp import cli +from tmuxp.cli.utils import tmuxp_echo +from tmuxp.workspace.finders import ( + find_local_workspace_files, + find_workspace_file, + get_workspace_dir, + get_workspace_dir_candidates, + in_cwd, + in_dir, + is_pure_name, +) + +if t.TYPE_CHECKING: + import _pytest.capture + + +def test_in_dir_from_config_dir(tmp_path: pathlib.Path) -> None: + """config.in_dir() finds configs config dir.""" + cli.startup(tmp_path) + yaml_config = tmp_path / "myconfig.yaml" + yaml_config.touch() + json_config = tmp_path / "myconfig.json" + json_config.touch() + configs_found = in_dir(tmp_path) + + assert len(configs_found) == 2 + + +def test_ignore_non_configs_from_current_dir(tmp_path: pathlib.Path) -> None: + """cli.in_dir() ignore non-config from config dir.""" + cli.startup(tmp_path) + + junk_config = tmp_path / "myconfig.psd" + junk_config.touch() + conf = tmp_path / "watmyconfig.json" + conf.touch() + configs_found = in_dir(tmp_path) + assert len(configs_found) == 1 + + +def test_get_configs_cwd( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """config.in_cwd() find config in shell current working directory.""" + confdir = tmp_path / "tmuxpconf2" + confdir.mkdir() + + monkeypatch.chdir(confdir) + with pathlib.Path(".tmuxp.json").open("w+b") as config1: + config1.close() + + configs_found = in_cwd() + assert len(configs_found) == 1 + assert ".tmuxp.json" in configs_found + + +class PureNameTestFixture(t.NamedTuple): + """Test fixture for verifying pure name path validation.""" + + test_id: str + path: str + expect: bool + + +PURE_NAME_TEST_FIXTURES: list[PureNameTestFixture] = [ + PureNameTestFixture( + test_id="current_dir", + path=".", + expect=False, + ), + PureNameTestFixture( + test_id="current_dir_slash", + path="./", + expect=False, + ), + PureNameTestFixture( + test_id="empty_path", + path="", + expect=False, + ), + PureNameTestFixture( + test_id="tmuxp_yaml", + path=".tmuxp.yaml", + expect=False, + ), + PureNameTestFixture( + test_id="parent_tmuxp_yaml", + path="../.tmuxp.yaml", + expect=False, + ), + PureNameTestFixture( + test_id="parent_dir", + path="../", + expect=False, + ), + PureNameTestFixture( + test_id="absolute_path", + path="/hello/world", + expect=False, + ), + PureNameTestFixture( + test_id="home_tmuxp_path", + path="~/.tmuxp/hey", + expect=False, + ), + PureNameTestFixture( + test_id="home_work_path", + path="~/work/c/tmux/", + expect=False, + ), + PureNameTestFixture( + test_id="home_work_tmuxp_yaml", + path="~/work/c/tmux/.tmuxp.yaml", + expect=False, + ), + PureNameTestFixture( + test_id="pure_name", + path="myproject", + expect=True, + ), +] + + +@pytest.mark.parametrize( + list(PureNameTestFixture._fields), + PURE_NAME_TEST_FIXTURES, + ids=[test.test_id for test in PURE_NAME_TEST_FIXTURES], +) +def test_is_pure_name( + test_id: str, + path: str, + expect: bool, +) -> None: + """Test is_pure_name() is truthy when file, not directory or config alias.""" + assert is_pure_name(path) == expect + + +def test_tmuxp_configdir_env_var( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests get_workspace_dir() when TMUXP_CONFIGDIR is set.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + + assert get_workspace_dir() == str(tmp_path) + + +def test_tmuxp_configdir_xdg_config_dir( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test get_workspace_dir() when XDG_CONFIG_HOME is set.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + tmux_dir = tmp_path / "tmuxp" + tmux_dir.mkdir() + + assert get_workspace_dir() == str(tmux_dir) + + +@pytest.fixture +def homedir(tmp_path: pathlib.Path) -> pathlib.Path: + """Fixture to ensure and return a home directory.""" + home = tmp_path / "home" + home.mkdir() + return home + + +@pytest.fixture +def configdir(homedir: pathlib.Path) -> pathlib.Path: + """Fixture to ensure user directory for tmuxp and return it, via homedir fixture.""" + conf = homedir / ".tmuxp" + conf.mkdir() + return conf + + +@pytest.fixture +def projectdir(homedir: pathlib.Path) -> pathlib.Path: + """Fixture to ensure and return an example project dir.""" + proj = homedir / "work" / "project" + proj.mkdir(parents=True) + return proj + + +def test_resolve_dot( + tmp_path: pathlib.Path, + homedir: pathlib.Path, + configdir: pathlib.Path, + projectdir: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test find_workspace_file() resolves dots as relative / current directory.""" + monkeypatch.setenv("HOME", str(homedir)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(homedir / ".config")) + + tmuxp_conf_path = projectdir / ".tmuxp.yaml" + tmuxp_conf_path.touch() + user_config_name = "myconfig" + user_config = configdir / f"{user_config_name}.yaml" + user_config.touch() + + project_config = tmuxp_conf_path + + monkeypatch.chdir(projectdir) + + expect = str(project_config) + assert find_workspace_file(".") == expect + assert find_workspace_file("./") == expect + assert find_workspace_file("") == expect + assert find_workspace_file("../project") == expect + assert find_workspace_file("../project/") == expect + assert find_workspace_file(".tmuxp.yaml") == expect + assert find_workspace_file(f"../../.tmuxp/{user_config_name}.yaml") == str( + user_config, + ) + assert find_workspace_file("myconfig") == str(user_config) + assert find_workspace_file("~/.tmuxp/myconfig.yaml") == str(user_config) + + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.json") + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.ini") + with pytest.raises(FileNotFoundError): + find_workspace_file("../") + with pytest.raises(FileNotFoundError): + find_workspace_file("mooooooo") + + monkeypatch.chdir(homedir) + + expect = str(project_config) + assert find_workspace_file("work/project") == expect + assert find_workspace_file("work/project/") == expect + assert find_workspace_file("./work/project") == expect + assert find_workspace_file("./work/project/") == expect + assert find_workspace_file(f".tmuxp/{user_config_name}.yaml") == str(user_config) + assert find_workspace_file(f"./.tmuxp/{user_config_name}.yaml") == str( + user_config, + ) + assert find_workspace_file("myconfig") == str(user_config) + assert find_workspace_file("~/.tmuxp/myconfig.yaml") == str(user_config) + + with pytest.raises(FileNotFoundError): + find_workspace_file("") + with pytest.raises(FileNotFoundError): + find_workspace_file(".") + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.yaml") + with pytest.raises(FileNotFoundError): + find_workspace_file("../") + with pytest.raises(FileNotFoundError): + find_workspace_file("mooooooo") + + monkeypatch.chdir(configdir) + + expect = str(project_config) + assert find_workspace_file("../work/project") == expect + assert find_workspace_file("../../home/work/project") == expect + assert find_workspace_file("../work/project/") == expect + assert find_workspace_file(f"{user_config_name}.yaml") == str(user_config) + assert find_workspace_file(f"./{user_config_name}.yaml") == str(user_config) + assert find_workspace_file("myconfig") == str(user_config) + assert find_workspace_file("~/.tmuxp/myconfig.yaml") == str(user_config) + + with pytest.raises(FileNotFoundError): + find_workspace_file("") + with pytest.raises(FileNotFoundError): + find_workspace_file(".") + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.yaml") + with pytest.raises(FileNotFoundError): + find_workspace_file("../") + with pytest.raises(FileNotFoundError): + find_workspace_file("mooooooo") + + monkeypatch.chdir(tmp_path) + + expect = str(project_config) + assert find_workspace_file("home/work/project") == expect + assert find_workspace_file("./home/work/project/") == expect + assert find_workspace_file(f"home/.tmuxp/{user_config_name}.yaml") == str( + user_config, + ) + assert find_workspace_file(f"./home/.tmuxp/{user_config_name}.yaml") == str( + user_config, + ) + assert find_workspace_file("myconfig") == str(user_config) + assert find_workspace_file("~/.tmuxp/myconfig.yaml") == str(user_config) + + with pytest.raises(FileNotFoundError): + find_workspace_file("") + with pytest.raises(FileNotFoundError): + find_workspace_file(".") + with pytest.raises(FileNotFoundError): + find_workspace_file(".tmuxp.yaml") + with pytest.raises(FileNotFoundError): + find_workspace_file("../") + with pytest.raises(FileNotFoundError): + find_workspace_file("mooooooo") + + +def test_find_workspace_file_arg( + homedir: pathlib.Path, + configdir: pathlib.Path, + projectdir: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test find_workspace_file() via file path.""" + parser = argparse.ArgumentParser() + parser.add_argument("workspace_file", type=str) + + def config_cmd(workspace_file: str) -> None: + tmuxp_echo(find_workspace_file(workspace_file, workspace_dir=configdir)) + + monkeypatch.setenv("HOME", str(homedir)) + tmuxp_config_path = projectdir / ".tmuxp.yaml" + tmuxp_config_path.touch() + user_config_name = "myconfig" + user_config = configdir / f"{user_config_name}.yaml" + user_config.touch() + + project_config = projectdir / ".tmuxp.yaml" + + def check_cmd(config_arg: str) -> _pytest.capture.CaptureResult[str]: + args = parser.parse_args([config_arg]) + config_cmd(workspace_file=args.workspace_file) + return capsys.readouterr() + + monkeypatch.chdir(projectdir) + expect = str(project_config) + assert expect in check_cmd(".").out + assert expect in check_cmd("./").out + assert expect in check_cmd("").out + assert expect in check_cmd("../project").out + assert expect in check_cmd("../project/").out + assert expect in check_cmd(".tmuxp.yaml").out + assert str(user_config) in check_cmd(f"../../.tmuxp/{user_config_name}.yaml").out + assert user_config.stem in check_cmd("myconfig").out + assert str(user_config) in check_cmd("~/.tmuxp/myconfig.yaml").out + + with pytest.raises(FileNotFoundError, match="file not found"): + assert "file not found" in check_cmd(".tmuxp.json").err + with pytest.raises(FileNotFoundError, match="file not found"): + assert "file not found" in check_cmd(".tmuxp.ini").err + with pytest.raises(FileNotFoundError, match="No tmuxp files found"): + assert "No tmuxp files found" in check_cmd("../").err + with pytest.raises( + FileNotFoundError, + 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}" + + +def test_find_workspace_file_logs_warning_on_multiple( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """find_workspace_file() logs WARNING when multiple workspace files found.""" + project = tmp_path / "project" + project.mkdir() + + # Create multiple .tmuxp files in the same directory + (project / ".tmuxp.yaml").write_text("session_name: test") + (project / ".tmuxp.json").write_text('{"session_name": "test"}') + + monkeypatch.chdir(project) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.finders"): + find_workspace_file(str(project)) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "multiple workspace files found" in warning_records[0].message + assert hasattr(warning_records[0], "tmux_config_path") + + out = capsys.readouterr().out + assert "Multiple .tmuxp." in out + assert "undefined behavior" in out + + +def test_find_local_workspace_files_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """find_local_workspace_files() logs DEBUG with tmux_config_path extra.""" + project = tmp_path / "project" + project.mkdir() + (project / ".tmuxp.yaml").write_text("session_name: test") + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.finders"): + find_local_workspace_files(project, stop_at_home=False) + + records = [ + r + for r in caplog.records + if r.msg == "searching for local workspace files from %s" + ] + assert len(records) >= 1 + assert hasattr(records[0], "tmux_config_path") diff --git a/tests/workspace/test_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/tests/workspace/test_freezer.py b/tests/workspace/test_freezer.py new file mode 100644 index 0000000000..d42386ecef --- /dev/null +++ b/tests/workspace/test_freezer.py @@ -0,0 +1,136 @@ +"""Tests tmux session freezing functionality for tmuxp.""" + +from __future__ import annotations + +import logging +import time +import typing + +import pytest + +from tests.fixtures import utils as test_utils +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import freezer, validation +from tmuxp.workspace.builder import WorkspaceBuilder + +if typing.TYPE_CHECKING: + import pathlib + + from libtmux.session import Session + + from tests.fixtures.structures import WorkspaceTestData + + +def test_freeze_config(session: Session) -> None: + """Test freezing a tmux session.""" + session_config = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/freezer/sample_workspace.yaml"), + ) + + builder = WorkspaceBuilder(session_config=session_config, server=session.server) + builder.build(session=session) + assert session == builder.session + + time.sleep(0.50) + + session = session + new_config = freezer.freeze(session) + + validation.validate_schema(new_config) + + # These should dump without an error + ConfigReader._dump(fmt="json", content=new_config) + ConfigReader._dump(fmt="yaml", content=new_config) + + # Inline configs should also dump without an error + compact_config = freezer.inline(new_config) + + ConfigReader._dump(fmt="json", content=compact_config) + ConfigReader._dump(fmt="yaml", content=compact_config) + + +"""Tests for :meth:`freezer.inline()`.""" + +ibefore_workspace = { # inline config + "session_name": "sample workspace", + "start_directory": "~", + "windows": [ + { + "shell_command": ["top"], + "window_name": "editor", + "panes": [{"shell_command": ["vim"]}, {"shell_command": ['cowsay "hey"']}], + "layout": "main-vertical", + }, + { + "window_name": "logging", + "panes": [{"shell_command": ["tail -F /var/log/syslog"]}], + }, + {"options": {"automatic-rename": True}, "panes": [{"shell_command": ["htop"]}]}, + ], +} + +iafter_workspace = { + "session_name": "sample workspace", + "start_directory": "~", + "windows": [ + { + "shell_command": "top", + "window_name": "editor", + "panes": ["vim", 'cowsay "hey"'], + "layout": "main-vertical", + }, + {"window_name": "logging", "panes": ["tail -F /var/log/syslog"]}, + {"options": {"automatic-rename": True}, "panes": ["htop"]}, + ], +} + + +def test_inline_workspace() -> None: + """:meth:`freezer.inline()` shell commands list to string.""" + test_workspace = freezer.inline(ibefore_workspace) + assert test_workspace == iafter_workspace + + +def test_export_yaml( + tmp_path: pathlib.Path, + config_fixture: WorkspaceTestData, +) -> None: + """Test exporting a frozen tmux session to YAML.""" + yaml_workspace_file = tmp_path / "config.yaml" + + sample_workspace = freezer.inline( + config_fixture.sample_workspace.sample_workspace_dict, + ) + configparser = ConfigReader(sample_workspace) + + yaml_workspace_data = configparser.dump("yaml", indent=2, default_flow_style=False) + + yaml_workspace_file.write_text(yaml_workspace_data, encoding="utf-8") + + new_workspace_data = ConfigReader._from_file(yaml_workspace_file) + assert config_fixture.sample_workspace.sample_workspace_dict == new_workspace_data + + +def test_freeze_logs_debug( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """freeze() logs DEBUG with tmux_session extra.""" + session_config = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/freezer/sample_workspace.yaml"), + ) + builder = WorkspaceBuilder(session_config=session_config, server=session.server) + builder.build(session=session) + + time.sleep(0.50) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.freezer"): + freezer.freeze(session) + + freeze_records = [r for r in caplog.records if r.msg == "freezing session"] + assert len(freeze_records) >= 1 + assert hasattr(freeze_records[0], "tmux_session") + + window_records = [r for r in caplog.records if r.msg == "frozen window"] + assert len(window_records) >= 1 + assert hasattr(window_records[0], "tmux_window") diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py new file mode 100644 index 0000000000..0ea457e7c6 --- /dev/null +++ b/tests/workspace/test_import_teamocil.py @@ -0,0 +1,159 @@ +"""Test for tmuxp teamocil configuration.""" + +from __future__ import annotations + +import logging +import typing as t + +import pytest + +from tests.fixtures import import_teamocil as fixtures +from tmuxp._internal import config_reader +from tmuxp.workspace import importers, validation + + +class TeamocilConfigTestFixture(t.NamedTuple): + """Test fixture for teamocil config conversion tests.""" + + test_id: str + teamocil_yaml: str + teamocil_dict: dict[str, t.Any] + tmuxp_dict: dict[str, t.Any] + + +TEAMOCIL_CONFIG_TEST_FIXTURES: list[TeamocilConfigTestFixture] = [ + TeamocilConfigTestFixture( + test_id="test1", + teamocil_yaml=fixtures.test1.teamocil_yaml, + teamocil_dict=fixtures.test1.teamocil_conf, + tmuxp_dict=fixtures.test1.expected, + ), + TeamocilConfigTestFixture( + test_id="test2", + teamocil_yaml=fixtures.test2.teamocil_yaml, + teamocil_dict=fixtures.test2.teamocil_dict, + tmuxp_dict=fixtures.test2.expected, + ), + TeamocilConfigTestFixture( + test_id="test3", + teamocil_yaml=fixtures.test3.teamocil_yaml, + teamocil_dict=fixtures.test3.teamocil_dict, + tmuxp_dict=fixtures.test3.expected, + ), + TeamocilConfigTestFixture( + test_id="test4", + teamocil_yaml=fixtures.test4.teamocil_yaml, + teamocil_dict=fixtures.test4.teamocil_dict, + tmuxp_dict=fixtures.test4.expected, + ), +] + + +@pytest.mark.parametrize( + list(TeamocilConfigTestFixture._fields), + TEAMOCIL_CONFIG_TEST_FIXTURES, + ids=[test.test_id for test in TEAMOCIL_CONFIG_TEST_FIXTURES], +) +def test_config_to_dict( + test_id: str, + teamocil_yaml: str, + teamocil_dict: dict[str, t.Any], + tmuxp_dict: dict[str, t.Any], +) -> None: + """Test exporting teamocil configuration to dictionary.""" + yaml_to_dict = config_reader.ConfigReader._load( + fmt="yaml", + content=teamocil_yaml, + ) + assert yaml_to_dict == teamocil_dict + + assert importers.import_teamocil(teamocil_dict) == tmuxp_dict + + validation.validate_schema(importers.import_teamocil(teamocil_dict)) + + +@pytest.fixture(scope="module") +def multisession_config() -> dict[ + str, + dict[str, t.Any], +]: + """Return loaded multisession teamocil config as a dictionary. + + Also prevents re-running assertion the loads the yaml, since ordering of + deep list items like panes will be inconsistent. + """ + teamocil_yaml_file = fixtures.layouts.teamocil_yaml_file + test_config = config_reader.ConfigReader._from_file(teamocil_yaml_file) + teamocil_dict: dict[str, t.Any] = fixtures.layouts.teamocil_dict + + assert test_config == teamocil_dict + return teamocil_dict + + +class TeamocilMultiSessionTestFixture(t.NamedTuple): + """Test fixture for teamocil multisession config tests.""" + + test_id: str + session_name: str + expected: dict[str, t.Any] + + +TEAMOCIL_MULTISESSION_TEST_FIXTURES: list[TeamocilMultiSessionTestFixture] = [ + TeamocilMultiSessionTestFixture( + test_id="basic_two_windows", + session_name="two-windows", + expected=fixtures.layouts.two_windows, + ), + TeamocilMultiSessionTestFixture( + test_id="two_windows_with_filters", + session_name="two-windows-with-filters", + expected=fixtures.layouts.two_windows_with_filters, + ), + TeamocilMultiSessionTestFixture( + test_id="two_windows_with_custom_command_options", + session_name="two-windows-with-custom-command-options", + expected=fixtures.layouts.two_windows_with_custom_command_options, + ), + TeamocilMultiSessionTestFixture( + test_id="three_windows_within_session", + session_name="three-windows-within-a-session", + expected=fixtures.layouts.three_windows_within_a_session, + ), +] + + +@pytest.mark.parametrize( + list(TeamocilMultiSessionTestFixture._fields), + TEAMOCIL_MULTISESSION_TEST_FIXTURES, + ids=[test.test_id for test in TEAMOCIL_MULTISESSION_TEST_FIXTURES], +) +def test_multisession_config( + test_id: str, + session_name: str, + expected: dict[str, t.Any], + multisession_config: dict[str, t.Any], +) -> None: + """Test importing teamocil multisession configuration.""" + # teamocil can fit multiple sessions in a config + assert importers.import_teamocil(multisession_config[session_name]) == expected + + validation.validate_schema( + importers.import_teamocil(multisession_config[session_name]), + ) + + +def test_import_teamocil_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_teamocil() logs DEBUG record.""" + workspace = { + "session": { + "name": "test", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + records = [r for r in caplog.records if r.msg == "importing teamocil workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py new file mode 100644 index 0000000000..457605f2ab --- /dev/null +++ b/tests/workspace/test_import_tmuxinator.py @@ -0,0 +1,78 @@ +"""Test for tmuxp tmuxinator configuration.""" + +from __future__ import annotations + +import logging +import typing as t + +import pytest + +from tests.fixtures import import_tmuxinator as fixtures +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import importers, validation + + +class TmuxinatorConfigTestFixture(t.NamedTuple): + """Test fixture for tmuxinator config conversion tests.""" + + test_id: str + tmuxinator_yaml: str + tmuxinator_dict: dict[str, t.Any] + tmuxp_dict: dict[str, t.Any] + + +TMUXINATOR_CONFIG_TEST_FIXTURES: list[TmuxinatorConfigTestFixture] = [ + TmuxinatorConfigTestFixture( + test_id="basic_config", + tmuxinator_yaml=fixtures.test1.tmuxinator_yaml, + tmuxinator_dict=fixtures.test1.tmuxinator_dict, + tmuxp_dict=fixtures.test1.expected, + ), + TmuxinatorConfigTestFixture( + test_id="legacy_tabs_config", # older vers use `tabs` instead of `windows` + tmuxinator_yaml=fixtures.test2.tmuxinator_yaml, + tmuxinator_dict=fixtures.test2.tmuxinator_dict, + tmuxp_dict=fixtures.test2.expected, + ), + TmuxinatorConfigTestFixture( + test_id="sample_config", # Test importing + tmuxinator_yaml=fixtures.test3.tmuxinator_yaml, + tmuxinator_dict=fixtures.test3.tmuxinator_dict, + tmuxp_dict=fixtures.test3.expected, + ), +] + + +@pytest.mark.parametrize( + list(TmuxinatorConfigTestFixture._fields), + TMUXINATOR_CONFIG_TEST_FIXTURES, + ids=[test.test_id for test in TMUXINATOR_CONFIG_TEST_FIXTURES], +) +def test_config_to_dict( + test_id: str, + tmuxinator_yaml: str, + tmuxinator_dict: dict[str, t.Any], + tmuxp_dict: dict[str, t.Any], +) -> None: + """Test exporting tmuxinator configuration to dictionary.""" + yaml_to_dict = ConfigReader._load(fmt="yaml", content=tmuxinator_yaml) + assert yaml_to_dict == tmuxinator_dict + + assert importers.import_tmuxinator(tmuxinator_dict) == tmuxp_dict + + validation.validate_schema(importers.import_tmuxinator(tmuxinator_dict)) + + +def test_import_tmuxinator_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_tmuxinator() logs DEBUG record.""" + workspace = { + "name": "test", + "windows": [{"main": ["echo hi"]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + records = [r for r in caplog.records if r.msg == "importing tmuxinator workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" diff --git a/tests/workspace/test_options.py b/tests/workspace/test_options.py new file mode 100644 index 0000000000..c7c9aa369b --- /dev/null +++ b/tests/workspace/test_options.py @@ -0,0 +1,150 @@ +"""Tests for workspace builder options (:mod:`tmuxp.workspace.options`).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp import exc +from tmuxp.workspace.options import ( + PaneReadiness, + WorkspaceBuilderOptions, + resolve_session_shell, + shell_is_zsh, +) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, PaneReadiness.AUTO), + ("auto", PaneReadiness.AUTO), + ("AUTO", PaneReadiness.AUTO), + (True, PaneReadiness.ALWAYS), + ("always", PaneReadiness.ALWAYS), + ("on", PaneReadiness.ALWAYS), + ("yes", PaneReadiness.ALWAYS), + ("1", PaneReadiness.ALWAYS), + (1, PaneReadiness.ALWAYS), + (False, PaneReadiness.NEVER), + ("never", PaneReadiness.NEVER), + ("off", PaneReadiness.NEVER), + ("no", PaneReadiness.NEVER), + ("0", PaneReadiness.NEVER), + (0, PaneReadiness.NEVER), + (PaneReadiness.ALWAYS, PaneReadiness.ALWAYS), + ], +) +def test_pane_readiness_from_config(value: t.Any, expected: PaneReadiness) -> None: + """from_config maps canonical values and truthy/falsy aliases.""" + assert PaneReadiness.from_config(value) is expected + + +@pytest.mark.parametrize("value", ["sometimes", "maybe", "2", ""]) +def test_pane_readiness_from_config_invalid(value: str) -> None: + """from_config rejects unknown values with an actionable error.""" + with pytest.raises(ValueError, match="pane_readiness"): + PaneReadiness.from_config(value) + + +def test_workspace_builder_options_defaults() -> None: + """An absent catalog yields AUTO readiness.""" + assert WorkspaceBuilderOptions.from_config({}).pane_readiness is PaneReadiness.AUTO + cfg = {"session_name": "x", "windows": []} + assert WorkspaceBuilderOptions.from_config(cfg).pane_readiness is PaneReadiness.AUTO + + +def test_workspace_builder_options_reads_catalog() -> None: + """from_config reads pane_readiness from the catalog.""" + cfg = {"workspace_builder_options": {"pane_readiness": "never"}} + options = WorkspaceBuilderOptions.from_config(cfg) + assert options.pane_readiness is PaneReadiness.NEVER + + +def test_workspace_builder_options_invalid_catalog_type() -> None: + """A non-mapping catalog is rejected as a builder-option error.""" + with pytest.raises(exc.InvalidWorkspaceBuilderOption, match="must be a mapping"): + WorkspaceBuilderOptions.from_config({"workspace_builder_options": ["nope"]}) + + +def test_workspace_builder_options_invalid_pane_readiness() -> None: + """An invalid pane_readiness is wrapped as a builder-option error.""" + with pytest.raises(exc.InvalidWorkspaceBuilderOption, match="pane_readiness"): + WorkspaceBuilderOptions.from_config( + {"workspace_builder_options": {"pane_readiness": "sometimes"}}, + ) + + +@pytest.mark.parametrize( + ("shell", "expected"), + [ + ("/usr/bin/zsh", True), + ("/bin/zsh", True), + ("zsh", True), + ("/bin/bash", False), + ("/bin/sh", False), + ("", False), + (None, False), + ], +) +def test_shell_is_zsh(shell: str | None, expected: bool) -> None: + """shell_is_zsh detects zsh by name.""" + assert shell_is_zsh(shell) is expected + + +class _FakeSession: + """Minimal stand-in exposing ``show_option`` for shell resolution.""" + + def __init__(self, shell: str | None) -> None: + self._shell = shell + + def show_option(self, name: str, **kwargs: t.Any) -> str | None: + """Return the canned default-shell value.""" + return self._shell + + +def test_resolve_session_shell_prefers_default_shell() -> None: + """The tmux default-shell wins over $SHELL.""" + shell = resolve_session_shell( + _FakeSession("/usr/bin/zsh"), + env={"SHELL": "/bin/bash"}, + ) + assert shell == "/usr/bin/zsh" + + +def test_resolve_session_shell_falls_back_to_env() -> None: + """$SHELL is the fallback when default-shell is empty.""" + shell = resolve_session_shell(_FakeSession(None), env={"SHELL": "/bin/bash"}) + assert shell == "/bin/bash" + + +def test_resolve_session_shell_empty_when_unknown() -> None: + """Returns an empty string when neither source resolves.""" + assert resolve_session_shell(_FakeSession(None), env={}) == "" + + +class _InheritedShellSession: + """Session whose default-shell is only visible with include_inherited.""" + + def __init__(self, shell: str) -> None: + self._shell = shell + + def show_option( + self, + name: str, + *, + include_inherited: bool = False, + **kwargs: t.Any, + ) -> str | None: + """Return the shell only when inherited options are requested.""" + return self._shell if include_inherited else None + + +def test_resolve_session_shell_reads_inherited_default_shell() -> None: + """A globally-inherited default-shell wins over the $SHELL fallback.""" + shell = resolve_session_shell( + _InheritedShellSession("/usr/bin/zsh"), + env={"SHELL": "/bin/bash"}, + ) + assert shell == "/usr/bin/zsh" diff --git a/tests/workspace/test_progress.py b/tests/workspace/test_progress.py new file mode 100644 index 0000000000..336fa9dafd --- /dev/null +++ b/tests/workspace/test_progress.py @@ -0,0 +1,258 @@ +"""Tests for tmuxp workspace builder progress callback.""" + +from __future__ import annotations + +import typing as t + +import pytest +from libtmux.server import Server + +from tmuxp import exc +from tmuxp.workspace.builder import WorkspaceBuilder + + +def test_builder_on_progress_callback( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """WorkspaceBuilder calls on_progress at each build milestone.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "progress-test", + "windows": [{"window_name": "editor", "panes": [{"shell_command": []}]}], + } + + calls: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_progress=calls.append, + ) + builder.build() + + assert any("Session created:" in c for c in calls) + assert any("Creating window:" in c for c in calls) + assert any("Creating pane:" in c for c in calls) + assert "Workspace built" in calls + + +def test_builder_on_before_script_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_before_script callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-callback-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + called: list[bool] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_before_script=lambda: called.append(True), + ) + builder.build() + assert called == [] + + +def test_builder_on_script_output_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_script_output callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-output-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + lines: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_script_output=lines.append, + ) + builder.build() + assert lines == [] + + +def test_builder_on_build_event_sequence( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_build_event fires the full event sequence during build().""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "build-event-test", + "windows": [ + { + "window_name": "editor", + "panes": [{"shell_command": []}, {"shell_command": []}], + }, + {"window_name": "logs", "panes": [{"shell_command": []}]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert event_types[0] == "session_created" + assert event_types[-1] == "workspace_built" + assert event_types.count("window_started") == 2 + assert event_types.count("window_done") == 2 + assert event_types.count("pane_creating") == 3 # 2 panes + 1 pane + + created = next(e for e in events if e["event"] == "session_created") + assert created["window_total"] == 2 + assert created["session_pane_total"] == 3 # 2 panes + 1 pane + + +def test_builder_on_build_event_session_name( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event carries correct session name.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "name-check", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["name"] == "name-check" + assert created["window_total"] == 1 + + +def test_builder_on_build_event_session_pane_total( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event includes session_pane_total summing all windows' panes.""" + monkeypatch.delenv("TMUX", raising=False) + + pane: dict[str, list[object]] = {"shell_command": []} + session_config = { + "session_name": "pane-total-test", + "windows": [ + {"window_name": "w1", "panes": [pane, pane]}, + {"window_name": "w2", "panes": [pane]}, + {"window_name": "w3", "panes": [pane, pane, pane]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["session_pane_total"] == 6 # 2 + 1 + 3 + + +def test_builder_before_script_events( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_started fires before run; before_script_done fires in finally.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-events-test", + "before_script": "echo hello", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + bs_start_idx = event_types.index("before_script_started") + bs_done_idx = event_types.index("before_script_done") + win_idx = event_types.index("window_started") + assert bs_start_idx < bs_done_idx < win_idx + + +def test_builder_before_script_done_fires_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_done fires in finally even when the script fails.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-fail-test", + "before_script": "/bin/false", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + with pytest.raises(exc.BeforeLoadScriptError): + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + +def test_builder_on_build_event_pane_numbers( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """pane_creating events carry 1-based pane_num and correct pane_total.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "pane-num-test", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + {"shell_command": []}, + {"shell_command": []}, + ], + }, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + pane_events = [e for e in events if e["event"] == "pane_creating"] + assert len(pane_events) == 3 + assert [e["pane_num"] for e in pane_events] == [1, 2, 3] + assert all(e["pane_total"] == 3 for e in pane_events) diff --git a/tmuxp/_compat.py b/tmuxp/_compat.py deleted file mode 100644 index a69ba4c793..0000000000 --- a/tmuxp/_compat.py +++ /dev/null @@ -1,28 +0,0 @@ -# flake8: NOQA -import sys - -PY3 = sys.version_info[0] == 3 -PYMINOR = sys.version_info[1] -PYPATCH = sys.version_info[2] - -_identity = lambda x: x - -if PY3 and PYMINOR >= 7: - breakpoint = breakpoint -else: - import pdb - - breakpoint = pdb.set_trace - - -console_encoding = sys.__stdout__.encoding - -implements_to_string = _identity - - -def console_to_str(s): - """From pypa/pip project, pip.backwardwardcompat. License MIT.""" - try: - return s.decode(console_encoding) - except UnicodeDecodeError: - return s.decode("utf_8") diff --git a/tmuxp/cli/__init__.py b/tmuxp/cli/__init__.py deleted file mode 100644 index ab09f1ec6e..0000000000 --- a/tmuxp/cli/__init__.py +++ /dev/null @@ -1,77 +0,0 @@ -"""CLI utilities for tmuxp. - -tmuxp.cli -~~~~~~~~~ - -""" -import logging -import os -import sys - -import click - -from libtmux.common import has_minimum_version -from libtmux.exc import TmuxCommandNotFound -from tmuxp.cli.ls import command_ls - -from .. import exc -from ..__about__ import __version__ -from ..log import setup_logger -from .convert import command_convert -from .debug_info import command_debug_info -from .edit import command_edit -from .freeze import command_freeze -from .import_config import command_import -from .load import command_load -from .shell import command_shell -from .utils import tmuxp_echo - -logger = logging.getLogger(__name__) - - -@click.group(context_settings={"obj": {}, "help_option_names": ["-h", "--help"]}) -@click.version_option(__version__, "-V", "--version", message="%(prog)s %(version)s") -@click.option( - "--log-level", - default="INFO", - help="Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", -) -def cli(log_level): - """Manage tmux sessions. - - Pass the "--help" argument to any command to see detailed help. - See detailed documentation and examples at: - http://tmuxp.git-pull.com/""" - try: - has_minimum_version() - except TmuxCommandNotFound: - tmuxp_echo("tmux not found. tmuxp requires you install tmux first.") - sys.exit() - except exc.TmuxpException as e: - tmuxp_echo(e, err=True) - sys.exit() - setup_logger(logger=logger, level=log_level.upper()) - - -def startup(config_dir): - """ - Initialize CLI. - - Parameters - ---------- - str : get_config_dir(): Config directory to search - """ - - if not os.path.exists(config_dir): - os.makedirs(config_dir) - - -# Register sub-commands here -cli.add_command(command_convert) -cli.add_command(command_edit) -cli.add_command(command_debug_info) -cli.add_command(command_load) -cli.add_command(command_ls) -cli.add_command(command_freeze) -cli.add_command(command_shell) -cli.add_command(command_import) diff --git a/tmuxp/cli/constants.py b/tmuxp/cli/constants.py deleted file mode 100644 index c5abebb7b1..0000000000 --- a/tmuxp/cli/constants.py +++ /dev/null @@ -1 +0,0 @@ -VALID_CONFIG_DIR_FILE_EXTENSIONS = [".yaml", ".yml", ".json"] diff --git a/tmuxp/cli/convert.py b/tmuxp/cli/convert.py deleted file mode 100644 index 6ee78eb287..0000000000 --- a/tmuxp/cli/convert.py +++ /dev/null @@ -1,44 +0,0 @@ -import os - -import click -import kaptan - -from .utils import ConfigPath - - -@click.command(name="convert") -@click.option( - "--yes", "-y", "confirmed", help='Auto confirms with "yes".', is_flag=True -) -@click.argument("config", type=ConfigPath(exists=True), nargs=1) -def command_convert(confirmed, config): - """Convert a tmuxp config between JSON and YAML.""" - - _, ext = os.path.splitext(config) - ext = ext.lower() - if ext == ".json": - to_filetype = "yaml" - elif ext in [".yaml", ".yml"]: - to_filetype = "json" - else: - raise click.BadParameter( - f"Unknown filetype: {ext} (valid: [.json, .yaml, .yml])" - ) - - configparser = kaptan.Kaptan() - configparser.import_config(config) - newfile = config.replace(ext, ".%s" % to_filetype) - - export_kwargs = {"default_flow_style": False} if to_filetype == "yaml" else {} - newconfig = configparser.export(to_filetype, indent=2, **export_kwargs) - - if not confirmed: - if click.confirm(f"convert to <{config}> to {to_filetype}?"): - if click.confirm("Save config to %s?" % newfile): - confirmed = True - - if confirmed: - buf = open(newfile, "w") - buf.write(newconfig) - buf.close() - print("New config saved to <%s>." % newfile) diff --git a/tmuxp/cli/debug_info.py b/tmuxp/cli/debug_info.py deleted file mode 100644 index f4c0166342..0000000000 --- a/tmuxp/cli/debug_info.py +++ /dev/null @@ -1,77 +0,0 @@ -import os -import pathlib -import platform -import sys - -import click - -from libtmux import __version__ as libtmux_version -from libtmux.common import get_version, tmux_cmd, which - -from ..__about__ import __version__ -from .utils import tmuxp_echo - -tmuxp_path = pathlib.Path(__file__).parent.parent - - -@click.command(name="debug-info", short_help="Print out all diagnostic info") -def command_debug_info(): - """ - Print debug info to submit with Issues. - """ - - def prepend_tab(strings): - """ - Prepend tab to strings in list. - """ - return list(map(lambda x: "\t%s" % x, strings)) - - def output_break(): - """ - Generate output break. - """ - return "-" * 25 - - def format_tmux_resp(std_resp): - """ - Format tmux command response for tmuxp stdout. - """ - return "\n".join( - [ - "\n".join(prepend_tab(std_resp.stdout)), - click.style("\n".join(prepend_tab(std_resp.stderr)), fg="red"), - ] - ) - - output = [ - output_break(), - "environment:\n%s" - % "\n".join( - prepend_tab( - [ - "dist: %s" % platform.platform(), - "arch: %s" % platform.machine(), - "uname: %s" % "; ".join(platform.uname()[:3]), - "version: %s" % platform.version(), - ] - ) - ), - output_break(), - "python version: %s" % " ".join(sys.version.split("\n")), - "system PATH: %s" % os.environ["PATH"], - "tmux version: %s" % get_version(), - "libtmux version: %s" % libtmux_version, - "tmuxp version: %s" % __version__, - "tmux path: %s" % which("tmux"), - "tmuxp path: %s" % tmuxp_path, - "shell: %s" % os.environ["SHELL"], - output_break(), - "tmux sessions:\n%s" % format_tmux_resp(tmux_cmd("list-sessions")), - "tmux windows:\n%s" % format_tmux_resp(tmux_cmd("list-windows")), - "tmux panes:\n%s" % format_tmux_resp(tmux_cmd("list-panes")), - "tmux global options:\n%s" % format_tmux_resp(tmux_cmd("show-options", "-g")), - "tmux window options:\n%s" - % format_tmux_resp(tmux_cmd("show-window-options", "-g")), - ] - - tmuxp_echo("\n".join(output)) diff --git a/tmuxp/cli/edit.py b/tmuxp/cli/edit.py deleted file mode 100644 index 0eb9c95a5f..0000000000 --- a/tmuxp/cli/edit.py +++ /dev/null @@ -1,15 +0,0 @@ -import os -import subprocess - -import click - -from .utils import ConfigPath, scan_config - - -@click.command(name="edit", short_help="Run $EDITOR on config.") -@click.argument("config", type=ConfigPath(exists=True), nargs=1) -def command_edit(config): - config = scan_config(config) - - sys_editor = os.environ.get("EDITOR", "vim") - subprocess.call([sys_editor, config]) diff --git a/tmuxp/cli/freeze.py b/tmuxp/cli/freeze.py deleted file mode 100644 index 343228873c..0000000000 --- a/tmuxp/cli/freeze.py +++ /dev/null @@ -1,136 +0,0 @@ -import os -import sys - -import click -import kaptan - -from libtmux.server import Server - -from .. import config, util -from ..workspacebuilder import freeze -from . import exc -from .utils import _validate_choices, get_abs_path, get_config_dir - - -@click.command(name="freeze") -@click.argument("session_name", nargs=1, required=False) -@click.option("-S", "socket_path", help="pass-through for tmux -S") -@click.option("-L", "socket_name", help="pass-through for tmux -L") -@click.option( - "-f", - "--config-format", - type=click.Choice(["yaml", "json"]), - help="format to save in", -) -@click.option("-o", "--save-to", type=click.Path(exists=False), help="file to save to") -@click.option( - "-y", - "--yes", - type=bool, - is_flag=True, - default=False, - help="Don't prompt for confirmation", -) -@click.option( - "-q", - "--quiet", - type=bool, - is_flag=True, - default=False, - help="Don't prompt for confirmation", -) -@click.option("--force", "force", help="overwrite the config file", is_flag=True) -def command_freeze( - session_name, socket_name, config_format, save_to, socket_path, yes, quiet, force -): - """Snapshot a session into a config. - - If SESSION_NAME is provided, snapshot that session. Otherwise, use the - current session.""" - - t = Server(socket_name=socket_name, socket_path=socket_path) - - try: - if session_name: - session = t.find_where({"session_name": session_name}) - else: - session = util.get_session(t) - - if not session: - raise exc.TmuxpException("Session not found.") - except exc.TmuxpException as e: - print(e) - return - - sconf = freeze(session) - configparser = kaptan.Kaptan() - newconfig = config.inline(sconf) - configparser.import_config(newconfig) - - if not quiet: - print( - "---------------------------------------------------------------" - "\n" - "Freeze does its best to snapshot live tmux sessions.\n" - ) - if not ( - yes - or click.confirm( - "The new config *WILL* require adjusting afterwards. Save config?" - ) - ): - if not quiet: - print( - "tmuxp has examples in JSON and YAML format at " - "\n" - "View tmuxp docs at ." - ) - sys.exit() - - dest = save_to - while not dest: - save_to = os.path.abspath( - os.path.join( - get_config_dir(), - "{}.{}".format(sconf.get("session_name"), config_format or "yaml"), - ) - ) - dest_prompt = click.prompt( - "Save to: %s" % save_to, value_proc=get_abs_path, default=save_to - ) - if not force and os.path.exists(dest_prompt): - print("%s exists. Pick a new filename." % dest_prompt) - continue - - dest = dest_prompt - dest = os.path.abspath(os.path.relpath(os.path.expanduser(dest))) - - if config_format is None: - valid_config_formats = ["json", "yaml"] - _, config_format = os.path.splitext(dest) - config_format = config_format[1:].lower() - if config_format not in valid_config_formats: - config_format = click.prompt( - "Couldn't ascertain one of [%s] from file name. Convert to" - % ", ".join(valid_config_formats), - value_proc=_validate_choices(["yaml", "json"]), - default="yaml", - ) - - if config_format == "yaml": - newconfig = configparser.export( - "yaml", indent=2, default_flow_style=False, safe=True - ) - elif config_format == "json": - newconfig = configparser.export("json", indent=2) - - if yes or click.confirm("Save to %s?" % dest): - destdir = os.path.dirname(dest) - if not os.path.isdir(destdir): - os.makedirs(destdir) - buf = open(dest, "w") - buf.write(newconfig) - buf.close() - - if not quiet: - print("Saved to %s." % dest) diff --git a/tmuxp/cli/import_config.py b/tmuxp/cli/import_config.py deleted file mode 100644 index a0a6e6f327..0000000000 --- a/tmuxp/cli/import_config.py +++ /dev/null @@ -1,170 +0,0 @@ -import os -import sys - -import click -import kaptan - -from .. import config -from .utils import ConfigPath, _validate_choices, get_abs_path, tmuxp_echo - - -def get_tmuxinator_dir(): - """ - Return tmuxinator configuration directory. - - Checks for ``TMUXINATOR_CONFIG`` environmental variable. - - Returns - ------- - str : - absolute path to tmuxinator config directory - - See Also - -------- - :meth:`tmuxp.config.import_tmuxinator` - """ - if "TMUXINATOR_CONFIG" in os.environ: - return os.path.expanduser(os.environ["TMUXINATOR_CONFIG"]) - - return os.path.expanduser("~/.tmuxinator/") - - -def get_teamocil_dir(): - """ - Return teamocil configuration directory. - - Returns - ------- - str : - absolute path to teamocil config directory - - See Also - -------- - :meth:`tmuxp.config.import_teamocil` - """ - return os.path.expanduser("~/.teamocil/") - - -def _resolve_path_no_overwrite(config): - path = get_abs_path(config) - if os.path.exists(path): - raise click.exceptions.UsageError("%s exists. Pick a new filename." % path) - return path - - -@click.group(name="import") -def command_import(): - """Import a teamocil/tmuxinator config.""" - - -def import_config(configfile, importfunc): - configparser = kaptan.Kaptan(handler="yaml") - - configparser.import_config(configfile) - newconfig = importfunc(configparser.get()) - configparser.import_config(newconfig) - - config_format = click.prompt( - "Convert to", value_proc=_validate_choices(["yaml", "json"]), default="yaml" - ) - - if config_format == "yaml": - newconfig = configparser.export("yaml", indent=2, default_flow_style=False) - elif config_format == "json": - newconfig = configparser.export("json", indent=2) - else: - sys.exit("Unknown config format.") - - tmuxp_echo( - newconfig + "---------------------------------------------------------------" - "\n" - "Configuration import does its best to convert files.\n" - ) - if click.confirm( - "The new config *WILL* require adjusting afterwards. Save config?" - ): - dest = None - while not dest: - dest_path = click.prompt( - "Save to [%s]" % os.getcwd(), value_proc=_resolve_path_no_overwrite - ) - - # dest = dest_prompt - if click.confirm("Save to %s?" % dest_path): - dest = dest_path - - buf = open(dest, "w") - buf.write(newconfig) - buf.close() - - tmuxp_echo("Saved to %s." % dest) - else: - tmuxp_echo( - "tmuxp has examples in JSON and YAML format at " - "\n" - "View tmuxp docs at " - ) - sys.exit() - - -@command_import.command( - name="tmuxinator", short_help="Convert and import a tmuxinator config." -) -@click.argument( - "configfile", type=ConfigPath(exists=True, config_dir=get_tmuxinator_dir), nargs=1 -) -def command_import_tmuxinator(configfile): - """Convert a tmuxinator config from CONFIGFILE to tmuxp format and import - it into tmuxp.""" - import_config(configfile, config.import_tmuxinator) - - -@click.command(name="convert") -@click.option( - "--yes", "-y", "confirmed", help='Auto confirms with "yes".', is_flag=True -) -@click.argument("config", type=ConfigPath(exists=True), nargs=1) -def command_convert(confirmed, config): - """Convert a tmuxp config between JSON and YAML.""" - - _, ext = os.path.splitext(config) - ext = ext.lower() - if ext == ".json": - to_filetype = "yaml" - elif ext in [".yaml", ".yml"]: - to_filetype = "json" - else: - raise click.BadParameter( - f"Unknown filetype: {ext} (valid: [.json, .yaml, .yml])" - ) - - configparser = kaptan.Kaptan() - configparser.import_config(config) - newfile = config.replace(ext, ".%s" % to_filetype) - - export_kwargs = {"default_flow_style": False} if to_filetype == "yaml" else {} - newconfig = configparser.export(to_filetype, indent=2, **export_kwargs) - - if not confirmed: - if click.confirm(f"convert to <{config}> to {to_filetype}?"): - if click.confirm("Save config to %s?" % newfile): - confirmed = True - - if confirmed: - buf = open(newfile, "w") - buf.write(newconfig) - buf.close() - print("New config saved to <%s>." % newfile) - - -@command_import.command( - name="teamocil", short_help="Convert and import a teamocil config." -) -@click.argument( - "configfile", type=ConfigPath(exists=True, config_dir=get_teamocil_dir), nargs=1 -) -def command_import_teamocil(configfile): - """Convert a teamocil config from CONFIGFILE to tmuxp format and import - it into tmuxp.""" - - import_config(configfile, config.import_teamocil) diff --git a/tmuxp/cli/load.py b/tmuxp/cli/load.py deleted file mode 100644 index d159c63d1a..0000000000 --- a/tmuxp/cli/load.py +++ /dev/null @@ -1,544 +0,0 @@ -"""Command line tool for managing tmux workspaces and tmuxp configurations. - -tmuxp.cli -~~~~~~~~~ - -""" -import importlib -import logging -import os -import sys - -import click -import kaptan - -from libtmux.common import has_gte_version, which -from libtmux.server import Server - -from .. import config, exc, log, util -from ..workspacebuilder import WorkspaceBuilder -from .utils import ConfigPath, _validate_choices, tmuxp_echo - - -def set_layout_hook(session, hook_name): - """Set layout hooks to normalize layout. - - References: - - - tmuxp issue: https://github.com/tmux-python/tmuxp/issues/309 - - tmux issue: https://github.com/tmux/tmux/issues/1106 - - tmux 2.6+ requires that the window be viewed with the client before - select-layout adjustments can take effect. - - To handle this, this function creates temporary hook for this session to - iterate through all windows and select the layout. - - In order for layout changes to take effect, a client must at the very - least be attached to the window (not just the session). - - hook_name is provided to allow this to set to multiple scenarios, such - as 'client-attached' (which the user attaches the session). You may - also want 'after-switch-client' for cases where the user loads tmuxp - sessions inside tmux since tmuxp offers to switch for them. - - Also, the hooks are set immediately unbind after they're invoked via -u. - - Parameters - ---------- - session : :class:`libtmux.session.Session` - session to bind hook to - hook_name : str - hook name to bind to, e.g. 'client-attached' - """ - cmd = ["set-hook", "-t", session.id, hook_name] - hook_cmd = [] - attached_window = session.attached_window - for window in session.windows: - # unfortunately, select-layout won't work unless - # we've literally selected the window at least once - # with the client - hook_cmd.append(f"selectw -t {window.id}") - # edit: removed -t, or else it won't respect main-pane-w/h - hook_cmd.append("selectl") - hook_cmd.append("selectw -p") - - # unset the hook immediately after executing - hook_cmd.append( - "set-hook -u -t {target_session} {hook_name}".format( - target_session=session.id, hook_name=hook_name - ) - ) - hook_cmd.append(f"selectw -t {attached_window.id}") - - # join the hook's commands with semicolons - hook_cmd = "{}".format("; ".join(hook_cmd)) - - # append the hook command - cmd.append(hook_cmd) - - # create the hook - session.cmd(*cmd) - - -def load_plugins(sconf): - """ - Load and return plugins in config - """ - plugins = [] - if "plugins" in sconf: - for plugin in sconf["plugins"]: - try: - module_name = plugin.split(".") - module_name = ".".join(module_name[:-1]) - plugin_name = plugin.split(".")[-1] - plugin = getattr(importlib.import_module(module_name), plugin_name) - plugins.append(plugin()) - except exc.TmuxpPluginException as error: - if not click.confirm( - "%sSkip loading %s?" - % (click.style(str(error), fg="yellow"), plugin_name), - default=True, - ): - click.echo( - click.style("[Not Skipping] ", fg="yellow") - + "Plugin versions constraint not met. Exiting..." - ) - sys.exit(1) - except Exception as error: - click.echo( - click.style("[Plugin Error] ", fg="red") - + f"Couldn't load {plugin}\n" - + click.style(f"{error}", fg="yellow") - ) - sys.exit(1) - - return plugins - - -def _reattach(builder): - """ - Reattach session (depending on env being inside tmux already or not) - - Parameters - ---------- - builder: :class:`workspacebuilder.WorkspaceBuilder` - - Notes - ----- - If ``TMUX`` environmental variable exists in the environment this script is - running, that means we're in a tmux client. So ``tmux switch-client`` will - load the session. - - If not, ``tmux attach-session`` loads the client to the target session. - """ - for plugin in builder.plugins: - plugin.reattach(builder.session) - proc = builder.session.cmd("display-message", "-p", "'#S'") - for line in proc.stdout: - print(line) - - if "TMUX" in os.environ: - builder.session.switch_client() - - else: - builder.session.attach_session() - - -def _load_attached(builder, detached): - """ - Load config in new session - - Parameters - ---------- - builder: :class:`workspacebuilder.WorkspaceBuilder` - detached : bool - """ - builder.build() - - if "TMUX" in os.environ: # tmuxp ran from inside tmux - # unset TMUX, save it, e.g. '/tmp/tmux-1000/default,30668,0' - tmux_env = os.environ.pop("TMUX") - - if has_gte_version("2.6"): - set_layout_hook(builder.session, "client-session-changed") - - builder.session.switch_client() # switch client to new session - - os.environ["TMUX"] = tmux_env # set TMUX back again - else: - if has_gte_version("2.6"): - # if attaching for first time - set_layout_hook(builder.session, "client-attached") - - # for cases where user switches client for first time - set_layout_hook(builder.session, "client-session-changed") - - if not detached: - builder.session.attach_session() - - -def _load_detached(builder): - """ - Load config in new session but don't attach - - Parameters - ---------- - builder: :class:`workspacebuilder.WorkspaceBuilder` - """ - builder.build() - - if has_gte_version("2.6"): # prepare for both cases - set_layout_hook(builder.session, "client-attached") - set_layout_hook(builder.session, "client-session-changed") - - print("Session created in detached state.") - - -def _load_append_windows_to_current_session(builder): - """ - Load config as new windows in current session - - Parameters - ---------- - builder: :class:`workspacebuilder.WorkspaceBuilder` - """ - current_attached_session = builder.find_current_attached_session() - builder.build(current_attached_session, append=True) - if has_gte_version("2.6"): # prepare for both cases - set_layout_hook(builder.session, "client-attached") - set_layout_hook(builder.session, "client-session-changed") - - -def _setup_plugins(builder): - """ - Runs after before_script - - Parameters - ---------- - builder: :class:`workspacebuilder.WorkspaceBuilder` - """ - for plugin in builder.plugins: - plugin.before_script(builder.session) - - return builder.session - - -def load_workspace( - config_file, - socket_name=None, - socket_path=None, - tmux_config_file=None, - new_session_name=None, - colors=None, - detached=False, - answer_yes=False, - append=False, -): - """ - Load a tmux "workspace" session via tmuxp file. - - Parameters - ---------- - config_file : str - absolute path to config file - socket_name : str, optional - ``tmux -L `` - socket_path: str, optional - ``tmux -S `` - new_session_name: str, options - ``tmux new -s `` - colors : str, optional - '-2' - Force tmux to support 256 colors - detached : bool - Force detached state. default False. - answer_yes : bool - Assume yes when given prompt to attach in new session. - Default False. - append : bool - Assume current when given prompt to append windows in same session. - Default False. - - Notes - ----- - - tmuxp will check and load a configuration file. The file will use kaptan - to load a JSON/YAML into a :py:obj:`dict`. Then :func:`config.expand` and - :func:`config.trickle` will be used to expand any shorthands, template - variables, or file paths relative to where the config/script is executed - from. - - :func:`config.expand` accepts the directory of the config file, so the - user's configuration can resolve absolute paths relative to where the - config file is. In otherwords, if a config file at */var/moo/hi.yaml* - has *./* in its configs, we want to be sure any file path with *./* is - relative to */var/moo*, not the user's PWD. - - A :class:`libtmux.Server` object is created. No tmux server is started yet, - just the object. - - The prepared configuration and the server object is passed into an instance - of :class:`~tmuxp.workspacebuilder.WorkspaceBuilder`. - - A sanity check against :meth:`libtmux.common.which` is ran. It will raise - an exception if tmux isn't found. - - If a tmux session under the same name as ``session_name`` in the tmuxp - configuration exists, tmuxp offers to attach the session. Currently, tmuxp - does not allow appending a workspace / incremental building on top of a - current session (pull requests are welcome!). - - :meth:`~tmuxp.workspacebuilder.WorkspaceBuilder.build` will build the session in - the background via using tmux's detached state (``-d``). - - After the session (workspace) is built, unless the user decided to load - the session in the background via ``tmuxp -d`` (which is in the spirit - of tmux's ``-d``), we need to prompt the user to attach the session. - - If the user is already inside a tmux client, which we detect via the - ``TMUX`` environment variable bring present, we will prompt the user to - switch their current client to it. - - If they're outside of tmux client - in a plain-old PTY - we will - automatically ``attach``. - - If an exception is raised during the building of the workspace, tmuxp will - prompt to cleanup (``$ tmux kill-session``) the session on the user's - behalf. An exception raised during this process means it's not easy to - predict how broken the session is. - - .. versionchanged:: tmux 2.6+ - - In tmux 2.6, the way layout and proportion's work when interfacing - with tmux in a detached state (outside of a client) changed. Since - tmuxp builds workspaces in a detached state, the WorkspaceBuilder isn't - able to rely on functionality requiring awarness of session geometry, - e.g. ``set-layout``. - - Thankfully, tmux is able to defer commands to run after the user - performs certain actions, such as loading a client via - ``attach-session`` or ``switch-client``. - - Upon client switch, ``client-session-changed`` is triggered [1]_. - - References - ---------- - .. [1] cmd-switch-client.c hook. GitHub repo for tmux. - - https://github.com/tmux/tmux/blob/2.6/cmd-switch-client.c#L132. - Accessed April 8th, 2018. - """ - # get the canonical path, eliminating any symlinks - config_file = os.path.realpath(config_file) - - tmuxp_echo( - click.style("[Loading] ", fg="green") - + click.style(config_file, fg="blue", bold=True) - ) - - # kaptan allows us to open a yaml or json file as a dict - sconfig = kaptan.Kaptan() - sconfig = sconfig.import_config(config_file).get() - # shapes configurations relative to config / profile file location - sconfig = config.expand(sconfig, os.path.dirname(config_file)) - # Overwrite session name - if new_session_name: - sconfig["session_name"] = new_session_name - # propagate config inheritance (e.g. session -> window, window -> pane) - sconfig = config.trickle(sconfig) - - t = Server( # create tmux server object - socket_name=socket_name, - socket_path=socket_path, - config_file=tmux_config_file, - colors=colors, - ) - - which("tmux") # raise exception if tmux not found - - try: # load WorkspaceBuilder object for tmuxp config / tmux server - builder = WorkspaceBuilder( - sconf=sconfig, plugins=load_plugins(sconfig), server=t - ) - except exc.EmptyConfigException: - tmuxp_echo("%s is empty or parsed no config data" % config_file, err=True) - return - - session_name = sconfig["session_name"] - - # if the session already exists, prompt the user to attach - if builder.session_exists(session_name) and not append: - if not detached and ( - answer_yes - or click.confirm( - "%s is already running. Attach?" - % click.style(session_name, fg="green"), - default=True, - ) - ): - _reattach(builder) - return - - try: - if detached: - _load_detached(builder) - return _setup_plugins(builder) - - if append: - if "TMUX" in os.environ: # tmuxp ran from inside tmux - _load_append_windows_to_current_session(builder) - else: - _load_attached(builder, detached) - - return _setup_plugins(builder) - - # append and answer_yes have no meaning if specified together - elif answer_yes: - _load_attached(builder, detached) - return _setup_plugins(builder) - - if "TMUX" in os.environ: # tmuxp ran from inside tmux - msg = ( - "Already inside TMUX, switch to session? yes/no\n" - "Or (a)ppend windows in the current active session?\n[y/n/a]" - ) - options = ["y", "n", "a"] - choice = click.prompt(msg, value_proc=_validate_choices(options)) - - if choice == "y": - _load_attached(builder, detached) - elif choice == "a": - _load_append_windows_to_current_session(builder) - else: - _load_detached(builder) - else: - _load_attached(builder, detached) - - except exc.TmuxpException as e: - import traceback - - tmuxp_echo(traceback.format_exc(), err=True) - tmuxp_echo(e, err=True) - - choice = click.prompt( - "Error loading workspace. (k)ill, (a)ttach, (d)etach?", - value_proc=_validate_choices(["k", "a", "d"]), - default="k", - ) - - if choice == "k": - builder.session.kill_session() - tmuxp_echo("Session killed.") - elif choice == "a": - _reattach(builder) - else: - sys.exit() - - return _setup_plugins(builder) - - -@click.command(name="load", short_help="Load tmuxp workspaces.") -@click.pass_context -@click.argument("config", type=ConfigPath(exists=True), nargs=-1) -@click.option("-S", "socket_path", help="pass-through for tmux -S") -@click.option("-L", "socket_name", help="pass-through for tmux -L") -@click.option("-f", "tmux_config_file", help="pass-through for tmux -f") -@click.option("-s", "new_session_name", help="start new session with new session name") -@click.option("--yes", "-y", "answer_yes", help="yes", is_flag=True) -@click.option( - "-d", "detached", help="Load the session without attaching it", is_flag=True -) -@click.option( - "-a", - "append", - help="Load configuration, appending windows to the current session", - is_flag=True, -) -@click.option( - "colors", - "-2", - flag_value=256, - default=True, - help="Force tmux to assume the terminal supports 256 colours.", -) -@click.option( - "colors", - "-8", - flag_value=88, - help="Like -2, but indicates that the terminal supports 88 colours.", -) -@click.option("--log-file", help="File to log errors/output to") -def command_load( - ctx, - config, - socket_name, - socket_path, - tmux_config_file, - new_session_name, - answer_yes, - detached, - append, - colors, - log_file, -): - """Load a tmux workspace from each CONFIG. - - CONFIG is a specifier for a configuration file. - - If CONFIG is a path to a directory, tmuxp will search it for - ".tmuxp.{yaml,yml,json}". - - If CONFIG is has no directory component and only a filename, e.g. - "myconfig.yaml", tmuxp will search the users's config directory for that - file. - - If CONFIG has no directory component, and only a name with no extension, - e.g. "myconfig", tmuxp will search the users's config directory for any - file with the extension ".yaml", ".yml", or ".json" that matches that name. - - If multiple configuration files that match a given CONFIG are found, tmuxp - will warn and pick the first one found. - - If multiple CONFIGs are provided, workspaces will be created for all of - them. The last one provided will be attached. The others will be created in - detached mode. - """ - util.oh_my_zsh_auto_title() - - if log_file: - logfile_handler = logging.FileHandler(log_file) - logfile_handler.setFormatter(log.LogFormatter()) - from . import logger - - logger.addHandler(logfile_handler) - - tmux_options = { - "socket_name": socket_name, - "socket_path": socket_path, - "tmux_config_file": tmux_config_file, - "new_session_name": new_session_name, - "answer_yes": answer_yes, - "colors": colors, - "detached": detached, - "append": append, - } - - if not config: - tmuxp_echo("Enter at least one CONFIG") - tmuxp_echo(ctx.get_help(), color=ctx.color) - ctx.exit() - - if isinstance(config, str): - load_workspace(config, **tmux_options) - - elif isinstance(config, tuple): - config = list(config) - # Load each configuration but the last to the background - for cfg in config[:-1]: - opt = tmux_options.copy() - opt.update({"detached": True, "new_session_name": None}) - load_workspace(cfg, **opt) - - # todo: obey the -d in the cli args only if user specifies - load_workspace(config[-1], **tmux_options) diff --git a/tmuxp/cli/ls.py b/tmuxp/cli/ls.py deleted file mode 100644 index 0164ea9a92..0000000000 --- a/tmuxp/cli/ls.py +++ /dev/null @@ -1,20 +0,0 @@ -import os - -import click - -from .constants import VALID_CONFIG_DIR_FILE_EXTENSIONS -from .utils import get_config_dir - - -@click.command( - name="ls", - short_help="List configured sessions in :meth:`tmuxp.cli.utils.get_config_dir`.", -) -def command_ls(): - tmuxp_dir = get_config_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_CONFIG_DIR_FILE_EXTENSIONS: - continue - print(stem) diff --git a/tmuxp/cli/shell.py b/tmuxp/cli/shell.py deleted file mode 100644 index da6f9cf960..0000000000 --- a/tmuxp/cli/shell.py +++ /dev/null @@ -1,103 +0,0 @@ -import os - -import click - -from libtmux import Server - -from .. import util -from .._compat import PY3, PYMINOR - - -@click.command(name="shell") -@click.argument("session_name", nargs=1, required=False) -@click.argument("window_name", nargs=1, required=False) -@click.option("-S", "socket_path", help="pass-through for tmux -S") -@click.option("-L", "socket_name", help="pass-through for tmux -L") -@click.option( - "-c", - "command", - help="Instead of opening shell, execute python code in libtmux and exit", -) -@click.option( - "--best", - "shell", - flag_value="best", - help="Use best shell available in site packages", - default=True, -) -@click.option("--pdb", "shell", flag_value="pdb", help="Use plain pdb") -@click.option("--code", "shell", flag_value="code", help="Use stdlib's code.interact()") -@click.option( - "--ptipython", "shell", flag_value="ptipython", help="Use ptpython + ipython" -) -@click.option("--ptpython", "shell", flag_value="ptpython", help="Use ptpython") -@click.option("--ipython", "shell", flag_value="ipython", help="Use ipython") -@click.option("--bpython", "shell", flag_value="bpython", help="Use bpython") -@click.option( - "--use-pythonrc/--no-startup", - "use_pythonrc", - help="Load PYTHONSTARTUP env var and ~/.pythonrc.py script in --code", - default=False, -) -@click.option( - "--use-vi-mode/--no-vi-mode", - "use_vi_mode", - help="Use vi-mode in ptpython/ptipython", - default=False, -) -def command_shell( - session_name, - window_name, - socket_name, - socket_path, - command, - shell, - use_pythonrc, - use_vi_mode, -): - """Launch python shell for tmux server, session, window and pane. - - Priority given to loaded session/wndow/pane objects: - - - session_name and window_name arguments - - current shell: envvar ``TMUX_PANE`` for determing window and session - - :attr:`libtmux.Server.attached_sessions`, :attr:`libtmux.Session.attached_window`, - :attr:`libtmux.Window.attached_pane` - """ - server = Server(socket_name=socket_name, socket_path=socket_path) - - util.raise_if_tmux_not_running(server=server) - - current_pane = util.get_current_pane(server=server) - - session = util.get_session( - server=server, session_name=session_name, current_pane=current_pane - ) - - window = util.get_window( - session=session, window_name=window_name, current_pane=current_pane - ) - - pane = util.get_pane(window=window, current_pane=current_pane) # NOQA: F841 - - if command is not None: - exec(command) - else: - if shell == "pdb" or (os.getenv("PYTHONBREAKPOINT") and PY3 and PYMINOR >= 7): - from ._compat import breakpoint as tmuxp_breakpoint - - tmuxp_breakpoint() - return - else: - from ..shell import launch - - launch( - shell=shell, - use_pythonrc=use_pythonrc, # shell: code - use_vi_mode=use_vi_mode, # shell: ptpython, ptipython - # tmux environment / libtmux variables - server=server, - session=session, - window=window, - pane=pane, - ) diff --git a/tmuxp/cli/utils.py b/tmuxp/cli/utils.py deleted file mode 100644 index ba6cceccde..0000000000 --- a/tmuxp/cli/utils.py +++ /dev/null @@ -1,259 +0,0 @@ -import logging -import os - -import click -from click.exceptions import FileError - -from .. import config, log -from .constants import VALID_CONFIG_DIR_FILE_EXTENSIONS - -logger = logging.getLogger(__name__) - - -def tmuxp_echo(message=None, log_level="INFO", style_log=False, **click_kwargs): - """ - Combines logging.log and click.echo - """ - if style_log: - logger.log(log.LOG_LEVELS[log_level], message) - else: - logger.log(log.LOG_LEVELS[log_level], click.unstyle(message)) - - click.echo(message, **click_kwargs) - - -def get_config_dir(): - """ - Return tmuxp configuration directory. - - ``TMUXP_CONFIGDIR`` environmental variable has precedence if set. We also - evaluate XDG default directory from XDG_CONFIG_HOME environmental variable - if set or its default. Then the old default ~/.tmuxp is returned for - compatibility. - - Returns - ------- - str : - absolute path to tmuxp config directory - """ - - paths = [] - if "TMUXP_CONFIGDIR" in os.environ: - paths.append(os.environ["TMUXP_CONFIGDIR"]) - if "XDG_CONFIG_HOME" in os.environ: - paths.append(os.path.join(os.environ["XDG_CONFIG_HOME"], "tmuxp")) - else: - paths.append("~/.config/tmuxp/") - paths.append("~/.tmuxp") - - for path in paths: - path = os.path.expanduser(path) - if os.path.isdir(path): - return path - # Return last path as default if none of the previous ones matched - return path - - -def _validate_choices(options): - """ - Callback wrapper for validating click.prompt input. - - Parameters - ---------- - options : list - List of allowed choices - - Returns - ------- - :func:`callable` - callback function for value_proc in :func:`click.prompt`. - - Raises - ------ - :class:`click.BadParameter` - """ - - def func(value): - if value not in options: - raise click.BadParameter( - "Possible choices are: {}.".format(", ".join(options)) - ) - return value - - return func - - -class ConfigPath(click.Path): - def __init__(self, config_dir=None, *args, **kwargs): - super().__init__(*args, **kwargs) - self.config_dir = config_dir - - def convert(self, value, param, ctx): - config_dir = self.config_dir - if callable(config_dir): - config_dir = config_dir() - - value = scan_config(value, config_dir=config_dir) - return super().convert(value, param, ctx) - - -def scan_config_argument(ctx, param, value, config_dir=None): - """Validate / translate config name/path values for click config arg. - - Wrapper on top of :func:`cli.scan_config`.""" - if callable(config_dir): - config_dir = config_dir() - - if not config: - tmuxp_echo("Enter at least one CONFIG") - tmuxp_echo(ctx.get_help(), color=ctx.color) - ctx.exit() - - if isinstance(value, str): - value = scan_config(value, config_dir=config_dir) - - elif isinstance(value, tuple): - value = tuple(scan_config(v, config_dir=config_dir) for v in value) - - return value - - -def get_abs_path(config): - path = os.path - join, isabs = path.join, path.isabs - dirname, normpath = path.dirname, path.normpath - cwd = os.getcwd() - - config = os.path.expanduser(config) - if not isabs(config) or len(dirname(config)) > 1: - config = normpath(join(cwd, config)) - - return config - - -def scan_config(config, config_dir=None): - """ - Return the real config path or raise an exception. - - If config is directory, scan for .tmuxp.{yaml,yml,json} in directory. If - one or more found, it will warn and pick the first. - - If config is ".", "./" or None, it will scan current directory. - - If config is has no path and only a filename, e.g. "myconfig.yaml" it will - search config dir. - - If config has no path and only a name with no extension, e.g. "myconfig", - it will scan for file name with yaml, yml and json. If multiple exist, it - will warn and pick the first. - - Parameters - ---------- - config : str - config file, valid examples: - - - a file name, myconfig.yaml - - relative path, ../config.yaml or ../project - - a period, . - - Raises - ------ - :class:`click.exceptions.FileError` - """ - if not config_dir: - config_dir = get_config_dir() - path = os.path - exists, join, isabs = path.exists, path.join, path.isabs - dirname, normpath, splitext = path.dirname, path.normpath, path.splitext - cwd = os.getcwd() - is_name = False - file_error = None - - config = os.path.expanduser(config) - # if purename, resolve to confg dir - if is_pure_name(config): - is_name = True - elif ( - not isabs(config) - or len(dirname(config)) > 1 - or config == "." - or config == "" - or config == "./" - ): # if relative, fill in full path - config = normpath(join(cwd, config)) - - # no extension, scan - if path.isdir(config) or not splitext(config)[1]: - if is_name: - candidates = [ - x - for x in [ - f"{join(config_dir, config)}{ext}" - for ext in VALID_CONFIG_DIR_FILE_EXTENSIONS - ] - if exists(x) - ] - if not len(candidates): - file_error = ( - "config not found in config dir (yaml/yml/json) %s " - "for name" % (config_dir) - ) - else: - candidates = [ - x - for x in [ - join(config, ext) - for ext in [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"] - ] - if exists(x) - ] - - if len(candidates) > 1: - tmuxp_echo( - click.style( - "Multiple .tmuxp.{yml,yaml,json} configs in %s" - % dirname(config), - fg="red", - ) - ) - tmuxp_echo( - click.wrap_text( - "This is undefined behavior, use only one. " - "Use file names e.g. myproject.json, coolproject.yaml. " - "You can load them by filename." - ) - ) - elif not len(candidates): - file_error = "No tmuxp files found in directory" - if len(candidates): - config = candidates[0] - elif not exists(config): - file_error = "file not found" - - if file_error: - raise FileError(file_error, config) - - return config - - -def is_pure_name(path): - """ - Return True if path is a name and not a file path. - - Parameters - ---------- - path : str - Path (can be absolute, relative, etc.) - - Returns - ------- - bool - True if path is a name of config in config dir, not file path. - """ - return ( - not os.path.isabs(path) - and len(os.path.dirname(path)) == 0 - and not os.path.splitext(path)[1] - and path != "." - and path != "" - ) diff --git a/tmuxp/config.py b/tmuxp/config.py deleted file mode 100644 index 702c12de7d..0000000000 --- a/tmuxp/config.py +++ /dev/null @@ -1,577 +0,0 @@ -"""Configuration parsing and export for tmuxp. - -tmuxp.config -~~~~~~~~~~~~ - -""" -import logging -import os -from typing import Dict - -from . import exc - -logger = logging.getLogger(__name__) - - -def validate_schema(session_config): - """ - Return True if config schema is correct. - - Parameters - ---------- - session_config : dict - session configuration - - Returns - ------- - bool - """ - - # verify session_name - if "session_name" not in session_config: - raise exc.ConfigError('config requires "session_name"') - - if "windows" not in session_config: - raise exc.ConfigError('config requires list of "windows"') - - for window in session_config["windows"]: - if "window_name" not in window: - raise exc.ConfigError('config window is missing "window_name"') - - if "plugins" in session_config: - if not isinstance(session_config["plugins"], list): - raise exc.ConfigError('"plugins" only supports list type') - - return True - - -def is_config_file(filename, extensions=[".yml", ".yaml", ".json"]): - """ - Return True if file has a valid config file type. - - Parameters - ---------- - filename : str - filename to check (e.g. ``mysession.json``). - extensions : str or list - filetypes to check (e.g. ``['.yaml', '.json']``). - - Returns - ------- - bool - """ - extensions = [extensions] if isinstance(extensions, str) else extensions - return any(filename.endswith(e) for e in extensions) - - -def in_dir( - config_dir=os.path.expanduser("~/.tmuxp"), extensions=[".yml", ".yaml", ".json"] -): - """ - Return a list of configs in ``config_dir``. - - Parameters - ---------- - config_dir : str - directory to search - extensions : list - filetypes to check (e.g. ``['.yaml', '.json']``). - - Returns - ------- - list - """ - configs = [] - - for filename in os.listdir(config_dir): - if is_config_file(filename, extensions) and not filename.startswith("."): - configs.append(filename) - - return configs - - -def in_cwd(): - """ - Return list of configs in current working directory. - - If filename is ``.tmuxp.py``, ``.tmuxp.json``, ``.tmuxp.yaml``. - - Returns - ------- - list - configs in current working directory - """ - configs = [] - - for filename in os.listdir(os.getcwd()): - if filename.startswith(".tmuxp") and is_config_file(filename): - configs.append(filename) - - return configs - - -def expandshell(_path): - """ - Return expanded path based on user's ``$HOME`` and ``env``. - - :py:func:`os.path.expanduser` and :py:func:`os.path.expandvars`. - - Parameters - ---------- - path : str - path to expand - - Returns - ------- - str - path with shell variables expanded - """ - return os.path.expandvars(os.path.expanduser(_path)) - - -def inline(session_config): - """ - Return config in inline form, opposite of :meth:`config.expand`. - - Parameters - ---------- - session_config : dict - - Returns - ------- - dict - configuration with optional inlined configs. - """ - - if ( - "shell_command" in session_config - and isinstance(session_config["shell_command"], list) - and len(session_config["shell_command"]) == 1 - ): - session_config["shell_command"] = session_config["shell_command"][0] - - if len(session_config.keys()) == int(1): - session_config = session_config["shell_command"] - if ( - "shell_command_before" in session_config - and isinstance(session_config["shell_command_before"], list) - and len(session_config["shell_command_before"]) == 1 - ): - session_config["shell_command_before"] = session_config["shell_command_before"][ - 0 - ] - - # recurse into window and pane config items - if "windows" in session_config: - session_config["windows"] = [ - inline(window) for window in session_config["windows"] - ] - if "panes" in session_config: - session_config["panes"] = [inline(pane) for pane in session_config["panes"]] - - return session_config - - -def expand_cmd(p: Dict) -> Dict: - if isinstance(p, str): - p = {"shell_command": [p]} - elif isinstance(p, list): - p = {"shell_command": p} - elif not p: - p = {"shell_command": []} - - assert isinstance(p, dict) - if "shell_command" in p: - cmds = p["shell_command"] - - if isinstance(p["shell_command"], str): - cmds = [cmds] - - if not cmds or any(a == cmds for a in [None, "blank", "pane"]): - cmds = [] - - if isinstance(cmds, list) and len(cmds) == int(1): - if any(a in cmds for a in [None, "blank", "pane"]): - cmds = [] - - for cmd_idx, cmd in enumerate(cmds): - if isinstance(cmd, str): - cmds[cmd_idx] = {"cmd": cmd} - cmds[cmd_idx]["cmd"] = expandshell(cmds[cmd_idx]["cmd"]) - - p["shell_command"] = cmds - else: - p["shell_command"] = [] - return p - - -def expand(session_config, cwd=None, parent=None): - """Return config with shorthand and inline properties expanded. - - This is necessary to keep the code in the :class:`WorkspaceBuilder` clean - and also allow for neat, short-hand configurations. - - As a simple example, internally, tmuxp expects that config options - like ``shell_command`` are a list (array):: - - 'shell_command': ['htop'] - - tmuxp configs allow for it to be simply a string:: - - 'shell_command': 'htop' - - Kaptan will load JSON/YAML files into python dicts for you. - - Parameters - ---------- - session_config : dict - the configuration for the session - cwd : str - directory to expand relative paths against. should be the dir of the - config directory. - parent : str - (used on recursive entries) start_directory of parent window or session - object. - - Returns - ------- - dict - """ - - # Note: cli.py will expand configs relative to project's config directory - # for the first cwd argument. - if not cwd: - cwd = os.getcwd() - - if "session_name" in session_config: - session_config["session_name"] = expandshell(session_config["session_name"]) - if "window_name" in session_config: - session_config["window_name"] = expandshell(session_config["window_name"]) - if "environment" in session_config: - for key in session_config["environment"]: - val = session_config["environment"][key] - val = expandshell(val) - if any(val.startswith(a) for a in [".", "./"]): - val = os.path.normpath(os.path.join(cwd, val)) - session_config["environment"][key] = val - if "global_options" in session_config: - for key in session_config["global_options"]: - val = session_config["global_options"][key] - if isinstance(val, str): - val = expandshell(val) - if any(val.startswith(a) for a in [".", "./"]): - val = os.path.normpath(os.path.join(cwd, val)) - session_config["global_options"][key] = val - if "options" in session_config: - for key in session_config["options"]: - val = session_config["options"][key] - if isinstance(val, str): - val = expandshell(val) - if any(val.startswith(a) for a in [".", "./"]): - val = os.path.normpath(os.path.join(cwd, val)) - session_config["options"][key] = val - - # Any config section, session, window, pane that can contain the - # 'shell_command' value - if "start_directory" in session_config: - session_config["start_directory"] = expandshell( - session_config["start_directory"] - ) - start_path = session_config["start_directory"] - if any(start_path.startswith(a) for a in [".", "./"]): - # if window has a session, or pane has a window with a - # start_directory of . or ./, make sure the start_directory can be - # relative to the parent. - # - # This is for the case where you may be loading a config from - # outside your shell current directory. - if parent: - cwd = parent["start_directory"] - start_path = os.path.normpath(os.path.join(cwd, start_path)) - session_config["start_directory"] = start_path - - if "before_script" in session_config: - session_config["before_script"] = expandshell(session_config["before_script"]) - if any(session_config["before_script"].startswith(a) for a in [".", "./"]): - session_config["before_script"] = os.path.normpath( - os.path.join(cwd, session_config["before_script"]) - ) - - if "shell_command" in session_config and isinstance( - session_config["shell_command"], str - ): - session_config["shell_command"] = [session_config["shell_command"]] - - if "shell_command_before" in session_config: - shell_command_before = session_config["shell_command_before"] - - session_config["shell_command_before"] = expand_cmd(shell_command_before) - - # recurse into window and pane config items - if "windows" in session_config: - session_config["windows"] = [ - expand(window, parent=session_config) - for window in session_config["windows"] - ] - elif "panes" in session_config: - pane_configs = session_config["panes"] - for pane_idx, pane_config in enumerate(pane_configs): - pane_configs[pane_idx] = {} - pane_configs[pane_idx].update(expand_cmd(pane_config)) - session_config["panes"] = [ - expand(pane, parent=session_config) for pane in pane_configs - ] - - return session_config - - -def trickle(session_config): - """Return a dict with "trickled down" / inherited config values. - - This will only work if config has been expanded to full form with - :meth:`config.expand`. - - tmuxp allows certain commands to be default at the session, window - level. shell_command_before trickles down and prepends the - ``shell_command`` for the pane. - - Parameters - ---------- - session_config : dict - the session configuration. - - Returns - ------- - dict - """ - - # prepends a pane's ``shell_command`` list with the window and sessions' - # ``shell_command_before``. - - if "start_directory" in session_config: - session_start_directory = session_config["start_directory"] - else: - session_start_directory = None - - if "suppress_history" in session_config: - suppress_history = session_config["suppress_history"] - else: - suppress_history = None - - for window_config in session_config["windows"]: - - # Prepend start_directory to relative window commands - if session_start_directory: - if "start_directory" not in window_config: - window_config["start_directory"] = session_start_directory - else: - if not any( - window_config["start_directory"].startswith(a) for a in ["~", "/"] - ): - window_start_path = os.path.join( - session_start_directory, window_config["start_directory"] - ) - window_config["start_directory"] = window_start_path - - # We only need to trickle to the window, workspace builder checks wconf - if suppress_history is not None: - if "suppress_history" not in window_config: - window_config["suppress_history"] = suppress_history - - # If panes were NOT specified for a window, assume that a single pane - # with no shell commands is desired - if "panes" not in window_config: - window_config["panes"] = [{"shell_command": []}] - - for pane_idx, pane_config in enumerate(window_config["panes"]): - commands_before = [] - - # Prepend shell_command_before to commands - if "shell_command_before" in session_config: - commands_before.extend( - session_config["shell_command_before"]["shell_command"] - ) - if "shell_command_before" in window_config: - commands_before.extend( - window_config["shell_command_before"]["shell_command"] - ) - if "shell_command_before" in pane_config: - commands_before.extend( - pane_config["shell_command_before"]["shell_command"] - ) - - if "shell_command" in pane_config: - commands_before.extend(pane_config["shell_command"]) - - window_config["panes"][pane_idx]["shell_command"] = commands_before - # pane_config['shell_command'] = commands_before - - return session_config - - -def import_tmuxinator(session_config): - """Return tmuxp config from a `tmuxinator`_ yaml config. - - .. _tmuxinator: https://github.com/aziz/tmuxinator - - Parameters - ---------- - session_config : dict - python dict for session configuration. - - Returns - ------- - dict - """ - - tmuxp_config = {} - - if "project_name" in session_config: - tmuxp_config["session_name"] = session_config.pop("project_name") - elif "name" in session_config: - tmuxp_config["session_name"] = session_config.pop("name") - else: - tmuxp_config["session_name"] = None - - if "project_root" in session_config: - tmuxp_config["start_directory"] = session_config.pop("project_root") - elif "root" in session_config: - tmuxp_config["start_directory"] = session_config.pop("root") - - if "cli_args" in session_config: - tmuxp_config["config"] = session_config["cli_args"] - - if "-f" in tmuxp_config["config"]: - tmuxp_config["config"] = tmuxp_config["config"].replace("-f", "").strip() - elif "tmux_options" in session_config: - tmuxp_config["config"] = session_config["tmux_options"] - - if "-f" in tmuxp_config["config"]: - tmuxp_config["config"] = tmuxp_config["config"].replace("-f", "").strip() - - if "socket_name" in session_config: - tmuxp_config["socket_name"] = session_config["socket_name"] - - tmuxp_config["windows"] = [] - - if "tabs" in session_config: - session_config["windows"] = session_config.pop("tabs") - - if "pre" in session_config and "pre_window" in session_config: - tmuxp_config["shell_command"] = session_config["pre"] - - if isinstance(session_config["pre"], str): - tmuxp_config["shell_command_before"] = [session_config["pre_window"]] - else: - tmuxp_config["shell_command_before"] = session_config["pre_window"] - elif "pre" in session_config: - if isinstance(session_config["pre"], str): - tmuxp_config["shell_command_before"] = [session_config["pre"]] - else: - tmuxp_config["shell_command_before"] = session_config["pre"] - - if "rbenv" in session_config: - if "shell_command_before" not in tmuxp_config: - tmuxp_config["shell_command_before"] = [] - tmuxp_config["shell_command_before"].append( - "rbenv shell %s" % session_config["rbenv"] - ) - - for window_config in session_config["windows"]: - for k, v in window_config.items(): - window_config = {"window_name": k} - - if isinstance(v, str) or v is None: - window_config["panes"] = [v] - tmuxp_config["windows"].append(window_config) - continue - elif isinstance(v, list): - window_config["panes"] = v - tmuxp_config["windows"].append(window_config) - continue - - if "pre" in v: - window_config["shell_command_before"] = v["pre"] - if "panes" in v: - window_config["panes"] = v["panes"] - if "root" in v: - window_config["start_directory"] = v["root"] - - if "layout" in v: - window_config["layout"] = v["layout"] - tmuxp_config["windows"].append(window_config) - return tmuxp_config - - -def import_teamocil(session_config): - """Return tmuxp config from a `teamocil`_ yaml config. - - .. _teamocil: https://github.com/remiprev/teamocil - - Parameters - ---------- - session_config : dict - python dict for session configuration - - Notes - ----- - - Todos: - - - change 'root' to a cd or start_directory - - width in pane -> main-pain-width - - with_env_var - - clear - - cmd_separator - """ - - tmuxp_config = {} - - if "session" in session_config: - session_config = session_config["session"] - - if "name" in session_config: - tmuxp_config["session_name"] = session_config["name"] - else: - tmuxp_config["session_name"] = None - - if "root" in session_config: - tmuxp_config["start_directory"] = session_config.pop("root") - - tmuxp_config["windows"] = [] - - for w in session_config["windows"]: - - windowdict = {"window_name": w["name"]} - - if "clear" in w: - windowdict["clear"] = w["clear"] - - if "filters" in w: - if "before" in w["filters"]: - for b in w["filters"]["before"]: - windowdict["shell_command_before"] = w["filters"]["before"] - if "after" in w["filters"]: - for b in w["filters"]["after"]: - windowdict["shell_command_after"] = w["filters"]["after"] - - if "root" in w: - windowdict["start_directory"] = w.pop("root") - - if "splits" in w: - w["panes"] = w.pop("splits") - - if "panes" in w: - for p in w["panes"]: - if "cmd" in p: - p["shell_command"] = p.pop("cmd") - if "width" in p: - # todo support for height/width - p.pop("width") - windowdict["panes"] = w["panes"] - - if "layout" in w: - windowdict["layout"] = w["layout"] - tmuxp_config["windows"].append(windowdict) - - return tmuxp_config diff --git a/tmuxp/exc.py b/tmuxp/exc.py deleted file mode 100644 index a8d78847a2..0000000000 --- a/tmuxp/exc.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Exceptions for tmuxp. - -tmuxp.exc -~~~~~~~~~ - -""" -from ._compat import implements_to_string - - -class TmuxpException(Exception): - - """Base Exception for Tmuxp Errors.""" - - -class ConfigError(TmuxpException): - - """Error parsing tmuxp configuration dict.""" - - -class EmptyConfigException(ConfigError): - - """Configuration is empty.""" - - -class TmuxpPluginException(TmuxpException): - - """Base Exception for Tmuxp Errors.""" - - -class BeforeLoadScriptNotExists(OSError): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.strerror = "before_script file '%s' doesn't exist." % self.strerror - - -@implements_to_string -class BeforeLoadScriptError(Exception): - - """Exception replacing :py:class:`subprocess.CalledProcessError` for - :meth:`tmuxp.util.run_before_script`. - """ - - def __init__(self, returncode, cmd, output=None): - self.returncode = returncode - self.cmd = cmd - self.output = output - self.message = ( - "before_script failed with returncode {returncode}.\n" - "command: {cmd}\n" - "Error output:\n" - "{output}" - ).format(returncode=self.returncode, cmd=self.cmd, output=self.output) - - def __str__(self): - return self.message diff --git a/tmuxp/log.py b/tmuxp/log.py deleted file mode 100644 index 312320f9b8..0000000000 --- a/tmuxp/log.py +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env python -"""Log utilities for tmuxp. - -tmuxp.log -~~~~~~~~~ - -""" -import logging -import time - -from colorama import Fore, Style - -LEVEL_COLORS = { - "DEBUG": Fore.BLUE, # Blue - "INFO": Fore.GREEN, # Green - "WARNING": Fore.YELLOW, - "ERROR": Fore.RED, - "CRITICAL": Fore.RED, -} - -LOG_LEVELS = { - "CRITICAL": 50, - "ERROR": 40, - "WARNING": 30, - "INFO": 20, - "DEBUG": 10, - "NOTSET": 0, -} - - -def setup_logger(logger=None, level="INFO"): - """ - Setup logging for CLI use. - - Tries to do some conditionals to prevent handlers from being added twice. - Just to be safe. - - Parameters - ---------- - logger : :py:class:`Logger` - logger instance for tmuxp - """ - if not logger: # if no logger exists, make one - logger = logging.getLogger() - - if not logger.handlers: # setup logger handlers - # channel = logging.StreamHandler() - # channel.setFormatter(log.DebugLogFormatter()) - # channel.setFormatter(log.LogFormatter()) - - logger.setLevel(level) - # logger.addHandler(channel) - - -def set_style( - message, stylized, style_before=None, style_after=None, prefix="", suffix="" -): - if stylized: - return prefix + style_before + message + style_after + suffix - - return prefix + message + suffix - - -def default_log_template(self, record, stylized=False): - """ - Return the prefix for the log message. Template for Formatter. - - Parameters - ---------- - :py:class:`logging.LogRecord` : - object. this is passed in from inside the - :py:meth:`logging.Formatter.format` record. - - Returns - ------- - str - template for logger message - """ - - reset = Style.RESET_ALL - levelname = set_style( - "(%(levelname)s)", - stylized, - style_before=(LEVEL_COLORS.get(record.levelname) + Style.BRIGHT), - style_after=Style.RESET_ALL, - suffix=" ", - ) - asctime = set_style( - "%(asctime)s", - stylized, - style_before=(Fore.BLACK + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), - prefix="[", - suffix="]", - ) - name = set_style( - "%(name)s", - stylized, - style_before=(Fore.WHITE + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), - prefix=" ", - suffix=" ", - ) - - if stylized: - return reset + levelname + asctime + name + reset - - return levelname + asctime + name - - -class LogFormatter(logging.Formatter): - template = default_log_template - - def __init__(self, color=True, *args, **kwargs): - logging.Formatter.__init__(self, *args, **kwargs) - - def format(self, record): - try: - record.message = record.getMessage() - except Exception as e: - record.message = f"Bad message ({e!r}): {record.__dict__!r}" - - date_format = "%H:%m:%S" - record.asctime = time.strftime(date_format, self.converter(record.created)) - - prefix = self.template(record) % record.__dict__ - - parts = prefix.split(record.message) - formatted = prefix + " " + record.message - return formatted.replace("\n", "\n" + parts[0] + " ") - - -def debug_log_template(self, record): - """ - Return the prefix for the log message. Template for Formatter. - - Parameters - ---------- - record : :py:class:`logging.LogRecord` - This is passed in from inside the :py:meth:`logging.Formatter.format` - record. - - Returns - ------- - str - Log template. - """ - - reset = Style.RESET_ALL - levelname = ( - LEVEL_COLORS.get(record.levelname) - + Style.BRIGHT - + "(%(levelname)1.1s)" - + Style.RESET_ALL - + " " - ) - asctime = ( - "[" - + Fore.BLACK - + Style.DIM - + Style.BRIGHT - + "%(asctime)s" - + Fore.RESET - + Style.RESET_ALL - + "]" - ) - name = ( - " " - + Fore.WHITE - + Style.DIM - + Style.BRIGHT - + "%(name)s" - + Fore.RESET - + Style.RESET_ALL - + " " - ) - module_funcName = Fore.GREEN + Style.BRIGHT + "%(module)s.%(funcName)s()" - lineno = ( - Fore.BLACK - + Style.DIM - + Style.BRIGHT - + ":" - + Style.RESET_ALL - + Fore.CYAN - + "%(lineno)d" - ) - - tpl = reset + levelname + asctime + name + module_funcName + lineno + reset - - return tpl - - -class DebugLogFormatter(LogFormatter): - """Provides greater technical details than standard log Formatter.""" - - template = debug_log_template diff --git a/tmuxp/plugin.py b/tmuxp/plugin.py deleted file mode 100644 index 15ab3862cb..0000000000 --- a/tmuxp/plugin.py +++ /dev/null @@ -1,222 +0,0 @@ -from distutils.version import LooseVersion - -import libtmux -from libtmux.common import get_version - -from .__about__ import __version__ -from .exc import TmuxpPluginException - -#: Minimum version of tmux required to run libtmux -TMUX_MIN_VERSION = "1.8" - -#: Most recent version of tmux supported -TMUX_MAX_VERSION = None - -#: Minimum version of libtmux required to run libtmux -LIBTMUX_MIN_VERSION = "0.8.3" - -#: Most recent version of libtmux supported -LIBTMUX_MAX_VERSION = None - -#: Minimum version of tmuxp required to use plugins -TMUXP_MIN_VERSION = "1.6.0" - -#: Most recent version of tmuxp -TMUXP_MAX_VERSION = None - - -class TmuxpPlugin: - def __init__( - self, - plugin_name="tmuxp-plugin", - tmux_min_version=TMUX_MIN_VERSION, - tmux_max_version=TMUX_MAX_VERSION, - tmux_version_incompatible=None, - libtmux_min_version=LIBTMUX_MIN_VERSION, - libtmux_max_version=LIBTMUX_MAX_VERSION, - libtmux_version_incompatible=None, - tmuxp_min_version=TMUXP_MIN_VERSION, - tmuxp_max_version=TMUXP_MAX_VERSION, - tmuxp_version_incompatible=None, - ): - """ - Initialize plugin. - - The default version values are set to the versions that the plugin - system requires. - - Parameters - ---------- - plugin_name : str - Name of the child plugin. Used in error message plugin fails to - load - - tmux_min_version : str - Min version of tmux that the plugin supports - - tmux_max_version : str - Min version of tmux that the plugin supports - - tmux_version_incompatible : list - Versions of tmux that are incompatible with the plugin - - libtmux_min_version : str - Min version of libtmux that the plugin supports - - libtmux_max_version : str - Max version of libtmux that the plugin supports - - libtmux_version_incompatible : list - Versions of libtmux that are incompatible with the plugin - - tmuxp_min_version : str - Min version of tmuxp that the plugin supports - - tmuxp_max_version : str - Max version of tmuxp that the plugin supports - - tmuxp_version_incompatible : list - Versions of tmuxp that are incompatible with the plugin - - """ - self.plugin_name = plugin_name - - # Dependency versions - self.tmux_version = get_version() - self.libtmux_version = libtmux.__version__ - self.tmuxp_version = LooseVersion(__version__) - - self.version_constraints = { - "tmux": { - "version": self.tmux_version, - "vmin": tmux_min_version, - "vmax": tmux_max_version, - "incompatible": tmux_version_incompatible - if tmux_version_incompatible - else [], - }, - "libtmux": { - "version": self.libtmux_version, - "vmin": libtmux_min_version, - "vmax": libtmux_max_version, - "incompatible": libtmux_version_incompatible - if libtmux_version_incompatible - else [], - }, - "tmuxp": { - "version": self.tmuxp_version, - "vmin": tmuxp_min_version, - "vmax": tmuxp_max_version, - "incompatible": tmuxp_version_incompatible - if tmuxp_version_incompatible - else [], - }, - } - - self._version_check() - - def _version_check(self): - """ - Check all dependency versions for compatibility. - """ - for dep, constraints in self.version_constraints.items(): - try: - assert self._pass_version_check(**constraints) - except AssertionError: - raise TmuxpPluginException( - "Incompatible {dep} version: {version}\n{plugin_name} " - "requirements:\nmin: {vmin} | max: {vmax} | " - "incompatible: {incompatible}\n".format( - dep=dep, plugin_name=self.plugin_name, **constraints - ) - ) - - def _pass_version_check(self, version, vmin, vmax, incompatible): - """ - Provide affirmative if version compatibility is correct. - """ - if vmin and version < LooseVersion(vmin): - return False - if vmax and version > LooseVersion(vmax): - return False - if version in incompatible: - return False - - return True - - def before_workspace_builder(self, session): - """ - Provide a session hook previous to creating the workspace. - - This runs after the session has been created but before any of - the windows/panes/commands are entered. - - Parameters - ---------- - session : :class:`libtmux.Session` - session to hook into - """ - - def on_window_create(self, window): - """ - Provide a window hook previous to doing anything with a window. - - This runs runs before anything is created in the windows, like panes. - - Parameters - ---------- - window: :class:`libtmux.Window` - window to hook into - """ - - def after_window_finished(self, window): - """ - Provide a window hook after creating the window. - - This runs after everything has been created in the window, including - the panes and all of the commands for the panes. It also runs after the - ``options_after`` has been applied to the window. - - Parameters - ---------- - window: :class:`libtmux.Window` - window to hook into - """ - - def before_script(self, session): - """ - Provide a session hook after the workspace has been built. - - This runs after the workspace has been loaded with ``tmuxp load``. It - augments instead of replaces the ``before_script`` section of the - configuration. - - This hook provides access to the LibTmux.session object for any - behavior that would be used in the ``before_script`` section of the - configuration file that needs access directly to the session object. - This runs after the workspace has been loaded with ``tmuxp load``. - - The hook augments, rather than replaces, the ``before_script`` section - of the configuration. While it is possible to do all of the - ``before_script`` configuration in this function, if a shell script - is currently being used for the configuration, it would be cleaner to - continue using the script in the ``before_section``. - - If changes to the session need to be made prior to - anything being built, please use ``before_workspace_builder`` instead. - - Parameters - ---------- - session : :class:`libtmux.Session` - session to hook into - """ - - def reattach(self, session): - """ - Provide a session hook before reattaching to the session. - - Parameters - ---------- - session : :class:`libtmux.Session` - session to hook into - """ diff --git a/tmuxp/util.py b/tmuxp/util.py deleted file mode 100644 index 118345a611..0000000000 --- a/tmuxp/util.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Utility and helper methods for tmuxp. - -tmuxp.util -~~~~~~~~~~ - -""" -import logging -import os -import shlex -import subprocess -import sys - -from libtmux.exc import LibTmuxException - -from . import exc -from ._compat import console_to_str - -logger = logging.getLogger(__name__) - -PY2 = sys.version_info[0] == 2 - - -def run_before_script(script_file, cwd=None): - """Function to wrap try/except for subprocess.check_call().""" - try: - proc = subprocess.Popen( - shlex.split(str(script_file)), - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - cwd=cwd, - ) - for line in iter(proc.stdout.readline, b""): - sys.stdout.write(console_to_str(line)) - proc.wait() - - if proc.returncode: - stderr = proc.stderr.read() - proc.stderr.close() - stderr = console_to_str(stderr).split("\n") - stderr = "\n".join(list(filter(None, stderr))) # filter empty - - raise exc.BeforeLoadScriptError( - proc.returncode, os.path.abspath(script_file), stderr - ) - - return proc.returncode - except OSError as e: - if e.errno == 2: - raise exc.BeforeLoadScriptNotExists(e, os.path.abspath(script_file)) - else: - raise e - - -def oh_my_zsh_auto_title(): - """Give warning and offer to fix ``DISABLE_AUTO_TITLE``. - - see: https://github.com/robbyrussell/oh-my-zsh/pull/257 - - """ - - if "SHELL" in os.environ and "zsh" in os.environ.get("SHELL"): - if os.path.exists(os.path.expanduser("~/.oh-my-zsh")): - # oh-my-zsh exists - if ( - "DISABLE_AUTO_TITLE" not in os.environ - or os.environ.get("DISABLE_AUTO_TITLE") == "false" - ): - print( - "Please set:\n\n" - "\texport DISABLE_AUTO_TITLE='true'\n\n" - "in ~/.zshrc or where your zsh profile is stored.\n" - 'Remember the "export" at the beginning!\n\n' - "Then create a new shell or type:\n\n" - "\t$ source ~/.zshrc" - ) - - -def raise_if_tmux_not_running(server): - """Raise exception if not running. More descriptive error if no server found.""" - try: - server.sessions - except LibTmuxException as e: - if any( - needle in str(e) - for needle in ["No such file or directory", "no server running on"] - ): - raise LibTmuxException( - "no tmux session found. Start a tmux session and try again. \n" - "Original error: " + str(e) - ) - else: - raise e - - -def get_current_pane(server): - """Return Pane if one found in env""" - if os.getenv("TMUX_PANE") is not None: - try: - return [ - p - for p in server._list_panes() - if p.get("pane_id") == os.getenv("TMUX_PANE") - ][0] - except IndexError: - pass - - -def get_session(server, session_name=None, current_pane=None): - if session_name: - session = server.find_where({"session_name": session_name}) - elif current_pane is not None: - session = server.find_where({"session_id": current_pane["session_id"]}) - else: - current_pane = get_current_pane(server) - if current_pane: - session = server.find_where({"session_id": current_pane["session_id"]}) - else: - session = server.list_sessions()[0] - - if not session: - if session_name: - raise exc.TmuxpException("Session not found: %s" % session_name) - else: - raise exc.TmuxpException("Session not found") - - return session - - -def get_window(session, window_name=None, current_pane=None): - if window_name: - window = session.find_where({"window_name": window_name}) - if not window: - raise exc.TmuxpException("Window not found: %s" % window_name) - elif current_pane is not None: - window = session.find_where({"window_id": current_pane["window_id"]}) - else: - window = session.list_windows()[0] - - return window - - -def get_pane(window, current_pane=None): - try: - if current_pane is not None: - pane = window.find_where({"pane_id": current_pane["pane_id"]}) # NOQA: F841 - else: - pane = window.attached_pane # NOQA: F841 - except exc.TmuxpException as e: - print(e) - return - - return pane diff --git a/tmuxp/workspacebuilder.py b/tmuxp/workspacebuilder.py deleted file mode 100644 index 4f085b1e15..0000000000 --- a/tmuxp/workspacebuilder.py +++ /dev/null @@ -1,489 +0,0 @@ -"""Create a tmux workspace from a configuration :py:obj:`dict`. - -tmuxp.workspacebuilder -~~~~~~~~~~~~~~~~~~~~~~ - -""" -import logging -import time - -from libtmux.exc import TmuxSessionExists -from libtmux.pane import Pane -from libtmux.server import Server -from libtmux.session import Session -from libtmux.window import Window - -from . import exc -from .util import get_current_pane, run_before_script - -logger = logging.getLogger(__name__) - - -class WorkspaceBuilder: - - """ - Load workspace from session :py:obj:`dict`. - - Build tmux workspace from a configuration. Creates and names windows, sets - options, splits windows into panes. - - The normal phase of loading is: - - 1. :term:`kaptan` imports json/yaml/ini. ``.get()`` returns python - :class:`dict`:: - - import kaptan - sconf = kaptan.Kaptan(handler='yaml') - sconf = sconfig.import_config(self.yaml_config).get() - - or from config file with extension:: - - import kaptan - sconf = kaptan.Kaptan() - sconf = sconfig.import_config('path/to/config.yaml').get() - - kaptan automatically detects the handler from filenames. - - 2. :meth:`config.expand` sconf inline shorthand:: - - from tmuxp import config - sconf = config.expand(sconf) - - 3. :meth:`config.trickle` passes down default values from session - -> window -> pane if applicable:: - - sconf = config.trickle(sconf) - - 4. (You are here) We will create a :class:`libtmux.Session` (a real - ``tmux(1)`` session) and iterate through the list of windows, and - their panes, returning full :class:`libtmux.Window` and - :class:`libtmux.Pane` objects each step of the way:: - - workspace = WorkspaceBuilder(sconf=sconf) - - It handles the magic of cases where the user may want to start - a session inside tmux (when `$TMUX` is in the env variables). - """ - - def __init__(self, sconf, plugins=[], server=None): - """ - Initialize workspace loading. - - Parameters - ---------- - sconf : dict - session config, includes a :py:obj:`list` of ``windows``. - - plugins : list - plugins to be used for this session - - server : :class:`libtmux.Server` - tmux server to build session in - - Notes - ----- - TODO: Initialize :class:`libtmux.Session` from here, in - ``self.session``. - """ - - if not sconf: - raise exc.EmptyConfigException("session configuration is empty.") - - # config.validate_schema(sconf) - - if isinstance(server, Server): - self.server = server - else: - self.server = None - - self.sconf = sconf - - self.plugins = plugins - - def session_exists(self, session_name=None): - exists = self.server.has_session(session_name) - if not exists: - return exists - - self.session = self.server.find_where({"session_name": session_name}) - return True - - def build(self, session=None, append=False): - """ - Build tmux workspace in session. - - Optionally accepts ``session`` to build with only session object. - - Without ``session``, it will use :class:`libmtux.Server` at - ``self.server`` passed in on initialization to create a new Session - object. - - Parameters - ---------- - session : :class:`libtmux.Session` - session to build workspace in - append : bool - append windows in current active session - """ - - if not session: - if not self.server: - raise exc.TmuxpException( - "WorkspaceBuilder.build requires server to be passed " - + "on initialization, or pass in session object to here." - ) - - if self.server.has_session(self.sconf["session_name"]): - self.session = self.server.find_where( - {"session_name": self.sconf["session_name"]} - ) - raise TmuxSessionExists( - "Session name %s is already running." % self.sconf["session_name"] - ) - else: - if "start_directory" in self.sconf: - session = self.server.new_session( - session_name=self.sconf["session_name"], - start_directory=self.sconf["start_directory"], - ) - else: - session = self.server.new_session( - session_name=self.sconf["session_name"] - ) - - assert self.sconf["session_name"] == session.name - assert len(self.sconf["session_name"]) > 0 - - self.session = session - self.server = session.server - - self.server._list_sessions() - assert self.server.has_session(session.name) - assert session.id - - assert isinstance(session, Session) - - for plugin in self.plugins: - plugin.before_workspace_builder(self.session) - - focus = None - - if "before_script" in self.sconf: - try: - cwd = None - - # we want to run the before_script file cwd'd from the - # session start directory, if it exists. - if "start_directory" in self.sconf: - cwd = self.sconf["start_directory"] - run_before_script(self.sconf["before_script"], cwd=cwd) - except Exception as e: - self.session.kill_session() - raise e - if "options" in self.sconf: - for option, value in self.sconf["options"].items(): - self.session.set_option(option, value) - if "global_options" in self.sconf: - for option, value in self.sconf["global_options"].items(): - self.session.set_option(option, value, _global=True) - if "environment" in self.sconf: - for option, value in self.sconf["environment"].items(): - self.session.set_environment(option, value) - - for w, wconf in self.iter_create_windows(session, append): - assert isinstance(w, Window) - - for plugin in self.plugins: - plugin.on_window_create(w) - - focus_pane = None - for p, pconf in self.iter_create_panes(w, wconf): - assert isinstance(p, Pane) - p = p - - if "layout" in wconf: - w.select_layout(wconf["layout"]) - - if "focus" in pconf and pconf["focus"]: - focus_pane = p - - if "focus" in wconf and wconf["focus"]: - focus = w - - self.config_after_window(w, wconf) - - for plugin in self.plugins: - plugin.after_window_finished(w) - - if focus_pane: - focus_pane.select_pane() - - if focus: - focus.select_window() - - def iter_create_windows(self, session, append=False): - """ - Return :class:`libtmux.Window` iterating through session config dict. - - Generator yielding :class:`libtmux.Window` by iterating through - ``sconf['windows']``. - - Applies ``window_options`` to window. - - Parameters - ---------- - session : :class:`libtmux.Session` - session to create windows in - append : bool - append windows in current active session - - Returns - ------- - tuple of (:class:`libtmux.Window`, ``wconf``) - Newly created window, and the section from the tmuxp configuration - that was used to create the window. - """ - for i, wconf in enumerate(self.sconf["windows"], start=1): - if "window_name" not in wconf: - window_name = None - else: - window_name = wconf["window_name"] - - is_first_window_pass = self.first_window_pass(i, session, append) - - w1 = None - if is_first_window_pass: # if first window, use window 1 - w1 = session.attached_window - w1.move_window(99) - - if "start_directory" in wconf: - sd = wconf["start_directory"] - else: - sd = None - - if "window_shell" in wconf: - ws = wconf["window_shell"] - else: - ws = None - - # If the first pane specifies a shell, use that instead. - try: - if wconf["panes"][0]["shell"] != "": - ws = wconf["panes"][0]["shell"] - except (KeyError, IndexError): - pass - - w = session.new_window( - window_name=window_name, - start_directory=sd, - attach=False, # do not move to the new window - window_index=wconf.get("window_index", ""), - window_shell=ws, - ) - - if is_first_window_pass: # if first window, use window 1 - session.attached_window.kill_window() - - assert isinstance(w, Window) - session.server._update_windows() - if "options" in wconf and isinstance(wconf["options"], dict): - for key, val in wconf["options"].items(): - w.set_window_option(key, val) - - if "focus" in wconf and wconf["focus"]: - w.select_window() - - session.server._update_windows() - - yield w, wconf - - def iter_create_panes(self, w, wconf): - """ - Return :class:`libtmux.Pane` iterating through window config dict. - - Run ``shell_command`` with ``$ tmux send-keys``. - - Parameters - ---------- - w : :class:`libtmux.Window` - window to create panes for - wconf : dict - config section for window - - Returns - ------- - tuple of (:class:`libtmux.Pane`, ``pconf``) - Newly created pane, and the section from the tmuxp configuration - that was used to create the pane. - """ - assert isinstance(w, Window) - - pane_base_index = int(w.show_window_option("pane-base-index", g=True)) - - p = None - - for pindex, pconf in enumerate(wconf["panes"], start=pane_base_index): - if pindex == int(pane_base_index): - p = w.attached_pane - else: - - def get_pane_start_directory(): - - if "start_directory" in pconf: - return pconf["start_directory"] - elif "start_directory" in wconf: - return wconf["start_directory"] - else: - return None - - def get_pane_shell(): - - if "shell" in pconf: - return pconf["shell"] - elif "window_shell" in wconf: - return wconf["window_shell"] - else: - return None - - p = w.split_window( - attach=True, - start_directory=get_pane_start_directory(), - shell=get_pane_shell(), - target=p.id, - ) - - assert isinstance(p, Pane) - if "layout" in wconf: - w.select_layout(wconf["layout"]) - - if "suppress_history" in pconf: - suppress = pconf["suppress_history"] - elif "suppress_history" in wconf: - suppress = wconf["suppress_history"] - else: - suppress = True - - enter = pconf.get("enter", True) - sleep_before = pconf.get("sleep_before", None) - sleep_after = pconf.get("sleep_after", None) - for cmd in pconf["shell_command"]: - enter = cmd.get("enter", enter) - sleep_before = cmd.get("sleep_before", sleep_before) - sleep_after = cmd.get("sleep_after", sleep_after) - - if sleep_before is not None: - time.sleep(sleep_before) - - p.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) - - if sleep_after is not None: - time.sleep(sleep_after) - - if "focus" in pconf and pconf["focus"]: - w.select_pane(p["pane_id"]) - - w.server._update_panes() - - yield p, pconf - - def config_after_window(self, w, wconf): - """Actions to apply to window after window and pane finished. - - When building a tmux session, sometimes its easier to postpone things - like setting options until after things are already structurally - prepared. - - Parameters - ---------- - w : :class:`libtmux.Window` - window to create panes for - wconf : dict - config section for window - """ - if "options_after" in wconf and isinstance(wconf["options_after"], dict): - for key, val in wconf["options_after"].items(): - w.set_window_option(key, val) - - def find_current_attached_session(self): - current_active_pane = get_current_pane(self.server) - - if not current_active_pane: - raise exc.TmuxpException("No session active.") - - return next( - ( - s - for s in self.server.list_sessions() - if s["session_id"] == current_active_pane["session_id"] - ), - None, - ) - - def first_window_pass(self, i, session, append): - return len(session.windows) == 1 and i == 1 and not append - - -def freeze(session): - """ - Freeze live tmux session and Return session config :py:obj:`dict`. - - Parameters - ---------- - session : :class:`libtmux.Session` - session object - - Returns - ------- - dict - tmuxp compatible workspace config - """ - sconf = {"session_name": session["session_name"], "windows": []} - - for w in session.windows: - wconf = { - "options": w.show_window_options(), - "window_name": w.name, - "layout": w.layout, - "panes": [], - } - if w.get("window_active", "0") == "1": - wconf["focus"] = "true" - - # If all panes have same path, set 'start_directory' instead - # of using 'cd' shell commands. - def pane_has_same_path(p): - return w.panes[0].current_path == p.current_path - - if all(pane_has_same_path(p) for p in w.panes): - wconf["start_directory"] = w.panes[0].current_path - - for p in w.panes: - pconf = {"shell_command": []} - - if "start_directory" not in wconf: - pconf["shell_command"].append("cd " + p.current_path) - - if p.get("pane_active", "0") == "1": - pconf["focus"] = "true" - - current_cmd = p.current_command - - def filter_interpretters_and_shells(): - return current_cmd.startswith("-") or any( - current_cmd.endswith(cmd) for cmd in ["python", "ruby", "node"] - ) - - if filter_interpretters_and_shells(): - current_cmd = None - - if current_cmd: - pconf["shell_command"].append(current_cmd) - else: - if not len(pconf["shell_command"]): - pconf = "pane" - - wconf["panes"].append(pconf) - - sconf["windows"].append(wconf) - - return sconf diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..4ff83b3877 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2136 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <4.0" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", +] + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + +[options.exclude-newer-package] +sphinx-gp-mermaid = false +libtmux = false +gp-sphinx = false +sphinx-autodoc-sphinx = false +sphinx-autodoc-api-style = false +sphinx-autodoc-fastmcp = false +sphinx-autodoc-pytest-fixtures = false +sphinx-gp-highlighting = false +sphinx-autodoc-argparse = false +sphinx-gp-llms = false +sphinx-ux-autodoc-layout = false +sphinx-ux-badges = false +gp-libs = false +sphinx-autodoc-docutils = false +gp-furo-theme = false +sphinx-gp-sitemap = false +sphinx-fonts = false +sphinx-autodoc-typehints-gp = false +sphinx-gp-opengraph = false +sphinx-gp-theme = false + +[[package]] +name = "aafigure" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/13/8516bd4dd1520c02797e4544bfac7a03521b28bb9404588197af2803ad89/aafigure-0.6.tar.gz", hash = "sha256:49f2c1fd2b579c1fffbac1386a2670b3f6f475cc7ff6cc04d8b984888c2d9e1e", size = 52292, upload-time = "2017-06-06T00:58:07.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/82/a58d09930e357b196c40e2224de6b0bfc3d15d3f880c56dbf55c67c353d9/aafigure-0.6-py2.py3-none-any.whl", hash = "sha256:c7f9409eee58d8484696a1c9e75b4c6ceab9fd060223007a19dcc6f515a30696", size = 27165, upload-time = "2017-06-06T00:58:04.894Z" }, +] + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +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/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "codecov" +version = "2.1.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/bb/594b26d2c85616be6195a64289c578662678afa4910cef2d3ce8417cf73e/codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c", size = 21416, upload-time = "2023-04-17T23:11:39.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/02/18785edcdf6266cdd6c6dc7635f1cbeefd9a5b4c3bb8aff8bd681e9dd095/codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5", size = 16512, upload-time = "2023-04-17T23:11:37.344Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, + { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "gp-furo-theme" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/1e/06ca39c8eed45e401dcb56304fc400afb2614010721315febe838e4b3cb4/gp_furo_theme-0.0.1a35.tar.gz", hash = "sha256:d2fb40acebc215fd36345adbcd6d36f454b7f8047206d7f44f5068fbae688ff9", size = 34309, upload-time = "2026-07-19T21:54:01.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/db/a2ad42d667bd9a028dfcdf3c7bc97bfc761d6ae0c4299dbabd9441ae08cb/gp_furo_theme-0.0.1a35-py3-none-any.whl", hash = "sha256:08a9a87584ac3f971e398deb21468ca63a3243d71907c7c5c01d1f80d3eea257", size = 43660, upload-time = "2026-07-19T21:53:36.455Z" }, +] + +[[package]] +name = "gp-libs" +version = "0.0.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/e3/947c93526c57541d7876b3c0177372220a904b4d8fa4024c54a47506a1c6/gp_libs-0.0.19.tar.gz", hash = "sha256:26fa76740dcfcc37b071903572b3fb3bfc71d2b545e07e7e63977838a3b2393e", size = 16022, upload-time = "2026-07-14T01:21:11.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/62/f5ad23c155267d145c96ba3485f638956b818f32ba03c7ad3fc31236c01c/gp_libs-0.0.19-py3-none-any.whl", hash = "sha256:f79c3cf4a7f646b26784c599fa39919257df7add43fc004e602777adc0e59f1e", size = 16694, upload-time = "2026-07-14T01:21:10.404Z" }, +] + +[[package]] +name = "gp-sphinx" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "gp-libs" }, + { name = "linkify-it-py" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-typehints-gp" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-fonts" }, + { name = "sphinx-gp-llms" }, + { name = "sphinx-gp-opengraph" }, + { name = "sphinx-gp-sitemap" }, + { name = "sphinx-gp-theme" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinxext-rediraffe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/6a/92bc71e25c9a4dae7bac61ce526514a63dc6229012d89c92762a0d5064af/gp_sphinx-0.0.1a35.tar.gz", hash = "sha256:44cfbbd30950f574fdc50beec1c966c1eaa1ac7e71b21bdb775b960d516cc97e", size = 19761, upload-time = "2026-07-19T21:54:02.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/b2/cd304cc360cdb6771ad63f4084257959bf6bb50b15c0f41a3549e7845f09/gp_sphinx-0.0.1a35-py3-none-any.whl", hash = "sha256:d8772bb635e4001368c1432a5a97b2042e207e381f947e8317df31caf0fac011", size = 20277, upload-time = "2026-07-19T21:53:37.966Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "libtmux" +version = "0.61.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/c2/6abbac4420c35b3f1140c72607117236b160173f74fd86941f99b28375d5/libtmux-0.61.0.tar.gz", hash = "sha256:2d6081081a629b9236a36a64f874667533811d2ce5a1b0caa9c821f4d9d2e618", size = 546122, upload-time = "2026-07-04T12:57:36.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/14/934d0d9076d1d46fcc71f3397daea7921363781674337084b15d1bbcbf84/libtmux-0.61.0-py3-none-any.whl", hash = "sha256:b9315db6600757f840a8f16438725103e579aaa4be3c32728c105f2c284330df", size = 118059, upload-time = "2026-07-04T12:57:34.598Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { 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/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, + { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/60/a90ca1cc6cffcb97b4260ed0ad2b7934b999d7c48abe4ea0840344862a3b/pytest_rerunfailures-16.4.tar.gz", hash = "sha256:8222d17c37eb7b9e4d6fc96a3c724ff4e1a5c97a5cc7cbb2c19e9282cfd21a11", size = 36635, upload-time = "2026-07-01T06:30:56.813Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/93/3cdcc4033444e822e01b573414b03fd37fd5533070c750477b8f5fa5224b/pytest_rerunfailures-16.4-py3-none-any.whl", hash = "sha256:f69b5beb39622c90d1e44bd945d826eff6db545dcf0b68f52b7e4ad15eaf6d6c", size = 16955, upload-time = "2026-07-01T06:30:55.333Z" }, +] + +[[package]] +name = "pytest-watcher" +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/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/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]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[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 = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "roman-numerals" }, +] +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/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.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2024.10.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa", size = 11908, upload-time = "2024-10-02T23:15:28.739Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2025.8.25" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "colorama" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, +] + +[[package]] +name = "sphinx-autodoc-api-style" +version = "0.0.1a35" +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'" }, + { name = "sphinx-ux-autodoc-layout" }, + { name = "sphinx-ux-badges" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/11/5b740da185145e5514ca768ed482de5ee2cbffd2f4d239a610eb6ab5e7b3/sphinx_autodoc_api_style-0.0.1a35.tar.gz", hash = "sha256:39a3a3ebbeef23e7caae58bb437da1edb30af9a68c328a35198622993dc24c5a", size = 9453, upload-time = "2026-07-19T21:54:03.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ec/8c984b39cc57ca21b2581f4095f03025ec19f10016aa4a5398041da51142/sphinx_autodoc_api_style-0.0.1a35-py3-none-any.whl", hash = "sha256:45c6a77e0f8df4c4d18c5be55d3db57c00690eb76bce0bf1fa67de1dd64a3a12", size = 9569, upload-time = "2026-07-19T21:53:39.479Z" }, +] + +[[package]] +name = "sphinx-autodoc-argparse" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/07/ae5226132714963082214ef73dd39e31f58c05ebe79cf5b4c690eab7b4ee/sphinx_autodoc_argparse-0.0.1a35.tar.gz", hash = "sha256:2e668dac2f115017bd8f95c5997ec174833159589577096b1ebb591b4ca6ed7a", size = 42803, upload-time = "2026-07-19T21:54:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/28/8d823a8ad5be0dcba4e62a9f9aa72985b0b7647b5cfc7c185fe7fcf36dd2/sphinx_autodoc_argparse-0.0.1a35-py3-none-any.whl", hash = "sha256:582329af0204874216cf081f5d6a31f6a4d3c070cfa6bdf5b03b0de506ee2309", size = 47654, upload-time = "2026-07-19T21:53:40.753Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints-gp" +version = "0.0.1a35" +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/8b/6e/f9341eb41928e4512f011b480cc40db51f10718b8458981e7d774b8d7166/sphinx_autodoc_typehints_gp-0.0.1a35.tar.gz", hash = "sha256:38897bb5d3cb1d356cd86283594bf67853df279260493a3860e043c6eaf4cf65", size = 36865, upload-time = "2026-07-19T21:54:09.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/e4/78eab0d6113aa6c6051efa0bb19fde3e16f76722ad765bfe1a80e69bf1e8/sphinx_autodoc_typehints_gp-0.0.1a35-py3-none-any.whl", hash = "sha256:fdbd3e3e63c6690f8e0a56abc167e6c74964b5e30a0ee6fbdf931a3aee7ac6ba", size = 39049, upload-time = "2026-07-19T21:53:47.797Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +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/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +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/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, +] + +[[package]] +name = "sphinx-fonts" +version = "0.0.1a35" +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/2d/db/db42563a8b22886241c00d319cb7c7297c074ce5d45ff4ed23f2fa4b001f/sphinx_fonts-0.0.1a35.tar.gz", hash = "sha256:13a1c83a02c0f14547f0295d8d9d9f5728ba5fb19793b319dbadddace714ee4c", size = 5788, upload-time = "2026-07-19T21:54:10.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/73/875faebf651c63eb29462ae181b70e164b707d53b245770ab8aa74c93ed2/sphinx_fonts-0.0.1a35-py3-none-any.whl", hash = "sha256:c1a78e43591937d32dab3e4335cfd810f1b3ccc656e2deb873b10d55b179f07d", size = 4360, upload-time = "2026-07-19T21:53:48.957Z" }, +] + +[[package]] +name = "sphinx-gp-highlighting" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/8e/7a169e2f3f8015d1e2af4921186cc032a8cd29e119a33cbb84f426f8a8db/sphinx_gp_highlighting-0.0.1a35.tar.gz", hash = "sha256:d51ddbc437b5458a8c39f7a09761b8332fa9599f6f10468686574cf4f4936ddf", size = 7453, upload-time = "2026-07-19T21:54:11.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/6a/b31a5d0614af78f6d13601d2501a6766b245b395e9d395f99a8f7bfdd135/sphinx_gp_highlighting-0.0.1a35-py3-none-any.whl", hash = "sha256:93fb5de31af957c7fa0ecb0d0448c3f098ccd6ddcd32c480d0ac5a10441c28b0", size = 7388, upload-time = "2026-07-19T21:53:50.117Z" }, +] + +[[package]] +name = "sphinx-gp-llms" +version = "0.0.1a35" +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/67/9c/e44a783c0efcf05a0c0589dc66e82d76754a6d4873da3f869c4183fc4b7f/sphinx_gp_llms-0.0.1a35.tar.gz", hash = "sha256:6b511c2f3e7301087ef5c994b9805882a95064a1bb9c40a8bb849528f5103d62", size = 9911, upload-time = "2026-07-19T21:54:12.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/44/81938643bfb8ecfd06fbd0e55326e0f0924b2f0aa06856f14ecaacaa1485/sphinx_gp_llms-0.0.1a35-py3-none-any.whl", hash = "sha256:7f245717fd75218e1cf430fdc774a184a455ace6ec52c6c67a0196e721a2d46c", size = 12638, upload-time = "2026-07-19T21:53:51.27Z" }, +] + +[[package]] +name = "sphinx-gp-mermaid" +version = "0.0.1a35" +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/79/7a/2e2510a76ff3a6fb66307bbc4cdbb86ed98b91818559efdc281e3bd14897/sphinx_gp_mermaid-0.0.1a35.tar.gz", hash = "sha256:bdab75ef56933541839463efbbb064315ebe1e03234e925c60ff6cfa824d4494", size = 71320, upload-time = "2026-07-19T21:54:13.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/56/1301a894a30015d53e0868a4bf0305c76a0fdda88fd53069218ef8e3550f/sphinx_gp_mermaid-0.0.1a35-py3-none-any.whl", hash = "sha256:205e765bab7666d2bc4d8848d819b10a24886088a5bdc664fde49cb6704d3b86", size = 12816, upload-time = "2026-07-19T21:53:52.616Z" }, +] + +[[package]] +name = "sphinx-gp-opengraph" +version = "0.0.1a35" +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/08/39/26f3f640d542dd0ce29fda607b7ba14e69be570361d977f1472483fb5ce5/sphinx_gp_opengraph-0.0.1a35.tar.gz", hash = "sha256:7cdff5b7bd301ef07f3a38c9506e89e2650fa21380f8bbf5e961aedc0bd9e91b", size = 11947, upload-time = "2026-07-19T21:54:13.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/a2/9df8761ba248add19fddf75ce83b2802bd1c753e7a835b9ed4ec75f9280c/sphinx_gp_opengraph-0.0.1a35-py3-none-any.whl", hash = "sha256:75c6a1bffcc21e9484fd5f65b0688cf6063bcf1648f122abd451cc4e7b7a9a80", size = 12185, upload-time = "2026-07-19T21:53:53.803Z" }, +] + +[[package]] +name = "sphinx-gp-sitemap" +version = "0.0.1a35" +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/3d/de/6f2ae4274b696d6ae4e19dc2323481c93ee45c7e7ac2fa84f7721042877f/sphinx_gp_sitemap-0.0.1a35.tar.gz", hash = "sha256:83d2ab5b307f3ef03215c0a51c0cae52d46c4bfead2d3c4343ba56d670d7c30f", size = 9956, upload-time = "2026-07-19T21:54:14.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/12/8e5cf2ce8cdae9ab3fbf4cd78ada87d0e8539ed6481d286cb3640d7d63da/sphinx_gp_sitemap-0.0.1a35-py3-none-any.whl", hash = "sha256:1852bddc1f67820394cd6c40be1040fc2e9830f63ba2786505c0d55591efb2ac", size = 8982, upload-time = "2026-07-19T21:53:54.969Z" }, +] + +[[package]] +name = "sphinx-gp-theme" +version = "0.0.1a35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gp-furo-theme" }, + { 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/54/33/c70d2736ad56f10dfda3086a5001aec03da53197aeb7289c3e20a4b53547/sphinx_gp_theme-0.0.1a35.tar.gz", hash = "sha256:8c699698cbe65fbdbe6d64201778c4339c4f9707feb6c618c8f4bad6dc191bf0", size = 18486, upload-time = "2026-07-19T21:54:15.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/d9/eed280cbae7110e6930b134605c2e6be76350b67b7fcbb47e25e9ea7b3e3/sphinx_gp_theme-0.0.1a35-py3-none-any.whl", hash = "sha256:5978fe159e467e3263383850331ff14d3f814631e060883764f68278068e0d36", size = 20106, upload-time = "2026-07-19T21:53:56.179Z" }, +] + +[[package]] +name = "sphinx-inline-tabs" +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/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/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]] +name = "sphinx-ux-autodoc-layout" +version = "0.0.1a35" +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/34/75/b6b218eb91a7fd24710ebafd4e0be7e3a1a598a7adb389d330a47719345d/sphinx_ux_autodoc_layout-0.0.1a35.tar.gz", hash = "sha256:aec957122a4e20015fd93d5d313d883d934f22fb1f38ec6edcb19d475f6e10b9", size = 30602, upload-time = "2026-07-19T21:54:16.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/3bc568bb987755aeb2617054c2949f83ff552e05a8de6d33cf5602b2044b/sphinx_ux_autodoc_layout-0.0.1a35-py3-none-any.whl", hash = "sha256:69110289993ed8be86ba256c2f833f31e6b7628c858a5fa6842ed2d93a9a1ee6", size = 34999, upload-time = "2026-07-19T21:53:57.624Z" }, +] + +[[package]] +name = "sphinx-ux-badges" +version = "0.0.1a35" +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/9a/b6/3cda8ad1a84fd2712a8220a8c97b852e93ed88644dd118911ba7de348faa/sphinx_ux_badges-0.0.1a35.tar.gz", hash = "sha256:f2d322c0d1d8bd51804a10daa1ee9e93adab6b5977c13c85b6a7d5ed29efd770", size = 16723, upload-time = "2026-07-19T21:54:17.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/32/6fcaeda520e242521084220442a5cabefae075ec3477261e0530dacec9d7/sphinx_ux_badges-0.0.1a35-py3-none-any.whl", hash = "sha256:c73426d800e59d403411921cbc37db41bcee70501cb856e50a812c545263de05", size = 17487, upload-time = "2026-07-19T21:53:58.872Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sphinxext-rediraffe" +version = "0.3.0" +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/e3/a9/ab13d156049eea633f992424f3e92cb40e3f1b606bb6d01d40a27457d38a/sphinxext_rediraffe-0.3.0.tar.gz", hash = "sha256:f319b3ccb7c3c3b6f63ffa6fd3eeb171b6d272df55075a9e84364394f391f507", size = 22114, upload-time = "2025-09-28T15:31:53.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/55/ab40a0d1378ee5c859590a633052cf1d0a1f8435af87558a9f7cd576601a/sphinxext_rediraffe-0.3.0-py3-none-any.whl", hash = "sha256:f4220beafa99c99177488276b8e4fcf61fbeeec4253c1e4aae841a18c475330c", size = 7194, upload-time = "2025-09-28T15:31:52.388Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +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/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tmuxp" +version = "1.74.0" +source = { editable = "." } +dependencies = [ + { name = "libtmux" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +coverage = [ + { name = "codecov" }, + { name = "coverage" }, + { name = "pytest-cov" }, +] +dev = [ + { name = "aafigure" }, + { name = "codecov" }, + { name = "coverage" }, + { name = "gp-libs" }, + { name = "gp-sphinx" }, + { name = "mypy" }, + { name = "pillow" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-api-style" }, + { name = "sphinx-autodoc-argparse" }, + { name = "sphinx-gp-highlighting" }, + { name = "sphinx-gp-mermaid" }, + { name = "types-docutils" }, + { name = "types-pygments" }, + { name = "types-pyyaml" }, +] +docs = [ + { name = "aafigure" }, + { name = "gp-libs" }, + { name = "gp-sphinx" }, + { name = "pillow" }, + { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-api-style" }, + { name = "sphinx-autodoc-argparse" }, + { name = "sphinx-gp-highlighting" }, + { name = "sphinx-gp-mermaid" }, +] +lint = [ + { name = "mypy" }, + { name = "ruff" }, + { name = "types-docutils" }, + { name = "types-pygments" }, + { name = "types-pyyaml" }, +] +testing = [ + { name = "gp-libs" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, +] + +[package.metadata] +requires-dist = [ + { name = "libtmux", specifier = "~=0.61.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +coverage = [ + { name = "codecov" }, + { name = "coverage" }, + { name = "pytest-cov" }, +] +dev = [ + { name = "aafigure" }, + { name = "codecov" }, + { name = "coverage" }, + { name = "gp-libs", specifier = ">=0.0.19" }, + { name = "gp-sphinx", specifier = "==0.0.1a35" }, + { name = "mypy" }, + { name = "pillow" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, + { name = "ruff" }, + { name = "sphinx-autobuild" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a35" }, + { name = "sphinx-autodoc-argparse", specifier = "==0.0.1a35" }, + { name = "sphinx-gp-highlighting", specifier = "==0.0.1a35" }, + { name = "sphinx-gp-mermaid", specifier = "==0.0.1a35" }, + { name = "types-docutils" }, + { name = "types-pygments" }, + { name = "types-pyyaml" }, +] +docs = [ + { name = "aafigure" }, + { name = "gp-libs", specifier = ">=0.0.19" }, + { name = "gp-sphinx", specifier = "==0.0.1a35" }, + { name = "pillow" }, + { name = "sphinx-autobuild" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a35" }, + { name = "sphinx-autodoc-argparse", specifier = "==0.0.1a35" }, + { name = "sphinx-gp-highlighting", specifier = "==0.0.1a35" }, + { name = "sphinx-gp-mermaid", specifier = "==0.0.1a35" }, +] +lint = [ + { name = "mypy" }, + { name = "ruff" }, + { name = "types-docutils" }, + { name = "types-pygments" }, + { name = "types-pyyaml" }, +] +testing = [ + { name = "gp-libs", specifier = ">=0.0.19" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "types-docutils" +version = "0.22.3.20260712" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/24/f50d49be6d5ebbae29ff83418b6788fc9897fdfb54d56555c9ae4b15fb53/types_docutils-0.22.3.20260712.tar.gz", hash = "sha256:bed54a50136c8e7613c03ee1c51eb958b42754915df83535de356b974ca05877", size = 57848, upload-time = "2026-07-12T05:13:36.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/50/2b408d249cc7260b2296af68c395d90267c23288d1eb96765637eb6c1490/types_docutils-0.22.3.20260712-py3-none-any.whl", hash = "sha256:4bbc5cf949f8b1b1c7e1befa5a4d1c3bcad9f22f823538eea26ba7ff694fa38e", size = 91970, upload-time = "2026-07-12T05:13:35.141Z" }, +] + +[[package]] +name = "types-pygments" +version = "2.20.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-docutils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/66/3e27e8dbe72947d51355d1fcba222a55bf5f0770ee660e9cc9df0d1ce5d7/types_pygments-2.20.0.20260518.tar.gz", hash = "sha256:bcab233d0389cb0a91146eb860e7bdcbceaa0f30bee73cefc7b367129cc4a330", size = 21152, upload-time = "2026-05-18T06:07:26.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/d6/5f19f5b633af6a7666c6096fdd06b70f0d4a8f585af5e18a3ef58ad47ec1/types_pygments-2.20.0.20260518-py3-none-any.whl", hash = "sha256:e40728efd00c9da5936366648d5b3c55e72ed52bdd80495a96577b4d717c4fcb", size = 29002, upload-time = "2026-05-18T06:07:26.054Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.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/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/31/cd11d2796b95c93645bac8e396b0f4bac0896a07a7b87d473bfc359f02c3/websockets-16.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de72a9c611178b15557d98eabd3101c9663c4d68938510478a6d162f99afd213", size = 179772, upload-time = "2026-07-10T06:30:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/34306d802f9b599eab041688a2086318037560cfae616a860234cca575b6/websockets-16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:37b0e4d726ffea3776670092d3d13e1cb605076f036a695fd1259de0d9b9fe02", size = 177457, upload-time = "2026-07-10T06:30:24.636Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/36ebbb978a7af70ff952afe5b22561264967164e9ad68b6734cae94efeb4/websockets-16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:00d50c0a27098fcb7ab47b3d99a1b1159b534dbcd959fbf05113ebc37e5f927b", size = 177737, upload-time = "2026-07-10T06:30:25.954Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/944f341d0d3c0450ffd3d171479531df1818cb1df1623af4065113999c44/websockets-16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1acb698bff1da1782b31aebd8d7a24d7d05453964abcd7d03dbf6e25893908e8", size = 186244, upload-time = "2026-07-10T06:30:27.235Z" }, + { url = "https://files.pythonhosted.org/packages/32/e5/a9b98fc49ef0214718a9c839c6c63856a921877256ec46f371be32decfa8/websockets-16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc2c453f3b5f99c56b16e233aad5299860558487d26adb2ed27a00c14ca24b8c", size = 187484, upload-time = "2026-07-10T06:30:28.615Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7a/a575b52ca090b1976ffbe4b5f0762d03f399dfcb48eab883101331be71a9/websockets-16.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1a9f08a0728b0835f1c6abe1d9b746ab3de49b7336a0e1919cf96be1e76273eb", size = 190143, upload-time = "2026-07-10T06:30:29.91Z" }, + { url = "https://files.pythonhosted.org/packages/7c/40/705fbbd5677242fd36f724e9a94103e6bbdcb7d71e8f4498bfc1a8a7d413/websockets-16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a089979d6173b27af18026c8d8b0077f83669a9169174482c4651e9f5739a5b6", size = 188004, upload-time = "2026-07-10T06:30:31.357Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0c/58227c8d66b1c4060c53bac8e066fb4fe2603060408e934f48660a448d72/websockets-16.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3c18dba232ec2b92a68579c9fed8ff5a18f853d1e09fc0b6ca3159e94f689fe", size = 186689, upload-time = "2026-07-10T06:30:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d0/5c1314782594aa347e0f18808ee277a61986a2a2f9f470df9893183995bd/websockets-16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c1eb7df4170d5068892a8834fb5c07b9552353deb0dbeb0bff3820481ae4792", size = 184559, upload-time = "2026-07-10T06:30:34.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e6/109c6f16850fd674b7e3d0e58b8987f05d3881abaa25f42a9faf5e85f097/websockets-16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c522bd48e625b6d557aa228967258d6d3da031c4cc21d3352fb302479aa9ba0a", size = 186997, upload-time = "2026-07-10T06:30:35.397Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ec/6afa1aebc59426438b85cf7a3868c53a89005e2250a648c99e99943b90a4/websockets-16.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d106396927a7f00b0f3a69215c3357f87bf0bca6844247121f7e8291e826a3b1", size = 185621, upload-time = "2026-07-10T06:30:36.88Z" }, + { url = "https://files.pythonhosted.org/packages/27/24/c038fe8682e9345bfa422d2cc5cc68b0491ab942c92e176bf8dfa6e8331f/websockets-16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d71bed12909b8039955536e192867d02d76cd3797cedfd0facf822e7668636c3", size = 187384, upload-time = "2026-07-10T06:30:38.096Z" }, + { url = "https://files.pythonhosted.org/packages/30/ca/dc0ef2be39c67394e24bc982a0af59cd6249bf2f4e4272813c5c505d0da9/websockets-16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9c1cf6f9a936b030b5bed0e800c5ee32069338129084546baf5ff5014dc62fa9", size = 185258, upload-time = "2026-07-10T06:30:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/3ffecd83ca3404b41fbdf8e9b178e55b529cd59bf64ea08b5a37b616b568/websockets-16.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3fd3e6a7af2c8fcdcf4ffbeaf7f54a567b91a83267204187797f31faaa2a4efa", size = 186050, upload-time = "2026-07-10T06:30:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/75/26/2e068497c78f31591a610ab7ef6d8d383ecadbe98f9121e1ebda77ef6d2b/websockets-16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dddd27175bf640acae5561fa79b77e8ec71fc445816200523e5c19b6a556fb72", size = 186273, upload-time = "2026-07-10T06:30:42.309Z" }, + { url = "https://files.pythonhosted.org/packages/44/ab/4dc049cb2c9e1be3a2c6fef77118f9c5049979e99cd56a97759d2f40f980/websockets-16.1-cp310-cp310-win32.whl", hash = "sha256:cce36c80b3f2fede7942f1756d3d885fa6fa086766c8c1bcf00695ab80f0d51a", size = 180157, upload-time = "2026-07-10T06:30:43.565Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/5e010ce5f66a8e5df380843f704ada508195a021c0c8a0f933639c9ee1c0/websockets-16.1-cp310-cp310-win_amd64.whl", hash = "sha256:115fc4695b94bb855995b23fb1abcb66099a5995575d3d5bc5605a616c58d0eb", size = 180458, upload-time = "2026-07-10T06:30:45.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" }, + { url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, + { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, + { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, + { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, + { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, + { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +]