diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d202a332d --- /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/workflows/docs.yml b/.github/workflows/docs.yml index 52a31a57c..9e2db16c7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,12 +10,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.12'] + python-version: ['3.14'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Filter changed file paths to outputs - uses: dorny/paths-filter@v2.11.1 + uses: dorny/paths-filter@v3.0.2 id: changes with: filters: | @@ -27,38 +27,37 @@ jobs: - 'examples/**' python_files: - 'src/libtmux/**' - - poetry.lock + - uv.lock - pyproject.toml - 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 poetry + - name: Install uv if: env.PUBLISH == 'true' - run: pipx install "poetry==1.7.1" + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - name: Set up Python ${{ matrix.python-version }} if: env.PUBLISH == 'true' - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: 'poetry' + run: uv python install ${{ matrix.python-version }} - name: Install dependencies [w/ docs] if: env.PUBLISH == 'true' - run: poetry install --with=docs,lint + run: uv sync --all-extras --dev - name: Print python versions if: env.PUBLISH == 'true' run: | python -V - poetry run python -V + uv run python -V - name: Build documentation if: env.PUBLISH == 'true' run: | - pushd docs; make SPHINXBUILD='poetry run sphinx-build' html; popd + pushd docs; make SPHINXBUILD='uv run sphinx-build' html; popd - name: Push documentation to S3 uses: jakejarvis/s3-sync-action@v0.5.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 218b56889..d8c5b6b56 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,23 +10,34 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.12'] - tmux-version: ['2.6', '2.7', '2.8', '3.0a', '3.1b', '3.2a', '3.3a', 'master'] + python-version: ['3.14'] + tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', 'master'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Install poetry - run: pipx install "poetry==1.7.1" + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: 'poetry' + run: uv python install ${{ matrix.python-version }} + + - name: Test runtime dependencies + run: | + uv run --no-dev -p python${{ matrix.python-version }} -- python -c ' + from libtmux import common, constants, exc, formats, neo, pane, server, session, window, __version__ + server = server.Server() + print("libtmux version:", __version__) + print("libtmux Server:", server) + ' + + - 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@v3 + uses: actions/cache@v4 with: path: ~/tmux-builds/tmux-${{ matrix.tmux-version }} key: tmux-${{ matrix.tmux-version }} @@ -46,23 +57,19 @@ jobs: cd ~ tmux -V - - name: Install python dependencies - run: | - poetry install --with=test,coverage,lint - - - name: Lint with ruff - run: poetry run ruff . + - name: Lint with ruff check + run: uv run ruff check . - - name: Format with ruff - run: poetry run ruff format . --check + - name: Format with ruff format + run: uv run ruff format . --check - name: Lint with mypy - run: poetry run mypy . + run: uv run mypy . - name: Print python versions run: | python -V - poetry run python -V + uv run python -V - name: Test with pytest continue-on-error: ${{ matrix.tmux-version == 'master' }} @@ -71,12 +78,12 @@ jobs: 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-append --cov-report=xml + uv run py.test --cov=./ --cov-append --cov-report=xml -n auto --verbose env: COV_CORE_SOURCE: . COV_CORE_CONFIG: .coveragerc COV_CORE_DATAFILE: .coverage.eager - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -84,31 +91,35 @@ jobs: runs-on: ubuntu-latest needs: build if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + permissions: + id-token: write # Required for OIDC trusted publishing + attestations: write # Required for generating attestations strategy: matrix: - python-version: ['3.12'] + python-version: ['3.14'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Install poetry - run: pipx install "poetry==1.7.1" + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: 'poetry' + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --dev - name: Build package if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - run: poetry build + run: uv build - name: Publish package if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} - skip_existing: true + attestations: true + skip-existing: true diff --git a/.gitignore b/.gitignore index d8c8a65a4..c15b86ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,8 @@ target/ # Monkeytype monkeytype.sqlite3 + +# Claude code +**/CLAUDE.local.md +**/CLAUDE.*.md +**/.claude/settings.local.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..b40402760 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.8.0 diff --git a/.python-version b/.python-version index 73e274086..6324d401a 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12.1 3.11.6 3.10.13 3.9.18 3.8.18 3.7.17 +3.14 diff --git a/.tmuxp.yaml b/.tmuxp.yaml index 6cbc4a146..38ca2cad9 100644 --- a/.tmuxp.yaml +++ b/.tmuxp.yaml @@ -1,13 +1,13 @@ session_name: libtmux start_directory: ./ # load session relative to config location (project root). shell_command_before: -- '[ -f .venv/bin/activate ] && source .venv/bin/activate && reset' +- uv virtualenv --quiet > /dev/null 2>&1 && clear windows: - window_name: libtmux focus: True layout: main-horizontal options: - main-pane-height: 35 + main-pane-height: 67% panes: - focus: true - pane @@ -16,7 +16,7 @@ windows: - window_name: docs layout: main-horizontal options: - main-pane-height: 35 + main-pane-height: 67% start_directory: docs/ panes: - focus: true diff --git a/.tool-versions b/.tool-versions index 224ec45da..ecb9f91f6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -poetry 1.7.1 -python 3.12.1 3.11.6 3.10.13 3.9.18 3.8.18 3.7.17 +uv 0.9.17 +python 3.14 3.13.11 3.12.12 3.11.14 3.10.19 diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 000000000..e5c31a13e --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,136 @@ +# 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 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 +- 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 000000000..40b0e0ef4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,320 @@ +# AGENTS.md + +This file provides guidance to AI agents (including Claude Code, Cursor, and other LLM-powered tools) when working with code in this repository. + +## CRITICAL REQUIREMENTS + +### Test Success +- ALL tests MUST pass for code to be considered complete and working +- Never describe code as "working as expected" if there are ANY failing tests +- Even if specific feature tests pass, failing tests elsewhere indicate broken functionality +- Changes that break existing tests must be fixed before considering implementation complete +- A successful implementation must pass linting, type checking, AND all existing tests + +## Project Overview + +libtmux is a typed Python library that provides an Object-Relational Mapping (ORM) wrapper for interacting programmatically with [tmux](https://github.com/tmux/tmux), a terminal multiplexer. + +Key features: +- Manage tmux servers, sessions, windows, and panes programmatically +- Typed Python API with full type hints +- Built on tmux's target and formats system +- Powers [tmuxp](https://github.com/tmux-python/tmuxp), a tmux workspace manager +- Provides pytest fixtures for testing with tmux + +## Development Environment + +This project uses: +- Python 3.10+ +- [uv](https://github.com/astral-sh/uv) for dependency management +- [ruff](https://github.com/astral-sh/ruff) for linting and formatting +- [mypy](https://github.com/python/mypy) for type checking +- [pytest](https://docs.pytest.org/) for testing + - [pytest-watcher](https://github.com/olzhasar/pytest-watcher) for continuous testing + +## Common Commands + +### Setting Up Environment + +```bash +# Install dependencies +uv pip install --editable . +uv pip sync + +# Install with development dependencies +uv pip install --editable . -G dev +``` + +### Running Tests + +```bash +# Run all tests +make test +# or directly with pytest +uv run pytest + +# Run a single test file +uv run pytest tests/test_pane.py + +# Run a specific test +uv run pytest tests/test_pane.py::test_send_keys + +# Run tests with test watcher +make start +# or +uv run ptw . + +# Run tests with doctests +uv run ptw . --now --doctest-modules +``` + +### Linting and Type Checking + +```bash +# Run ruff for linting +make ruff +# or directly +uv run ruff check . + +# Format code with ruff +make ruff_format +# or directly +uv run ruff format . + +# Run ruff linting with auto-fixes +uv run ruff check . --fix --show-fixes + +# Run mypy for type checking +make mypy +# or directly +uv run mypy src tests + +# Watch mode for linting (using entr) +make watch_ruff +make watch_mypy +``` + +### Development Workflow + +Follow this workflow for code changes: + +1. **Format First**: `uv run ruff format .` +2. **Run Tests**: `uv run pytest` +3. **Run Linting**: `uv run ruff check . --fix --show-fixes` +4. **Check Types**: `uv run mypy` +5. **Verify Tests Again**: `uv run pytest` + +### Documentation + +```bash +# Build documentation +make build_docs + +# Start documentation server with auto-reload +make start_docs + +# Update documentation CSS/JS +make design_docs +``` + +## Code Architecture + +libtmux follows an object-oriented design that mirrors tmux's hierarchy: + +``` +Server (tmux server instance) + └─ Session (tmux session) + └─ Window (tmux window) + └─ Pane (tmux pane) +``` + +### Core Modules + +1. **Server** (`src/libtmux/server.py`) + - Represents a tmux server instance + - Manages sessions + - Executes tmux commands via `tmux()` method + - Entry point for most libtmux interactions + +2. **Session** (`src/libtmux/session.py`) + - Represents a tmux session + - Manages windows within the session + - Provides session-level operations (attach, kill, rename, etc.) + +3. **Window** (`src/libtmux/window.py`) + - Represents a tmux window + - Manages panes within the window + - Provides window-level operations (split, rename, move, etc.) + +4. **Pane** (`src/libtmux/pane.py`) + - Represents a tmux pane (terminal instance) + - Provides pane-level operations (send-keys, capture, resize, etc.) + - Core unit for command execution and output capture + +5. **Common** (`src/libtmux/common.py`) + - Base classes and shared functionality + - `TmuxRelationalObject` and `TmuxMappingObject` base classes + - Format handling and command execution + +6. **Formats** (`src/libtmux/formats.py`) + - Tmux format string constants + - Used for querying tmux state + +7. **Neo** (`src/libtmux/neo.py`) + - Modern query interface and dataclass-based objects + - Alternative to traditional ORM-style objects + +8. **pytest Plugin** (`src/libtmux/pytest_plugin.py`) + - Provides fixtures for testing with tmux + - Creates temporary tmux sessions/windows/panes + +## Testing Strategy + +libtmux uses pytest for testing with custom fixtures. The pytest plugin (`pytest_plugin.py`) defines fixtures for creating temporary tmux objects for testing. These include: + +- `server`: A tmux server instance for testing +- `session`: A tmux session for testing +- `window`: A tmux window for testing +- `pane`: A tmux pane for testing + +These fixtures handle setup and teardown automatically, creating isolated test environments. + +### Testing Guidelines + +1. **Use existing fixtures over mocks** + - Use fixtures from conftest.py instead of `monkeypatch` and `MagicMock` when available + - For 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` + +3. **Running tests continuously** + - Use pytest-watcher during development: `uv run ptw .` + - For doctests: `uv run ptw . --now --doctest-modules` + +### Example Fixture Usage + +```python +def test_window_rename(window): + """Test renaming a window.""" + # window is already a Window instance with a live tmux window + window.rename_window('new_name') + assert window.window_name == 'new_name' +``` + +## Coding Standards + +Key highlights: + +### Imports + +- **Use namespace imports for standard library modules**: `import enum` instead of `from enum import Enum` + - **Exception**: `dataclasses` module may use `from dataclasses import dataclass, field` for cleaner decorator syntax + - This rule applies to Python standard library only; third-party packages may use `from X import Y` +- **For typing**, use `import typing as t` and access via namespace: `t.NamedTuple`, etc. +- **Use `from __future__ import annotations`** at the top of all Python files + +### Docstrings + +Follow NumPy docstring style for all functions and methods: + +```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 +""" +``` + +### Doctest 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 + +### Git Commit Standards + +Format commit messages as: +``` +Component/File(commit-type[Subcomponent/method]): Concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic +``` + +Common commit types: +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting +- **py(deps)**: Dependencies +- **py(deps[dev])**: Dev Dependencies +- **ai(rules[LLM type])**: AI Rule Updates + +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 +``` + +## Debugging Tips + +When stuck in debugging loops: + +1. **Pause and acknowledge the loop** +2. **Minimize to MVP**: Remove all debugging cruft and experimental code +3. **Document the issue** comprehensively for a fresh approach +4. **Format for portability** (using quadruple backticks) + +## tmux-Specific Considerations + +### tmux Command Execution + +- All tmux commands go through the `cmd()` method on Server/Session/Window/Pane objects +- Commands return a `CommandResult` object with `stdout` and `stderr` +- Use tmux format strings to query object state (see `formats.py`) + +### Format Strings + +libtmux uses tmux's format system extensively: +- Defined in `src/libtmux/formats.py` +- Used to query session_id, window_id, pane_id, etc. +- Format: `#{format_name}` (e.g., `#{session_id}`, `#{window_name}`) + +### Object Refresh + +- Objects can become stale if tmux state changes externally +- Use refresh methods (e.g., `session.refresh()`) to update object state +- Alternative: use `neo.py` query interface for fresh data + +## References + +- Documentation: https://libtmux.git-pull.com/ +- API Reference: https://libtmux.git-pull.com/api.html +- Architecture: https://libtmux.git-pull.com/about.html +- tmux man page: http://man.openbsd.org/OpenBSD-current/man1/tmux.1 +- tmuxp (workspace manager): https://tmuxp.git-pull.com/ diff --git a/CHANGES b/CHANGES index 5eaaab24b..1c9b1571f 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,7 @@ # Changelog -To install the unreleased libtmux version, see [developmental releases](https://libtmux.git-pull.com/quickstart.html#developmental-releases). +For instructions on installing the development version of libtmux, refer to +[development releases](https://libtmux.git-pull.com/quickstart.html#developmental-releases). [pip](https://pip.pypa.io/en/stable/): @@ -8,11 +9,1043 @@ To install the unreleased libtmux version, see [developmental releases](https:// $ pip install --user --upgrade --pre libtmux ``` -## libtmux 0.26.x (unreleased) +[pipx](https://pypa.github.io/pipx/docs/): -- _Notes on upcoming releases will be added here_ +```console +$ pipx install --suffix=@next 'libtmux' --pip-args '\--pre' --force +// Usage: libtmux@next [command] +``` + +[uv](https://docs.astral.sh/uv/): + +```console +$ uv add libtmux --prerelease allow +``` + +[uvx](https://docs.astral.sh/uv/guides/tools/): + +```console +$ uvx --from 'libtmux' --prerelease allow python +``` + +## libtmux 0.54.x (Yet to be released) + + + +## libtmux 0.53.0 (2025-12-14) + +### Breaking changes + +#### Session.attach() no longer calls refresh() (#616) + +{meth}`~libtmux.Session.attach` previously called {meth}`~libtmux.neo.Obj.refresh` +after the `attach-session` command returned. This was semantically incorrect since +`attach-session` is a blocking interactive command where session state can change +arbitrarily during attachment. + +This was never strictly defined behavior as libtmux abstracts tmux internals away. +Code that relied on the session object being refreshed after `attach()` should +explicitly call `session.refresh()` if needed. + +### Bug fixes + +#### Session.attach() no longer fails if session killed during attachment (#616) + +Fixed an issue where {meth}`~libtmux.Session.attach` would raise +{exc}`~libtmux.exc.TmuxObjectDoesNotExist` when a user killed the session while +attached (e.g., closing all windows) and then detached. + +## libtmux 0.52.1 (2025-12-07) + +### CI + +#### Migrate to PyPI Trusted Publisher (#615) + +PyPI publishing now uses OIDC-based Trusted Publisher instead of API tokens. +This improves security and enables package attestations for supply chain verification. + +## libtmux 0.52.0 (2025-12-07) + +#### Pane.capture_pane() enhanced (#614) + +The {meth}`~libtmux.pane.Pane.capture_pane` method now supports 5 new parameters +that expose additional tmux `capture-pane` flags: + +| Parameter | tmux Flag | Description | +|-----------|-----------|-------------| +| `escape_sequences` | `-e` | Include ANSI escape sequences (colors, attributes) | +| `escape_non_printable` | `-C` | Escape non-printable chars as octal `\xxx` | +| `join_wrapped` | `-J` | Join wrapped lines back together | +| `preserve_trailing` | `-N` | Preserve trailing spaces at line ends | +| `trim_trailing` | `-T` | Trim trailing empty positions (tmux 3.4+) | + +**Capturing colored output:** + +```python +# Capture with ANSI escape sequences preserved +pane.send_keys('printf "\\033[31mRED\\033[0m"', enter=True) +output = pane.capture_pane(escape_sequences=True) +# Output contains: '\x1b[31mRED\x1b[0m' +``` + +**Joining wrapped lines:** + +```python +# Long lines that wrap are joined back together +output = pane.capture_pane(join_wrapped=True) +``` + +**Version compatibility:** + +The `trim_trailing` parameter requires tmux 3.4+. If used with an older version, +a warning is issued and the flag is ignored. All other parameters work with +libtmux's minimum supported version (tmux 3.2a). + +## libtmux 0.51.0 (2025-12-06) + +### Breaking changes + +#### APIs deprecated (#611) + +Legacy API methods (deprecated in 0.16-0.33) now raise {exc}`~libtmux.exc.DeprecatedError` (hard error) instead of emitting {class}`DeprecationWarning`. + +See {doc}`migration` for full context and examples. + +| Deprecated API | Replacement | Deprecated | Raises | Note | +|----------------|-------------|------------|--------|------| +| `kill_server()` | {meth}`~libtmux.Server.kill` | 0.30.0 | 0.51.0 | Server | +| `attach_session()`, `kill_session()` | {meth}`~libtmux.Session.attach`, {meth}`~libtmux.Session.kill` | 0.30.0 | 0.51.0 | Session | +| `select_window()`, `kill_window()`, `split_window()` | {meth}`~libtmux.Window.select`, {meth}`~libtmux.Window.kill`, {meth}`~libtmux.Window.split` | 0.30.0 / 0.33.0 | 0.51.0 | Window | +| `resize_pane()`, `select_pane()`, `split_window()` | {meth}`~libtmux.Pane.resize`, {meth}`~libtmux.Pane.select`, {meth}`~libtmux.Pane.split` | 0.28.0 / 0.30.0 / 0.33.0 | 0.51.0 | Pane | +| `attached_window`, `attached_pane` | {attr}`~libtmux.Session.active_window`, {attr}`~libtmux.Session.active_pane` / {attr}`~libtmux.Window.active_pane` | 0.31.0 | 0.51.0 | Session/Window | +| `list_*()`, `_list_*()`, `_update_*()`, `children`, `where()`, `find_where()`, `get_by_id()` | {attr}`~libtmux.Server.sessions` / {attr}`~libtmux.Session.windows` / {attr}`~libtmux.Window.panes` with {meth}`~libtmux.common.QueryList.filter` / {meth}`~libtmux.common.QueryList.get` | 0.16.0 / 0.17.0 | 0.51.0 | Query/filter helpers | +| Dict-style access (`obj["key"]`, `obj.get(...)`) | Attribute access (e.g., {attr}`~libtmux.window.Window.window_name`) | 0.17.0 | 0.51.0 | All tmux objects | + +The following deprecations from 0.50.0 continue to emit {class}`DeprecationWarning` (soft deprecation): + +| Deprecated API | Replacement | Deprecated | Note | +|----------------|-------------|------------|------| +| `set_window_option()`, `show_window_option()`, `show_window_options()` | {meth}`~libtmux.window.Window.set_option`, {meth}`~libtmux.window.Window.show_option`, {meth}`~libtmux.window.Window.show_options` | 0.50.0 | Window | +| `g` parameter on options/hooks methods | `global_` on {meth}`~libtmux.options.OptionsMixin.set_option`, {meth}`~libtmux.options.OptionsMixin.show_option`, {meth}`~libtmux.options.OptionsMixin.show_options` | 0.50.0 | Options & hooks | + +## libtmux 0.50.1 (2025-12-06) + +### Documentation (#612) + +- Normalize docs headings and Sphinx module directives to fix anchor and index generation issues. +- Tweak Sphinx type-hints configuration to avoid RST indentation conflicts and suppress forward-reference warnings. +- Refresh docstrings and cross-references (pane/window APIs, environment helpers, pytest plugin) for clearer return types and stable anchors. +- Fix incorrect return type annotations for `capture_pane()` and `display_message()` methods + (changed from `str | list[str]` to `list[str]` - the methods always return a list). + +## libtmux 0.50.0 (2025-11-30) + +### Overview + +libtmux 0.50 brings a major enhancement to option and hook management. The new +{class}`~options.OptionsMixin` and {class}`~hooks.HooksMixin` classes provide a +unified, typed API for managing tmux options and hooks across all object types. + +**Highlights:** + +- **Unified Options API**: New `show_option()`, `show_options()`, `set_option()`, + and `unset_option()` methods available on Server, Session, Window, and Pane. +- **Hook Management**: Full programmatic control over tmux hooks with support for + indexed hook arrays and bulk operations. +- **SparseArray**: New internal data structure for handling tmux's sparse indexed + arrays (e.g., `command-alias[0]`, `command-alias[99]`). +- **tmux 3.2+ baseline**: Removed support for tmux versions below 3.2a, enabling + cleaner code and full hook/option feature support. + +### What's New + +### Unified Options API (#516) + +All tmux objects now share a consistent options interface through +{class}`~options.OptionsMixin`: + +```python +import libtmux + +server = libtmux.Server() +session = server.sessions[0] +window = session.windows[0] +pane = window.panes[0] + +# Get all options as a structured dict +session.show_options() +# {'activity-action': 'other', 'base-index': 0, ...} + +# Get a single option value +session.show_option('base-index') +# 0 + +# Set an option +window.set_option('automatic-rename', True) + +# Unset an option (revert to default) +window.unset_option('automatic-rename') +``` + +**New methods on Server, Session, Window, and Pane:** + +| Method | Description | +|--------|-------------| +| `show_options()` | Get all options as a structured dict | +| `show_option(name)` | Get a single option value | +| `set_option(name, value)` | Set an option | +| `unset_option(name)` | Unset/remove an option | + +**New parameters for `set_option()`:** + +| Parameter | tmux flag | Description | +|-----------|-----------|-------------| +| `_format` | `-F` | Expand format strings in value | +| `unset` | `-u` | Unset the option | +| `global_` | `-g` | Set as global option | +| `unset_panes` | `-U` | Also unset in child panes | +| `prevent_overwrite` | `-o` | Don't overwrite if exists | +| `suppress_warnings` | `-q` | Suppress warnings | +| `append` | `-a` | Append to existing value | + +### Hook Management (#516) + +New {class}`~hooks.HooksMixin` provides programmatic control over tmux hooks: + +```python +session = server.sessions[0] + +# Set a hook +session.set_hook('session-renamed', 'display-message "Renamed!"') + +# Get hook value +session.show_hook('session-renamed') +# 'display-message "Renamed!"' + +# Get all hooks +session.show_hooks() +# {'session-renamed': 'display-message "Renamed!"'} + +# Remove a hook +session.unset_hook('session-renamed') +``` + +**Indexed hooks and bulk operations:** + +tmux hooks support multiple values via indices (e.g., `session-renamed[0]`, +`session-renamed[1]`). The bulk operations API makes this easy: + +```python +# Set multiple hooks at once +session.set_hooks('session-renamed', { + 0: 'display-message "Hook 0"', + 1: 'display-message "Hook 1"', + 5: 'run-shell "echo hook 5"', +}) +``` + +**Hook methods available on Server, Session, Window, and Pane:** + +| Method | Description | +|--------|-------------| +| `set_hook(hook, value)` | Set a hook | +| `show_hook(hook)` | Get hook value (returns SparseArray for indexed hooks) | +| `show_hooks()` | Get all hooks | +| `unset_hook(hook)` | Remove a hook | +| `run_hook(hook)` | Run a hook immediately | +| `set_hooks(hook, values)` | Set multiple indexed hooks at once | + +### SparseArray for Indexed Options (#516) + +tmux uses sparse indexed arrays for options like `command-alias[0]`, +`command-alias[99]`, `terminal-features[0]`. Python lists can't represent +gaps in indices, so libtmux introduces {class}`~_internal.sparse_array.SparseArray`: + +```python +>>> from libtmux._internal.sparse_array import SparseArray + +>>> arr: SparseArray[str] = SparseArray() +>>> arr.add(0, "first") +>>> arr.add(99, "ninety-ninth") # Gap in indices preserved! +>>> arr[0] +'first' +>>> arr[99] +'ninety-ninth' +>>> list(arr.keys()) +[0, 99] +>>> list(arr.iter_values()) # Values in index order +['first', 'ninety-ninth'] +``` + +### New Constants (#516) + +- {class}`~constants.OptionScope` enum: `Server`, `Session`, `Window`, `Pane` +- `OPTION_SCOPE_FLAG_MAP`: Maps scope to tmux flags (`-s`, `-w`, `-p`) +- `HOOK_SCOPE_FLAG_MAP`: Maps scope to hook flags + +### Breaking changes + +### Deprecated Window methods (#516) + +The following methods are deprecated and will be removed in a future release: + +| Deprecated | Replacement | +|------------|-------------| +| `Window.set_window_option()` | `Window.set_option()` | +| `Window.show_window_option()` | `Window.show_option()` | +| `Window.show_window_options()` | `Window.show_options()` | + +The old methods will emit a {class}`DeprecationWarning` when called: + +```python +window.set_window_option('automatic-rename', 'on') +# DeprecationWarning: Window.set_window_option() is deprecated + +# Use the new method instead: +window.set_option('automatic-rename', True) +``` + +### tmux Version Compatibility + +| Feature | Minimum tmux | +|---------|-------------| +| All options/hooks features | 3.2+ | +| Window/Pane hook scopes (`-w`, `-p`) | 3.2+ | +| `client-active`, `window-resized` hooks | 3.3+ | +| `pane-title-changed` hook | 3.5+ | + +## libtmux 0.49.0 (2025-11-29) + +### Breaking Changes + +### tmux 1.8 to 3.1c support removed (#608) + +Support for tmux versions below 3.2a has been removed. This completes the +deprecation announced in v0.48.0. + +- Minimum tmux version is now 3.2a (`TMUX_MIN_VERSION`) +- Removed `TMUX_SOFT_MIN_VERSION` constant and deprecation warning system +- Removed version guards throughout the codebase +- For users on older tmux, use libtmux v0.48.x + +## libtmux 0.48.0 (2025-11-28) + +### Breaking Changes + +### Minimum tmux version bumped to 3.2+ (606) + +tmux versions below 3.2a are now deprecated. libtmux 0.48.0 will be the last +version to support tmux below 3.2. This is to ensure support for hooks, options, +and newer tmux features. + +A `FutureWarning` will be emitted on first use. Support for these versions will be removed in a future +release. Set `LIBTMUX_SUPPRESS_VERSION_WARNING=1` to suppress the warning. + +### Internal + +- Added `TMUX_SOFT_MIN_VERSION` constant (3.2a) for deprecation threshold (#606) + +### What's new + +### tmux 3.6 support (#607) + +Added tmux 3.6 to test grid and `TMUX_MAX_VERSION` 3.4 -> 3.6. + +## libtmux 0.47.0 (2025-11-01) + +### Breaking changes + +- Drop support for Python 3.9; the new minimum is Python 3.10 (#602). See also: + - [Python 3.9 EOL timeline](https://devguide.python.org/versions/#:~:text=Release%20manager-,3.9,-PEP%20596) + - [PEP 596](https://peps.python.org/pep-0596/) + +### Development + +- Add Python 3.14 to test matrix (#601) + +## libtmux 0.46.2 (2025-05-26) + +### Development + +- Add `StrPath` type support for `start_directory` parameters (#596, #597, #598): + - `Server.new_session`: Accept PathLike objects for session start directory + - `Session.new_window`: Accept PathLike objects for window start directory + - `Pane.split` and `Pane.split_window`: Accept PathLike objects for pane start directory + - `Window.split` and `Window.split_window`: Accept PathLike objects for pane start directory + - Enables `pathlib.Path` objects alongside strings for all start directory parameters + - Includes comprehensive tests for all parameter types (None, empty string, string paths, PathLike objects) + + Thank you @Data5tream for the initial commit in #596! + +## libtmux 0.46.1 (2025-03-16) + +_Maintenance only, no bug fixes or new features_ + +A version branch has been created at v0.46.x, the next release of v0.47.0 may +be a few months in waiting (watchers / snapshots are in development in #587). + +### Documentation + +- Typo fix for `Pane.send_keys` (#593), thank you @subbyte! + +## libtmux 0.46.0 (2025-02-25) + +### Breaking + +### Imports removed from libtmux.test (#580) + +Root-level of imports from `libtmux.test` are no longer possible. + +```python +# Before 0.46.0 +from libtmux.test import namer +``` + +```python +# From 0.46.0 onward +from libtmux.test.named import namer +``` + +Same thing with constants: + +```python +# Before 0.46.0 +from libtmux.test import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +```python +# From 0.46.0 onward +from libtmux.test.constants import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +### Development + +### Test helpers: Increased coverage (#580) + +Several improvements to the test helper modules: + +- Enhanced `EnvironmentVarGuard` in `libtmux.test.environment` to better handle variable cleanup +- Added comprehensive test suites for test constants and environment utilities +- Improved docstrings and examples in `libtmux.test.random` with test coverage annotations +- Fixed potential issues with environment variable handling during tests +- Added proper coverage markers to exclude type checking blocks from coverage reports + +## libtmux 0.45.0 (2025-02-23) + +### Breaking Changes + +### Test helpers: Refactor + +Test helper functionality has been split into focused modules (#578): + +- `libtmux.test` module split into: + - `libtmux.test.constants`: Test-related constants (`TEST_SESSION_PREFIX`, etc.) + - `libtmux.test.environment`: Environment variable mocking + - `libtmux.test.random`: Random string generation utilities + - `libtmux.test.temporary`: Temporary session/window management + +**Breaking**: Import paths have changed. Update imports: + +```python +# Old (0.44.x and earlier) +from libtmux.test import ( + TEST_SESSION_PREFIX, + get_test_session_name, + get_test_window_name, + namer, + temp_session, + temp_window, + EnvironmentVarGuard, +) +``` + +```python +# New (0.45.0+) +from libtmux.test.constants import TEST_SESSION_PREFIX +from libtmux.test.environment import EnvironmentVarGuard +from libtmux.test.random import get_test_session_name, get_test_window_name, namer +from libtmux.test.temporary import temp_session, temp_window +``` + +### Development + +- CI: Check for runtime dependencies (#574) + + Kudos @ppentchev for inspiration on the command + ([comment](https://github.com/tmux-python/libtmux/pull/572#issuecomment-2663642923)). + +## libtmux 0.44.2 (2025-02-17) + +### Bug fix + +- Fix `typing_extensions` issue by wrapping it in `TYPE_CHECKING`, continuation of #564, via #572. + +### Development + +- Improved test organization and coverage in `test_common.py` (#570): + - Consolidated version-related tests into parametrized fixtures using NamedTuples + - Added comprehensive test cases for various version formats (master, next, OpenBSD, dev, rc) + - Improved test readability with clear test IDs and logical grouping + - Consistent use of pytest parametrize convention across test suite +- Fix broken test for `test_window_rename` (#570) + +## libtmux 0.44.1 (2025-02-17) + +### Packaging + +- Types: Only import `typing_extensions` when necessary, via #563, @ppentchev! + +## libtmux 0.44.0 (2025-02-16) + +### New Features + +### Context Managers support (#566) + +Added context manager support for all major object types: + +- `Server`: Automatically kills the server when exiting the context +- `Session`: Automatically kills the session when exiting the context +- `Window`: Automatically kills the window when exiting the context +- `Pane`: Automatically kills the pane when exiting the context + +Example usage: + +```python +with Server() as server: + with server.new_session() as session: + with session.new_window() as window: + with window.split() as pane: + pane.send_keys('echo "Hello"') + # Do work with the pane + # Everything is cleaned up automatically when exiting contexts +``` + +This makes it easier to write clean, safe code that properly cleans up tmux resources. + +## libtmux 0.43.0 (2025-02-15) + +### New Features + +### Server Initialization Callbacks + +Server now accepts 2 new optional params, `socket_name_factory` and `on_init` callbacks (#565): + +- `socket_name_factory`: Callable that generates unique socket names for new servers +- `on_init`: Callback that runs after server initialization +- Useful for creating multiple servers with unique names and tracking server instances +- Socket name factory is tried after socket_name, maintaining backward compatibility + +### New test fixture: `TestServer` + +Add `TestServer` pytest fixture for creating temporary tmux servers (#565): + +- Creates servers with unique socket names that clean up after themselves +- Useful for testing interactions between multiple tmux servers +- Includes comprehensive test coverage and documentation +- Available in doctest namespace + +### Documentation + +- Fix links to the "Topics" section +- More docs for "Traversal" Topic (#567) + +## libtmux 0.42.1 (2024-02-15) + +### Bug fixes + +- tests: Import `Self` in a `TYPE_CHECKING` guard to prevent dependency issues. + Via #562, Thank you @ppentchev! + +### Development + +- dev dependencies: Include `typing-extensions` for Python version < 3.11 via + the `testing` and `lint` groups, via #564. + +## libtmux 0.42.0 (2025-02-02) + +### Bug fixes + +- `tmux_cmd`: Migrate to to `text=True` + + This deprecates usage of `console_to_str()` and `str_from_console()`. + + Resolves #558 via #560. + +- compat: Remove `console_to_str()` and `str_from_console()` + + These are both deprecated artifacts of libtmux' Python 2.x compatiblity layer. + +## libtmux 0.41.0 (2025-02-02) + +### Fixes + +- {meth}`Server.__repr__()`: Use {meth}`os.geteuid()` for default `socket_path`. Thank you @lazysegtree! + (#557, resolves #556) + +### Documentation + +- `Server`: Fix `colors` docstring to note it accepts `88` or `256`, Thank you + @TravisDart! (via #544) + +### Development + +### chore: Implement PEP 563 deferred annotation resolution (#555) + +- Add `from __future__ import annotations` to defer annotation resolution and reduce unnecessary runtime computations during type checking. +- Enable Ruff checks for PEP-compliant annotations: + - [non-pep585-annotation (UP006)](https://docs.astral.sh/ruff/rules/non-pep585-annotation/) + - [non-pep604-annotation (UP007)](https://docs.astral.sh/ruff/rules/non-pep604-annotation/) + +For more details on PEP 563, see: https://peps.python.org/pep-0563/ + +## libtmux 0.40.1 (2024-12-24) + +### Bug fix + +- `Server.new_session`: Fix handling of environmental variables passed to new + sessions. Thank you @ppentchev! (#553) + +## libtmux 0.40.0 (2024-12-20) + +_Maintenance only, no bug fixes or new features_ + +### Breaking + +- `_global` renamed to `global_` + +### Development + +- Aggressive automated lint fixes via `ruff` (#550) + + via ruff v0.8.4, all automated lint fixes, including unsafe and previews were applied for Python 3.9: + + ```sh + ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . + ``` + +- Tests: Stability fixes for legacy `test_select_pane` test (#552) + +## libtmux 0.39.0 (2024-11-26) + +_Maintenance only, no bug fixes or new features_ + +### Breaking changes + +- Drop Python 3.8. end of life was October 7th, 2024 (#548) + + tmuxp 1.48.0 was the last release for Python 3.8. + + The minimum python for tmuxp as of 1.49.0 is Python 3.9 + +## libtmux 0.38.1 (2024-11-26) + +- Keep minimum Python version at 3.8 for now. + +## libtmux 0.38.0 (2024-11-26) + +### Breaking changes + +### Project and package management: poetry to uv (#547) + +[uv] is the new package and project manager for the project, replacing Poetry. + +[uv]: https://github.com/astral-sh/uv + +### Build system: poetry to hatchling (#547) + +[Build system] moved from [poetry] to [hatchling]. + +[Build system]: https://packaging.python.org/en/latest/tutorials/packaging-projects/#choosing-a-build-backend +[poetry]: https://github.com/python-poetry/poetry +[hatchling]: https://hatch.pypa.io/latest/ + +### Development + +- Code quality: Use f-strings in more places (#540) + + via [ruff 0.4.2](https://github.com/astral-sh/ruff/blob/v0.4.2/CHANGELOG.md). + +### Documentation + +- Fix docstrings in `query_list` for `MultipleObjectsReturned` and + `ObjectDoesNotExist`. + +## libtmux 0.37.0 (04-21-2024) + +_Maintenance only, no bug fixes or new features_ + +### Testing + +- Add `pytest-xdist` ([PyPI](https://pypi.org/project/pytest-xdist/), [GitHub](https://github.com/pytest-dev/pytest-xdist)) for parallel testing (#522). + + pytest: + + ```console + py.test -n auto + ``` + + pytest-watcher: + + ```console + env PYTEST_ADDOPTS='-n auto' make start + ``` + + entr(1): + + ```console + make watch_test test="-n auto" + ``` + +- Improve flakey tests: + + - `retry_until()` tests: Relax clock in `assert` (#522). + - `tests/test_pane.py::test_capture_pane_start`: Use `retry_until()` to poll, + improve correctness of test (#522). + +### Documentation + +- Automatically linkify links that were previously only text. + +### Development + +- poetry: 1.8.1 -> 1.8.2 + + See also: https://github.com/python-poetry/poetry/blob/1.8.2/CHANGELOG.md + +## libtmux 0.36.0 (2024-03-24) + +_Maintenance only, no bug fixes or new features_ + +### Development + +- Aggressive automated lint fixes via `ruff` (#539) + + via ruff v0.3.4, all automated lint fixes, including unsafe and previews were applied: + + ```sh + ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . + ``` + + Branches were treated with: + + ```sh + git rebase \ + --strategy-option=theirs \ + --exec 'poetry run ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; poetry run ruff format .; git add src tests; git commit --amend --no-edit' \ + origin/master + ``` + +## libtmux 0.35.1 (2024-03-23) + +### Bug fix + +- {attr}`Server.attached_sessions` fix for when multiple clients attached, thank you @patrislav1 (#537) + + - #538 fix to `QueryList`. + +## libtmux 0.35.0 (2024-03-17) + +### Breaking changes + +- Eliminate redundant targets / `window_index`'s across codebase (#536). + +## libtmux 0.34.0 (2024-03-17) + +### Breaking changes + +### Command target change (#535) + +Commands: All `cmd()` methods using custom or overridden targets must use the keyword argument +`target`. This avoids entanglement with inner shell values that include `-t` for +other purposes. These methods include: + +- {meth}`Server.cmd()` +- {meth}`Session.cmd()` +- {meth}`Window.cmd()` +- {meth}`Pane.cmd()` + +## libtmux 0.33.0 (2024-03-17) + +### Breaking changes + +### Improved new sessions (#532) + +- `Session.new_window()`: + + - Learned `direction`, via {class}`~libtmux.constants.WindowDirection`). + - [PEP 3102] keyword-only arguments after window name (#534). + +- Added {meth}`Window.new_window()` shorthand to create window based on that + window's position. + +[PEP 3102]: https://www.python.org/dev/peps/pep-3102/ + +### Improved window splitting (#532) + +- `Window.split_window()` to {meth}`Window.split()` + + - Deprecate `Window.split_window()` + +- `Pane.split_window()` to {meth}`Pane.split()` + + - Deprecate `Pane.split_window()` + - Learned `direction`, via {class}`~libtmux.constants.PaneDirection`). + + - Deprecate `vertical` and `horizontal` in favor of `direction`. + + - Learned `zoom` + +### Tweak: Pane position (#532) + +It's now possible to retrieve the position of a pane in a window via a +`bool` helper:: + +- {attr}`Pane.at_left` +- {attr}`Pane.at_right` +- {attr}`Pane.at_bottom` +- {attr}`Pane.at_right` + +### Development + +- poetry: 1.7.1 -> 1.8.1 + + See also: https://github.com/python-poetry/poetry/blob/1.8.1/CHANGELOG.md + +## libtmux 0.32.0 (2024-03-01) + +_Maintenance only, no bug fixes or new features_ + +### Packaging + +- Add implicit imports to `__init__.py` (#531), thank you @ssbarnea. + +### Development + +- ruff 0.2.2 -> 0.3.0 + +## libtmux 0.31.0 (2024-02-17) + +### Cleanups (#527) + +- Streamline `{Server,Session,Window,Pane}.cmd()`, across all usages to: + - Use `cmd: str` as first positional + - Removed unused keyword arguments `**kwargs` + +### Renamings (#527) + +- `Session.attached_window` renamed to {meth}`Session.active_window` + + - `Session.attached_window` deprecated + +- `Session.attached_pane` renamed to {meth}`Session.active_pane` + + - `Session.attached_pane` deprecated + +- `Window.attached_pane` renamed to {meth}`Window.active_pane` + + - `Window.attached_pane` deprecated + +### Improvements (#527) + +- `Server.attached_windows` now uses `QueryList`'s `.filter()` + +### Documentation (#527) + +- Document `.cmd` in README and quickstart +- Add doctests and improve docstrings to `cmd()` methods across: + - {meth}`Server.cmd()` + - {meth}`Session.cmd()` + - {meth}`Window.cmd()` + - {meth}`Pane.cmd()` + +### Post-release: v0.31.0post0 (2024-02-17) + +- Documentation updates + +## libtmux 0.30.2 (2024-02-16) + +### Development + +- Updated `TMUX_MAX_VERSION` from 3.3 to 3.4 + +## libtmux 0.30.1 (2024-02-16) + +### Fixes + +- Adjusted pytest plugin and test module: Updated to use renamed methods from + version 0.30.0. + +## libtmux 0.30.0 (2024-02-16) + +### Additions + +- Introduced {meth}`Pane.kill()` method + +### Modifications + +- `Window.select_window()` renamed to {meth}`Window.select()` + - Deprecated `Window.select_window()` +- `Pane.select_pane()` renamed to {meth}`Pane.select()` + - Deprecated `Pane.pane_select()` +- `Session.attach_session()` renamed to {meth}`Session.attach()` + - Deprecated `Session.attach_session()` +- `Server.kill_server()` renamed to {meth}`Server.kill()` + - Deprecated `Server.kill_server()` +- `Session.kill_session()` renamed to {meth}`Session.kill()` + - Deprecated `Session.kill_session()` +- `Window.kill_window()` renamed to {meth}`Window.kill()` + - Deprecated `Window.kill_window()` + +### Enhancements + +- {meth}`Server.new_session()`: Support environment variables +- {meth}`Window.split_window()`: Support `size` via `-l` + + Supports columns/rows (`size=10`) and percentage (`size='10%'`) + +## libtmux 0.29.0 (2024-02-16) + +### Fixes + +- Use {exc}`DeprecationWarning` for APIs set to be deprecated (#526) + +### Testing + +- pytest: Ignore {exc}`DeprecationWarning` by default (#526) + +## libtmux 0.28.1 (2024-02-15) + +_Maintenance only, no bug fixes or new features_ + +### Testing + +- CI: Bump actions to node 20+ versions + +### Documentation + +- Refine docs and add migration for v0.28.0 + +## libtmux 0.28.0 (2024-02-14) + +### Breaking changes - +### Detached / unselected by default (#523) + +To ensure consistency and principle of least surprise, keep these set to +not use `-a` unless explicitly specified. + +Breaking: {meth}`Session.new_window()` + {meth}`Window.split_window()` no longer attaches by default. + +- 0.28.0 and greater: Defaults to `attach=False`. +- 0.27.1 and below: Defaults to `attach=True`. + +To keep the old behavior in 0.28.0 and beyond, pass `attach=True` explicitly. + +### Improved resizing (#523) + +- Breaking: `Pane.resize_pane()` renamed to {meth}`Pane.resize()` (#523) + + This convention will be more consistent with {meth}`Window.resize()`. + +- Breaking: {meth}`Pane.resize()`'s params changed (#523) + + - No longer accepts `-U`, `-D`, `-L`, `-R` directly, instead accepts + {class}`~libtmux.constants.ResizeAdjustmentDirection`). + +- {meth}`Pane.resize()`: + + - Accept adjustments via `adjustment_direction` w/ + {class}`~libtmux.constants.ResizeAdjustmentDirection` + `adjustment`. + + - Learned to accept manual `height` and / or `width` (columns/rows or percentage) + + - Zoom (and unzoom) + +- {meth}`Window.resize()`: Newly added + +Tip: If {meth}`Pane.resize()` was not taking affect <= 0.27.1, try to resize with +{meth}`Window.resize()` first. + +### Fixes + +- {meth}`Window.refresh()` and {meth}`Pane.refresh()`: Refresh more underlying state (#523) +- {meth}`Obj._refresh`: Allow passing args (#523) + + e.g. `-a` (all) to `list-panes` and `list-windows` + +- `Server.panes`: Fix listing of panes (#523) + + Would list only panes in attached session, rather than all in a server. + +### Improvement + +- Pane, Window: Improve parsing of option values that return numbers + (#520) +- `Obj._refresh`: Allow passing `list_extra_args` to ensure `list-windows` and + `list-panes` can return more than the target (#523) + +### Tests + +- pytest: Fix `usefixture` warning (#519) +- ci: Add tmux 3.4 to test matrix (#909) + +## libtmux 0.27.1 (2024-02-07) + +### Packaging + +- Include `MIGRATION` in source distribution tarball (#517, for #508) + +## libtmux 0.27.0 (2024-02-07) + +### Improvement + +- QueryList typings (#515) + + - This improves the annotations in descendant objects such as: + + - `Server.sessions` + - `Session.windows` + - `Window.panes` + + - Bolster tests (ported from `libvcs`): doctests and pytests + +## libtmux 0.26.0 (2024-02-06) + +### Breaking changes + +- `get_by_id()` (already deprecated) keyword argument renamed from `id` to + `Server.get_by_id(session_id)`, `Session.get_by_id(window_id)`, and `Window.get_by_id(pane_id)` (#514) + +### Documentation + +- Various docstring fixes and tweaks (#514) + +### Development + +- Strengthen linting (#514) + + - Add flake8-commas (COM) + + - https://docs.astral.sh/ruff/rules/#flake8-commas-com + - https://pypi.org/project/flake8-commas/ + + - Add flake8-builtins (A) + + - https://docs.astral.sh/ruff/rules/#flake8-builtins-a + - https://pypi.org/project/flake8-builtins/ + + - Add flake8-errmsg (EM) + + - https://docs.astral.sh/ruff/rules/#flake8-errmsg-em + - https://pypi.org/project/flake8-errmsg/ ### CI @@ -309,7 +1342,7 @@ _Maintenance only, no bug fixes or new features_ ### New features -#### Detect if server active (#448) +### Detect if server active (#448) - `Server.is_alive()` - `Server.raise_if_dead()` @@ -413,7 +1446,7 @@ _Maintenance only, no bug fixes or new features_ ## libtmux 0.15.4 (2022-09-21) -### Bug fixes +### Fixes - Use stable `pytest` API imports where possible to fix issues in downstream packaging on Arch (#441, via #442) @@ -652,10 +1685,8 @@ _Maintenance only, no bug fixes or new features_ - Python 3.7 and 3.8 returns in 0.12.0 - ~~Final python 3.7 and 3.8 release~~ - - ~~Bug fixes and security updates will go to - [`v0.11.x`](https://github.com/tmux-python/libtmux/tree/v0.11.x)~~ + *Note: This was not the final Python 3.7 and 3.8 release as originally stated. + Python 3.7 and 3.8 support was extended in 0.12.0.* - Internal: Use new separator to split `tmux(1)` formatting information (#289, #343) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/MIGRATION b/MIGRATION index 81bb64110..123b0cdcf 100644 --- a/MIGRATION +++ b/MIGRATION @@ -19,11 +19,336 @@ well. [tracker]: https://github.com/tmux-python/libtmux/discussions ``` -## Next release +## Complete Deprecation Reference -_Migration instructions for the upcoming release will be added here_ +This table provides a quick reference for all deprecated APIs. See version-specific +sections below for detailed migration examples and code samples. - +### Method Renamings + +| Class | Deprecated | Replacement | Since | Raises | +|-------|------------|-------------|-------|--------| +| Server | `kill_server()` | `kill()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Session | `attach_session()` | `attach()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Session | `kill_session()` | `kill()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Window | `select_window()` | `select()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Window | `kill_window()` | `kill()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Window | `split_window()` | `split()` | 0.33.0 (2024-03-17) | 0.51.0 | +| Window | `set_window_option()` | `set_option()` | 0.50.0 (2025-11-30) | _(warning)_ | +| Window | `show_window_option()` | `show_option()` | 0.50.0 (2025-11-30) | _(warning)_ | +| Window | `show_window_options()` | `show_options()` | 0.50.0 (2025-11-30) | _(warning)_ | +| Pane | `select_pane()` | `select()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Pane | `resize_pane()` | `resize()` | 0.28.0 (2024-02-14) | 0.51.0 | +| Pane | `split_window()` | `split()` | 0.33.0 (2024-03-17) | 0.51.0 | + +### Property Renamings + +| Class | Deprecated | Replacement | Since | Raises | +|-------|------------|-------------|-------|--------| +| Session | `attached_window` | `active_window` | 0.31.0 (2024-02-17) | 0.51.0 | +| Session | `attached_pane` | `active_pane` | 0.31.0 (2024-02-17) | 0.51.0 | +| Window | `attached_pane` | `active_pane` | 0.31.0 (2024-02-17) | 0.51.0 | + +### Parameter Changes + +| Method(s) | Deprecated | Replacement | Since | Raises | +|-----------|------------|-------------|-------|--------| +| Options/hooks methods | `g` | `global_` | 0.50.0 (2025-11-30) | _(warning)_ | +| `split_window()` / `split()` | `percent` | `size` | 0.28.0 (2024-02-14) | 0.51.0 | +| `split_window()` / `split()` | `vertical`/`horizontal` | `direction` (PaneDirection) | 0.33.0 (2024-03-17) | 0.51.0 | +| `resize_pane()` | `-U`, `-D`, `-L`, `-R` | `adjustment_direction` | 0.28.0 (2024-02-14) | 0.51.0 | +| `Server.get_by_id()` | `id` | `session_id` | 0.16.0 (2022-12-10) | 0.51.0 | +| `Session.get_by_id()` | `id` | `window_id` | 0.16.0 (2022-12-10) | 0.51.0 | +| `Window.get_by_id()` | `id` | `pane_id` | 0.16.0 (2022-12-10) | 0.51.0 | + +### Query/Filter API Changes + +| Class | Deprecated | Replacement | Since | Raises | +|-------|------------|-------------|-------|--------| +| Server | `list_sessions()` / `_list_sessions()` | `sessions` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `where({...})` | `sessions.filter(**kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `find_where({...})` | `sessions.get(default=None, **kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `_list_panes()` / `_update_panes()` | `panes` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `_list_windows()` / `_update_windows()` | `windows` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `get_by_id(id)` | `sessions.get(session_id=..., default=None)` | 0.16.0 (2022-12-10) | 0.51.0 | +| Session | `list_windows()` / `_list_windows()` | `windows` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Session | `where({...})` | `windows.filter(**kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Session | `find_where({...})` | `windows.get(default=None, **kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Session | `get_by_id(id)` | `windows.get(window_id=..., default=None)` | 0.16.0 (2022-12-10) | 0.51.0 | +| Window | `list_panes()` / `_list_panes()` | `panes` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Window | `where({...})` | `panes.filter(**kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Window | `find_where({...})` | `panes.get(default=None, **kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Window | `get_by_id(id)` | `panes.get(pane_id=..., default=None)` | 0.16.0 (2022-12-10) | 0.51.0 | +| All | `children` property | `sessions`/`windows`/`panes` | 0.17.0 (2022-12-26) | 0.51.0 | + +### Attribute Access Changes + +| Pattern | Deprecated | Replacement | Since | Raises | +|---------|------------|-------------|-------|--------| +| Dict key access | `obj['key']` | `obj.key` | 0.17.0 (2022-12-26) | 0.51.0 | +| Dict get | `obj.get('key')` | `obj.key` | 0.17.0 (2022-12-26) | 0.51.0 | +| Dict get w/ default | `obj.get('key', None)` | `getattr(obj, 'key', None)` | 0.17.0 (2022-12-26) | 0.51.0 | + +### Removed Items + +| Item | Removed In | Migration | +|------|------------|-----------| +| tmux < 3.2a support | 0.49.0 (2025-11-29) | Upgrade tmux or use libtmux 0.48.x | +| `console_to_str()` | 0.42.0 (2025-02-02) | Use `text=True` in subprocess | +| `str_from_console()` | 0.42.0 (2025-02-02) | Use `text=True` in subprocess | +| `common.which()` | 0.12.0 (2022-07-13) | Use `shutil.which()` | + +### Default Behavior Changes + +| Method | Old Default | New Default | Since | +|--------|-------------|-------------|-------| +| `Session.new_window()` | `attach=True` | `attach=False` | 0.28.0 (2024-02-14) | +| `Window.split_window()` | `attach=True` | `attach=False` | 0.28.0 (2024-02-14) | + +--- + +## Upcoming Release + +_Detailed migration steps for the next version will be posted here._ + + + +## libtmux 0.50.0: Unified Options and Hooks API (#516) + +### New unified options API + +All tmux objects (Server, Session, Window, Pane) now share a consistent options +interface through {class}`~libtmux.options.OptionsMixin`: + +```python +# Get all options +session.show_options() + +# Get a single option +session.show_option('base-index') + +# Set an option +window.set_option('automatic-rename', True) + +# Unset an option +window.unset_option('automatic-rename') +``` + +### New hooks API + +All tmux objects now support hook management through +{class}`~libtmux.hooks.HooksMixin`: + +```python +# Set a hook +session.set_hook('session-renamed', 'display-message "Renamed!"') + +# Get hook value +session.show_hook('session-renamed') + +# Get all hooks +session.show_hooks() + +# Remove a hook +session.unset_hook('session-renamed') +``` + +### Deprecated Window methods + +The following `Window` methods are deprecated and will be removed in a future +release: + +| Deprecated | Replacement | +|------------|-------------| +| `Window.set_window_option()` | {meth}`Window.set_option() ` | +| `Window.show_window_option()` | {meth}`Window.show_option() ` | +| `Window.show_window_options()` | {meth}`Window.show_options() ` | + +**Before (deprecated):** + +```python +window.set_window_option('automatic-rename', 'on') +window.show_window_option('automatic-rename') +window.show_window_options() +``` + +**After (0.50.0+):** + +```python +window.set_option('automatic-rename', True) +window.show_option('automatic-rename') +window.show_options() +``` + +### Deprecated `g` parameter + +The `g` parameter for global options is deprecated in favor of `global_`: + +**Before (deprecated):** + +```python +session.show_option('status', g=True) +session.set_option('status', 'off', g=True) +``` + +**After (0.50.0+):** + +```python +session.show_option('status', global_=True) +session.set_option('status', 'off', global_=True) +``` + +Using the old `g` parameter will emit a {class}`DeprecationWarning`. + +## libtmux 0.46.0 (2025-02-25) + +### Imports removed from libtmux.test (#580) + +Root-level of imports from `libtmux.test` are no longer possible. + +```python +# Before 0.46.0 +from libtmux.test import namer +``` + +```python +# From 0.46.0 onward +from libtmux.test.named import namer +``` + +Same thing with constants: + +```python +# Before 0.46.0 +from libtmux.test import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +```python +# From 0.46.0 onward +from libtmux.test.constants import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +## libtmux 0.45.0 (2025-02-23) + +### Test helpers: Module moves + +Test helper functionality has been split into focused modules (#578): + +- `libtmux.test` module split into: + - `libtmux.test.constants`: Test-related constants (`TEST_SESSION_PREFIX`, etc.) + - `libtmux.test.environment`: Environment variable mocking + - `libtmux.test.random`: Random string generation utilities + - `libtmux.test.temporary`: Temporary session/window management + +**Breaking**: Import paths have changed. Update imports: + +```python +# Old (0.44.x and earlier) +from libtmux.test import ( + TEST_SESSION_PREFIX, + get_test_session_name, + get_test_window_name, + namer, + temp_session, + temp_window, + EnvironmentVarGuard, +) +``` + +```python +# New (0.45.0+) +from libtmux.test.constants import TEST_SESSION_PREFIX +from libtmux.test.environment import EnvironmentVarGuard +from libtmux.test.random import get_test_session_name, get_test_window_name, namer +from libtmux.test.temporary import temp_session, temp_window +``` + +## 0.35.0: Commands require explicit targets (2024-03-17) + +### Commands require explicit targets (#535) + +- {meth}`Server.cmd()`, {meth}`Session.cmd()`, {meth}`Window.cmd()`, {meth}`Pane.cmd()` require passing `target` instead of `['-t', target]`, `['-tTargetName']`, etc. This change is to avoid issues mistakenly interpreting `-t` in other shell values as targets. + + Before: + + ```python + session.cmd('send-keys', 'echo hello', '-t', '0') + ``` + + With 0.35.0 and after: + + ```python + session.cmd('send-keys', 'echo hello', target='0') + ``` + +## 0.33.0: Deprecations for splitting (2024-03-03) + +### Deprecations (#532) + +- `Window.split_window()` to {meth}`Window.split()` +- `Pane.split_window()` to {meth}`Pane.split()` + +## 0.31.0: Renaming and command cleanup (2024-02-17) + +### Cleanups (#527) + +- Commands: Param change + + {meth}`Server.cmd()`, {meth}`Session.cmd()`, {meth}`Window.cmd()`, {meth}`Pane.cmd()` + + - Use `cmd: str` as first positional + - Removed unused keyword arguments `**kwargs` + +### Renamings (#527) + +- `Session.attached_window` renamed to {meth}`Session.active_window` + - `Session.attached_window` deprecated +- `Session.attached_pane` renamed to {meth}`Session.active_pane` + - `Session.attached_pane` deprecated +- `Window.attached_pane` renamed to {meth}`Window.active_pane` + - `Window.attached_pane` deprecated + +## 0.28.0: Resizing and detached by default (2024-02-15) + +### Detach by default + +- {meth}`Session.new_window()` + {meth}`Window.split_window()` no longer attaches by default (#523) + + - 0.28.0 and greater: Defaults to `attach=False`. + - 0.27.1 and below: Defaults to `attach=True`. + + For the old behavior in 0.28.0 and beyond, pass `attach=True` explicitly. + +### Resizing panes + +- `Pane.resize_pane()` renamed to {meth}`Pane.resize()` (via #523) + + This convention will be more consistent with {meth}`Window.resize()`. + +- {meth}`Pane.resize_pane()`'s params changed (#523) + + - No longer accepts `-U`, `-D`, `-L`, `-R` directly, instead accepts + {class}`~libtmux.constants.ResizeAdjustmentDirection` (see below). + + - 0.27.1 and below: `pane.resize_pane("-D", 20)`, `pane.resize_pane("-R", 20)` + + - 0.28.0 and beyond: + + ```python + from libtmux.constants import ResizeAdjustmentDirection + pane.resize_pane(adjustment_direction=ResizeAdjustmentDirection.Down, adjustment=25) + pane.resize_pane( + adjustment_direction=ResizeAdjustmentDirection.Right, adjustment=25 + ) + ``` ## 0.17.0: Simplified attributes (2022-12-26) @@ -49,9 +374,11 @@ _Migration instructions for the upcoming release will be added here_ - 0.16 and below: `window['id']` 0.17 and after: `window.id` + - 0.16 and below: `window.get('id')` 0.17 and after: `window.id` + - 0.16 and below: `window.get('id', None)` 0.17 and after: `getattr(window, 'id', None)` diff --git a/Makefile b/Makefile index 8b2c6e40c..d786f7e7b 100644 --- a/Makefile +++ b/Makefile @@ -12,10 +12,10 @@ entr_warn: @echo "----------------------------------------------------------" test: - poetry run py.test $(test) + uv run py.test $(test) start: - $(MAKE) test; poetry run ptw . + $(MAKE) test; uv run ptw . watch_test: if command -v entr > /dev/null; then ${ALL_FILES} | entr -c $(MAKE) test; else $(MAKE) test entr_warn; fi @@ -39,16 +39,16 @@ dev_docs: $(MAKE) -j watch_docs serve_docs ruff_format: - poetry run ruff format . + uv run ruff format . ruff: - poetry run ruff . + uv run ruff check . watch_ruff: if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) ruff; else $(MAKE) ruff entr_warn; fi mypy: - poetry run mypy `${PY_FILES}` + uv run mypy `${PY_FILES}` watch_mypy: if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) mypy; else $(MAKE) mypy entr_warn; fi @@ -57,7 +57,7 @@ format_markdown: prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES monkeytype_create: - poetry run monkeytype run `poetry run which py.test` + uv run monkeytype run `uv run which py.test` monkeytype_apply: - poetry run monkeytype list-modules | xargs -n1 -I{} sh -c 'poetry run monkeytype apply {}' + uv run monkeytype list-modules | xargs -n1 -I{} sh -c 'uv run monkeytype apply {}' diff --git a/README.md b/README.md index b45031e5d..1d3dc7770 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,99 @@ -# libtmux +
+

⚙️ libtmux

+

Drive tmux from Python: typed, object-oriented control over servers, sessions, windows, and panes.

+

+ libtmux logo +

+

+ PyPI version + Docs status + Tests status + Coverage + License +

+
-`libtmux` is a [typed](https://docs.python.org/3/library/typing.html) Python library that provides a wrapper for interacting programmatically with tmux, a terminal multiplexer. You can use it to manage tmux servers, -sessions, windows, and panes. Additionally, `libtmux` powers [tmuxp], a tmux workspace manager. +## 🐍 What is libtmux? -[![Python Package](https://img.shields.io/pypi/v/libtmux.svg)](https://pypi.org/project/libtmux/) -[![Docs](https://github.com/tmux-python/libtmux/workflows/docs/badge.svg)](https://libtmux.git-pull.com/) -[![Build Status](https://github.com/tmux-python/libtmux/workflows/tests/badge.svg)](https://github.com/tmux-python/tmux-python/actions?query=workflow%3A%22tests%22) -[![Code Coverage](https://codecov.io/gh/tmux-python/libtmux/branch/master/graph/badge.svg)](https://codecov.io/gh/tmux-python/libtmux) -[![License](https://img.shields.io/github/license/tmux-python/libtmux.svg)](https://github.com/tmux-python/libtmux/blob/master/LICENSE) +libtmux is a typed Python API over [tmux], the terminal multiplexer. Stop shelling out and parsing `tmux ls`. Instead, interact with real Python objects: `Server`, `Session`, `Window`, and `Pane`. The same API powers [tmuxp], so it stays battle-tested in real-world workflows. -libtmux builds upon tmux's -[target](http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS) and -[formats](http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#FORMATS) to -create an object mapping to traverse, inspect and interact with live -tmux sessions. +### ✨ Features -View the [documentation](https://libtmux.git-pull.com/), -[API](https://libtmux.git-pull.com/api.html) information and -[architectural details](https://libtmux.git-pull.com/about.html). +- Typed, object-oriented control of tmux state +- Query and [traverse](https://libtmux.git-pull.com/topics/traversal.html) live sessions, windows, and panes +- Raw escape hatch via `.cmd(...)` on any object +- Works with multiple tmux sockets and servers +- [Context managers](https://libtmux.git-pull.com/topics/context_managers.html) for automatic cleanup +- [pytest plugin](https://libtmux.git-pull.com/pytest-plugin/index.html) for isolated tmux fixtures +- Proven in production via tmuxp and other tooling -# Install +## Requirements & support -```console -$ pip install --user libtmux +- tmux: >= 3.2a +- Python: >= 3.10 (CPython and PyPy) + +Maintenance-only backports (no new fixes): + +- Python 2.x: [`v0.8.x`](https://github.com/tmux-python/libtmux/tree/v0.8.x) +- tmux 1.8-3.1c: [`v0.48.x`](https://github.com/tmux-python/libtmux/tree/v0.48.x) + +## 📦 Installation + +Stable release: + +```bash +pip install libtmux ``` -# Open a tmux session +With pipx: -Session name `foo`, window name `bar` +```bash +pipx install libtmux +``` -```console -$ tmux new-session -s foo -n bar +With uv / uvx: + +```bash +uv add libtmux +uvx --from "libtmux" python ``` -# Pilot your tmux session via python +From the main branch (bleeding edge): -```console -$ python +```bash +pip install 'git+https://github.com/tmux-python/libtmux.git' +``` + +Tip: libtmux is pre-1.0. Pin a range in projects to avoid surprises: + +requirements.txt: + +```ini +libtmux==0.50.* ``` -Use [ptpython], [ipython], etc. for a nice shell with autocompletions: +pyproject.toml: + +```toml +libtmux = "0.50.*" +``` + +## 🚀 Quickstart + +### Open a tmux session + +First, start a tmux session to connect to: ```console -$ pip install --user ptpython +$ tmux new-session -s foo -n bar ``` +### Pilot your tmux session via Python + +Use [ptpython], [ipython], etc. for a nice REPL with autocompletions: + ```console +$ pip install --user ptpython $ ptpython ``` @@ -53,168 +101,223 @@ Connect to a live tmux session: ```python >>> import libtmux ->>> s = libtmux.Server() ->>> s +>>> svr = libtmux.Server() +>>> svr Server(socket_path=/tmp/tmux-.../default) ``` -Tip: You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your -current tmux server / session / window pane. +**Tip:** You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your +current tmux server / session / window / pane. -[tmuxp]: https://tmuxp.git-pull.com/ -[`tmuxp shell`]: https://tmuxp.git-pull.com/cli/shell.html [ptpython]: https://github.com/prompt-toolkit/ptpython [ipython]: https://ipython.org/ +[`tmuxp shell`]: https://tmuxp.git-pull.com/cli/shell.html -List sessions: +### Run any tmux command -```python ->>> server.sessions -[Session($1 ...), Session($0 ...)] -``` - -Filter sessions by attribute: +Every object has a `.cmd()` escape hatch that honors socket name and path: ```python ->>> server.sessions.filter(history_limit='2000') -[Session($1 ...), Session($0 ...)] +>>> server = Server(socket_name='libtmux_doctest') +>>> server.cmd('display-message', 'hello world') + ``` -Direct lookup: +Create a new session: ```python ->>> server.sessions.get(session_id="$1") -Session($1 ...) +>>> server.cmd('new-session', '-d', '-P', '-F#{session_id}').stdout[0] +'$...' ``` -Find session by dict lookup: +### List and filter sessions + +[**Learn more about Filtering**](https://libtmux.git-pull.com/topics/filtering.html) ```python ->>> server.sessions[0].rename_session('foo') -Session($1 foo) ->>> server.sessions.filter(session_name="foo")[0] -Session($1 foo) +>>> server.sessions +[Session($... ...), ...] ``` -Control your session: +Filter by attribute: ```python ->>> session.rename_session('foo') -Session($1 foo) ->>> session.new_window(attach=False, window_name="ha in the bg") -Window(@2 2:ha in the bg, Session($1 foo)) ->>> session.kill_window("ha in") +>>> server.sessions.filter(history_limit='2000') +[Session($... ...), ...] ``` -Create new window in the background (don't switch to it): +Direct lookup: ```python ->>> session.new_window(attach=False, window_name="ha in the bg") -Window(@2 2:ha in the bg, Session($1 ...)) +>>> server.sessions.get(session_id=session.session_id) +Session($... ...) ``` -Close window: +### Control sessions and windows + +[**Learn more about Workspace Setup**](https://libtmux.git-pull.com/topics/workspace_setup.html) ```python ->>> w = session.attached_window ->>> w.kill_window() +>>> session.rename_session('my-session') +Session($... my-session) ``` -Grab remaining tmux window: +Create new window in the background (don't switch to it): ```python ->>> window = session.attached_window ->>> window.split_window(attach=False) -Pane(%2 Window(@1 1:... Session($1 ...))) -``` +>>> bg_window = session.new_window(attach=False, window_name="bg-work") +>>> bg_window +Window(@... ...:bg-work, Session($... ...)) -Rename window: +>>> session.windows.filter(window_name__startswith="bg") +[Window(@... ...:bg-work, Session($... ...))] -```python ->>> window.rename_window('libtmuxower') -Window(@1 1:libtmuxower, Session($1 ...)) +>>> session.windows.get(window_name__startswith="bg") +Window(@... ...:bg-work, Session($... ...)) + +>>> bg_window.kill() ``` -Split window (create a new pane): +### Split windows and send keys + +[**Learn more about Pane Interaction**](https://libtmux.git-pull.com/topics/pane_interaction.html) ```python ->>> pane = window.split_window() ->>> pane = window.split_window(attach=False) ->>> pane.select_pane() -Pane(%3 Window(@1 1:..., Session($1 ...))) ->>> window = session.new_window(attach=False, window_name="test") ->>> window -Window(@2 2:test, Session($1 ...)) ->>> pane = window.split_window(attach=False) +>>> pane = window.split(attach=False) >>> pane -Pane(%5 Window(@2 2:test, Session($1 ...))) +Pane(%... Window(@... ...:..., Session($... ...))) ``` -Type inside the pane (send key strokes): +Type inside the pane (send keystrokes): ```python ->>> pane.send_keys('echo hey send now') - +>>> pane.send_keys('echo hello') >>> pane.send_keys('echo hey', enter=False) >>> pane.enter() -Pane(%1 ...) +Pane(%... ...) ``` -Grab the output of pane: +### Capture pane output ```python ->>> pane.clear() # clear the pane -Pane(%1 ...) ->>> pane.send_keys("cowsay 'hello'", enter=True) ->>> print('\n'.join(pane.cmd('capture-pane', '-p').stdout)) # doctest: +SKIP -$ cowsay 'hello' - _______ -< hello > - ------- - \ ^__^ - \ (oo)\_______ - (__)\ )\/\ - ||----w | - || || -... -``` - -Traverse and navigate: +>>> pane.clear() +Pane(%... ...) +>>> pane.send_keys("echo 'hello world'", enter=True) +>>> pane.cmd('capture-pane', '-p').stdout # doctest: +SKIP +["$ echo 'hello world'", 'hello world', '$'] +``` + +### Traverse the hierarchy + +[**Learn more about Traversal**](https://libtmux.git-pull.com/topics/traversal.html) + +Navigate from pane up to window to session: ```python >>> pane.window -Window(@1 1:..., Session($1 ...)) +Window(@... ...:..., Session($... ...)) >>> pane.window.session -Session($1 ...) +Session($... ...) ``` -# Python support +## Core concepts + +| libtmux object | tmux concept | Notes | +|----------------|-----------------------------|--------------------------------| +| [`Server`](https://libtmux.git-pull.com/api/servers.html) | tmux server / socket | Entry point; owns sessions | +| [`Session`](https://libtmux.git-pull.com/api/sessions.html) | tmux session (`$0`, `$1`,...) | Owns windows | +| [`Window`](https://libtmux.git-pull.com/api/windows.html) | tmux window (`@1`, `@2`,...) | Owns panes | +| [`Pane`](https://libtmux.git-pull.com/api/panes.html) | tmux pane (`%1`, `%2`,...) | Where commands run | -Unsupported / no security releases or bug fixes: +Also available: [`Options`](https://libtmux.git-pull.com/api/options.html) and [`Hooks`](https://libtmux.git-pull.com/api/hooks.html) abstractions for tmux configuration. -- Python 2.x: The backports branch is - [`v0.8.x`](https://github.com/tmux-python/libtmux/tree/v0.8.x). +Collections are live and queryable: -# Donations +```python +server = libtmux.Server() +session = server.sessions.get(session_name="demo") +api_windows = session.windows.filter(window_name__startswith="api") +pane = session.active_window.active_pane +pane.send_keys("echo 'hello from libtmux'", enter=True) +``` + +## tmux vs libtmux vs tmuxp + +| Tool | Layer | Typical use case | +|---------|----------------------------|----------------------------------------------------| +| tmux | CLI / terminal multiplexer | Everyday terminal usage, manual control | +| libtmux | Python API over tmux | Programmatic control, automation, testing | +| tmuxp | App on top of libtmux | Declarative tmux workspaces from YAML / TOML | -Your donations fund development of new features, testing and support. -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. +## Testing & fixtures -See donation options at . +[**Learn more about the pytest plugin**](https://libtmux.git-pull.com/pytest-plugin/index.html) -# Project details +Writing a tool that interacts with tmux? Use our fixtures to keep your tests clean and isolated. + +```python +def test_my_tmux_tool(session): + # session is a real tmux session in an isolated server + window = session.new_window(window_name="test") + pane = window.active_pane + pane.send_keys("echo 'hello from test'", enter=True) + + assert window.window_name == "test" + # Fixtures handle cleanup automatically +``` -- tmux support: 1.8+ -- python support: >= 3.8, pypy, pypy3 -- Source: -- Docs: -- API: -- Changelog: -- Issues: -- Test Coverage: -- pypi: -- Open Hub: -- Repology: -- License: [MIT](http://opensource.org/licenses/MIT). +- Fresh tmux server/session/window/pane fixtures per test +- Temporary HOME and tmux config fixtures keep indices stable +- `TestServer` helper spins up multiple isolated tmux servers + +## When you might not need libtmux + +- Layouts are static and live entirely in tmux config files +- You do not need to introspect or control running tmux from other tools +- Python is unavailable where tmux is running + +## Project links + +**Topics:** +[Traversal](https://libtmux.git-pull.com/topics/traversal.html) · +[Filtering](https://libtmux.git-pull.com/topics/filtering.html) · +[Pane Interaction](https://libtmux.git-pull.com/topics/pane_interaction.html) · +[Workspace Setup](https://libtmux.git-pull.com/topics/workspace_setup.html) · +[Automation Patterns](https://libtmux.git-pull.com/topics/automation_patterns.html) · +[Context Managers](https://libtmux.git-pull.com/topics/context_managers.html) · +[Options & Hooks](https://libtmux.git-pull.com/topics/options_and_hooks.html) + +**Reference:** +[Docs][docs] · +[API][api] · +[pytest plugin](https://libtmux.git-pull.com/pytest-plugin/index.html) · +[Architecture][architecture] · +[Changelog][history] · +[Migration][migration] + +**Project:** +[Issues][issues] · +[Coverage][coverage] · +[Releases][releases] · +[License][license] · +[Support][support] + +**[The Tao of tmux][tao]** — deep-dive book on tmux fundamentals + +## Contributing & support + +Contributions are welcome. Please open an issue or PR if you find a bug or want to improve the API or docs. If libtmux helps you ship, consider sponsoring development via [support]. + +[docs]: https://libtmux.git-pull.com +[api]: https://libtmux.git-pull.com/api.html +[architecture]: https://libtmux.git-pull.com/about.html +[history]: https://libtmux.git-pull.com/history.html +[migration]: https://libtmux.git-pull.com/migration.html +[issues]: https://github.com/tmux-python/libtmux/issues +[coverage]: https://codecov.io/gh/tmux-python/libtmux +[releases]: https://pypi.org/project/libtmux/ +[license]: https://github.com/tmux-python/libtmux/blob/master/LICENSE +[support]: https://tony.sh/support.html +[tao]: https://leanpub.com/the-tao-of-tmux +[tmuxp]: https://tmuxp.git-pull.com +[tmux]: https://github.com/tmux/tmux diff --git a/conftest.py b/conftest.py index 876090be4..ada5aae3f 100644 --- a/conftest.py +++ b/conftest.py @@ -7,17 +7,23 @@ See "pytest_plugins in non-top-level conftest files" in https://docs.pytest.org/en/stable/deprecations.html """ -import pathlib + +from __future__ import annotations + import shutil import typing as t import pytest from _pytest.doctest import DoctestItem +from libtmux.pane import Pane from libtmux.pytest_plugin import USING_ZSH +from libtmux.server import Server +from libtmux.session import Session +from libtmux.window import Window if t.TYPE_CHECKING: - from libtmux.session import Session + import pathlib pytest_plugins = ["pytester"] @@ -25,20 +31,25 @@ @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, - doctest_namespace: t.Dict[str, t.Any], + doctest_namespace: dict[str, t.Any], ) -> None: """Configure doctest fixtures for pytest-doctest.""" if isinstance(request._pyfuncitem, DoctestItem) and shutil.which("tmux"): request.getfixturevalue("set_home") + doctest_namespace["Server"] = Server + doctest_namespace["Session"] = Session + doctest_namespace["Window"] = Window + doctest_namespace["Pane"] = Pane doctest_namespace["server"] = request.getfixturevalue("server") - session: "Session" = request.getfixturevalue("session") + doctest_namespace["Server"] = request.getfixturevalue("TestServer") + session: Session = request.getfixturevalue("session") doctest_namespace["session"] = session - doctest_namespace["window"] = session.attached_window - doctest_namespace["pane"] = session.attached_pane + doctest_namespace["window"] = session.active_window + doctest_namespace["pane"] = session.active_pane doctest_namespace["request"] = request -@pytest.fixture(autouse=True, scope="function") +@pytest.fixture(autouse=True) def set_home( monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path, @@ -47,12 +58,18 @@ def set_home( monkeypatch.setenv("HOME", str(user_path)) +@pytest.fixture(autouse=True) +def setup_fn( + clear_env: None, +) -> None: + """Function-level test configuration fixtures for pytest.""" + + @pytest.fixture(autouse=True, scope="session") -@pytest.mark.usefixtures("clear_env") -def setup( +def setup_session( request: pytest.FixtureRequest, config_file: pathlib.Path, ) -> None: - """Configure test fixtures for pytest.""" + """Session-level test configuration for pytest.""" if USING_ZSH: request.getfixturevalue("zshrc") diff --git a/docs/Makefile b/docs/Makefile index d0f8ec310..8501c103e 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,7 +5,7 @@ WATCH_FILES= find .. -type f -not -path '*/\.*' | grep -i '.*[.]\(rst\|md\)\$\|. # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = poetry run sphinx-build +SPHINXBUILD = uv run sphinx-build PAPER = BUILDDIR = _build @@ -181,8 +181,8 @@ dev: $(MAKE) -j watch serve start: - poetry run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} $(O) + uv run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} $(O) design: # This adds additional watch directories (for _static file changes) and disable incremental builds - poetry run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} --watch "." -a $(O) + uv run sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) --port ${HTTP_PORT} --watch "." -a $(O) diff --git a/docs/_templates/book.html b/docs/_templates/book.html index 861e978dd..16d2febcf 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/sidebar/projects.html b/docs/_templates/sidebar/projects.html index 217e41800..7b46e0bce 100644 --- a/docs/_templates/sidebar/projects.html +++ b/docs/_templates/sidebar/projects.html @@ -1,7 +1,7 @@ ``` -## Table of Contents - -:hidden: - ```{toctree} :maxdepth: 2 +:hidden: quickstart about -topics/traversal -reference/index +topics/index +api/index pytest-plugin/index +test-helpers/index ``` ```{toctree} diff --git a/docs/internals/constants.md b/docs/internals/constants.md new file mode 100644 index 000000000..65059ce94 --- /dev/null +++ b/docs/internals/constants.md @@ -0,0 +1,15 @@ +# Internal Constants - `libtmux._internal.constants` + +:::{warning} +Be careful with these! These constants are private, internal as they're **not** covered by version policies. They can break or be removed between minor versions! + +If you need a data structure here made public or stabilized please [file an issue](https://github.com/tmux-python/libtmux/issues). +::: + +```{eval-rst} +.. automodule:: libtmux._internal.constants + :members: + :undoc-members: + :inherited-members: + :show-inheritance: +``` diff --git a/docs/internals/index.md b/docs/internals/index.md index 09d4a1d6f..0d19d3763 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -11,6 +11,8 @@ If you need an internal API stabilized please [file an issue](https://github.com ```{toctree} dataclasses query_list +constants +sparse_array ``` ## Environmental variables diff --git a/docs/internals/sparse_array.md b/docs/internals/sparse_array.md new file mode 100644 index 000000000..74ea7892d --- /dev/null +++ b/docs/internals/sparse_array.md @@ -0,0 +1,14 @@ +# Internal Sparse Array - `libtmux._internal.sparse_array` + +:::{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/libtmux/issues). +::: + +```{eval-rst} +.. automodule:: libtmux._internal.sparse_array + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/pytest-plugin/index.md b/docs/pytest-plugin/index.md index 2cbb599fc..82d55dd2f 100644 --- a/docs/pytest-plugin/index.md +++ b/docs/pytest-plugin/index.md @@ -7,15 +7,13 @@ independent tmux server. ```{seealso} Using the pytest plugin? -Do you want more flexbility? Correctness? Power? Defaults changed? [Connect with us] on the tracker, we want to know +Do you want more flexibility? Correctness? Power? Defaults changed? [Connect with us] on the tracker, we want to know your case, we won't stabilize APIs until we're sure everything is by the book. [connect with us]: https://github.com/tmux-python/libtmux/discussions -``` - ```{module} libtmux.pytest_plugin - +:no-index: ``` ## Usage @@ -33,7 +31,7 @@ The pytest plugin will be automatically detected via pytest, and the fixtures wi View libtmux's own [tests/](https://github.com/tmux-python/libtmux/tree/master/tests) as well as tmuxp's [tests/](https://github.com/tmux-python/tmuxp/tree/master/tests). -libtmux's tests `autouse` the {ref}`recommended-fixtures` above to ensure stable, assertions and +libtmux's tests `autouse` the {ref}`recommended-fixtures` above to ensure stable test execution, assertions and object lookups in the test grid. ## pytest-tmux @@ -41,14 +39,14 @@ object lookups in the test grid. `pytest-tmux` works through providing {ref}`pytest fixtures ` - so read up on those! -The plugin's fixtures guarantee a fresh, headless `tmux(1)` server, session, window, or pane is +The plugin's fixtures guarantee a fresh, headless {command}`tmux(1)` server, session, window, or pane is passed into your test. (recommended-fixtures)= ## Recommended fixtures -These are fixtures are automatically used when the plugin is enabled and `pytest` is ran. +These fixtures are automatically used when the plugin is enabled and `pytest` is run. - Creating temporary, test directories for: - `/home/` ({func}`home_path`) @@ -59,22 +57,24 @@ These are fixtures are automatically used when the plugin is enabled and `pytest These are set to ensure panes and windows can be reliably referenced and asserted. +(setting_a_tmux_configuration)= + ## Setting a tmux configuration If you would like {func}`session fixture ` to automatically use a configuration, you have a few options: - Pass a `config_file` into {class}`~libtmux.Server` -- Set the `HOME` directory to a local or temporary pytest path with a configurat configuration file +- Set the `HOME` directory to a local or temporary pytest path with a configuration file -You could also read the code and override {func}`server fixtures `'s in your own doctest. doctest. +You could also read the code and override {func}`server fixture ` in your own doctest. (custom_session_params)= ### Custom session parameters -You can override `session_params` to custom the `session` fixture. The -dictionary will directly pass into :meth:`Server.new_session` keyword arguments. +You can override `session_params` to customize the `session` fixture. The +dictionary will directly pass into {meth}`Server.new_session` keyword arguments. ```python import pytest @@ -93,6 +93,34 @@ def test_something(session): The above will assure the libtmux session launches with `-x 800 -y 600`. +(temp_server)= + +### Creating temporary servers + +If you need multiple independent tmux servers in your tests, the {func}`TestServer fixture ` provides a factory that creates servers with unique socket names. Each server is automatically cleaned up when the test completes. + +```python +def test_something(TestServer): + Server = TestServer() # Get unique partial'd Server + server = Server() # Create server instance + + session = server.new_session() + assert server.is_alive() +``` + +You can also use it with custom configurations, similar to the {ref}`server fixture `: + +```python +def test_with_config(TestServer, tmp_path): + config_file = tmp_path / "tmux.conf" + config_file.write_text("set -g status off") + + Server = TestServer() + server = Server(config_file=str(config_file)) +``` + +This is particularly useful when testing interactions between multiple tmux servers or when you need to verify behavior across server restarts. + (set_home)= ### Setting a temporary home directory @@ -119,11 +147,3 @@ def set_home( :show-inheritance: :member-order: bysource ``` - -## Test utilities - -```{toctree} -:maxdepth: 1 - -test -``` diff --git a/docs/pytest-plugin/test.md b/docs/pytest-plugin/test.md deleted file mode 100644 index 8fbff818b..000000000 --- a/docs/pytest-plugin/test.md +++ /dev/null @@ -1,6 +0,0 @@ -# Test helpers - -```{eval-rst} -.. automodule:: libtmux.test - :members: -``` diff --git a/docs/quickstart.md b/docs/quickstart.md index c61e5223e..39b11aa70 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -12,7 +12,7 @@ from inside a live tmux session. ## Requirements -- [tmux] +- [tmux] 3.2a or newer - [pip] - for this handbook's examples [tmux]: https://tmux.github.io/ @@ -41,6 +41,31 @@ the 4th beta release of `1.10.0` before general availability. $ pip install --user --upgrade --pre libtmux ``` +- [pipx]\: + + ```console + $ pipx install --suffix=@next 'libtmux' --pip-args '\--pre' --force + // Usage: libtmux@next [command] + ``` + +- [uv tool install][uv-tools]\: + + ```console + $ uv tool install --prerelease=allow libtmux + ``` + +- [uv]\: + + ```console + $ uv add libtmux --prerelease allow + ``` + +- [uvx]\: + + ```console + $ uvx --from 'libtmux' --prerelease allow python + ``` + via trunk (can break easily): - [pip]\: @@ -49,16 +74,31 @@ via trunk (can break easily): $ pip install --user -e git+https://github.com/tmux-python/libtmux.git#egg=libtmux ``` +- [pipx]\: + + ```console + $ pipx install --suffix=@master 'libtmux @ git+https://github.com/tmux-python/libtmux.git@master' --force + ``` + +- [uv]\: + + ```console + $ uv tool install libtmux --from git+https://github.com/tmux-python/libtmux.git + ``` + [pip]: https://pip.pypa.io/en/stable/ +[pipx]: https://pypa.github.io/pipx/docs/ +[uv]: https://docs.astral.sh/uv/ +[uv-tools]: https://docs.astral.sh/uv/concepts/tools/ +[uvx]: https://docs.astral.sh/uv/guides/tools/ +[ptpython]: https://github.com/prompt-toolkit/ptpython ## Start a tmux session Now, let's open a tmux session. ```console - $ tmux new-session -n bar -s foo - ``` This tutorial will be using the session and window name in the example. @@ -89,7 +129,7 @@ $ ptpython ``` ```{module} libtmux - +:no-index: ``` First, we can grab a {class}`Server`. @@ -120,10 +160,45 @@ equivalent to `$ tmux -L mysocket`. `server` is now a living object bound to the tmux server's Sessions, Windows and Panes. +## Raw, contextual commands + +New session: + +```python +>>> server.cmd('new-session', '-d', '-P', '-F#{session_id}').stdout[0] +'$2' +``` + +```python +>>> session.cmd('new-window', '-P').stdout[0] +'libtmux...:2.0' +``` + +From raw command output, to a rich {class}`Window` object (in practice and as shown +later, you'd use {meth}`Session.new_window()`): + +```python +>>> Window.from_window_id(window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], server=session.server) +Window(@2 2:..., Session($1 libtmux_...)) +``` + +Create a pane from a window: + +```python +>>> window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0] +'%2' +``` + +Raw output directly to a {class}`Pane` (in practice, you'd use {meth}`Window.split()`): + +```python +>>> Pane.from_pane_id(pane_id=window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0], server=window.server) +Pane(%... Window(@1 1:..., Session($1 libtmux_...))) +``` + ## Find your {class}`Session` -If you have multiple tmux sessions open, you can see that all of the -methods in {class}`Server` are available. +If you have multiple tmux sessions open, all methods in {class}`Server` are available. We can list sessions with {meth}`Server.sessions`: @@ -142,7 +217,7 @@ Session($1 ...) However, this isn't guaranteed, libtmux works against current tmux information, the session's name could be changed, or another tmux session may be created, -so {meth}`Server.sessions` and {meth}`Server.windows` exists as a lookup. +so {meth}`Server.sessions` and {meth}`Server.windows` exist as a lookup. ## Get session by ID @@ -260,7 +335,7 @@ Window(@2 2:check this out, Session($1 ...)) And kill: ```python ->>> window.kill_window() +>>> window.kill() ``` Use {meth}`Session.windows` and {meth}`Session.windows.filter()` to list and sort @@ -268,25 +343,25 @@ through active {class}`Window`'s. ## Manipulating windows -Now that we know how to create windows, let's use one. Let's use {meth}`Session.attached_window()` +Now that we know how to create windows, let's use one. Let's use {meth}`Session.active_window()` to grab our current window. ```python ->>> window = session.attached_window +>>> window = session.active_window ``` `window` now has access to all of the objects inside of {class}`Window`. -Let's create a pane, {meth}`Window.split_window`: +Let's create a pane, {meth}`Window.split`: ```python ->>> window.split_window(attach=False) +>>> window.split(attach=False) Pane(%2 Window(@1 ...:..., Session($1 ...))) ``` Powered up. Let's have a break down: -1. `window = session.attached_window()` gave us the {class}`Window` of the current attached to window. +1. `window = session.active_window()` gave us the {class}`Window` of the current attached to window. 2. `attach=False` assures the cursor didn't switch to the newly created pane. 3. Returned the created {class}`Pane`. @@ -306,19 +381,19 @@ You have two ways you can move your cursor to new sessions, windows and panes. For one, arguments such as `attach=False` can be omittted. ```python ->>> pane = window.split_window() +>>> pane = window.split() ``` This gives you the {class}`Pane` along with moving the cursor to a new window. You can also use the `.select_*` available on the object, in this case the pane has -{meth}`Pane.select_pane()`. +{meth}`Pane.select()`. ```python ->>> pane = window.split_window(attach=False) +>>> pane = window.split(attach=False) ``` ```python ->>> pane.select_pane() +>>> pane.select() Pane(%1 Window(@1 ...:..., Session($1 ...))) ``` @@ -336,7 +411,7 @@ As long as you have the object, or are iterating through a list of them, you can ```python >>> window = session.new_window(attach=False, window_name="test") ->>> pane = window.split_window(attach=False) +>>> pane = window.split(attach=False) >>> pane.send_keys('echo hey', enter=False) ``` @@ -366,6 +441,38 @@ automatically sent, the leading space character prevents adding it to the user's shell history. Omitting `enter=false` means the default behavior (sending the command) is done, without needing to use `pane.enter()` after. +## Working with options + +libtmux provides a unified API for managing tmux options across Server, Session, +Window, and Pane objects. + +### Getting options + +```python +>>> server.show_option('buffer-limit') +50 + +>>> window.show_options() # doctest: +ELLIPSIS +{...} +``` + +### Setting options + +```python +>>> window.set_option('automatic-rename', False) # doctest: +ELLIPSIS +Window(@... ...) + +>>> window.show_option('automatic-rename') +False + +>>> window.unset_option('automatic-rename') # doctest: +ELLIPSIS +Window(@... ...) +``` + +:::{seealso} +See {ref}`options-and-hooks` for more details on options and hooks. +::: + ## Final notes These objects created use tmux's internal usage of ID's to make servers, @@ -384,4 +491,3 @@ and our [test suite] (see {ref}`development`.) [workspacebuilder.py]: https://github.com/tmux-python/libtmux/blob/master/libtmux/workspacebuilder.py [test suite]: https://github.com/tmux-python/libtmux/tree/master/tests -[ptpython]: https://github.com/prompt-toolkit/ptpython diff --git a/docs/redirects.txt b/docs/redirects.txt index 9188d36fa..afff787ad 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -4,5 +4,15 @@ "windows.md" "reference/windows.md" "traversal.md" "topics/traversal.md" "sessions.md" "reference/sessions.md" -"api.md" "internals/index.md" +"api.md" "reference/index.md" "pytest-plugin.md" "pytest-plugin/index.md" +"reference/common.md" "api/common.md" +"reference/constants.md" "api/constants.md" +"reference/exceptions.md" "api/exceptions.md" +"reference/index.md" "api/index.md" +"reference/panes.md" "api/panes.md" +"reference/properties.md" "api/properties.md" +"reference/servers.md" "api/servers.md" +"reference/sessions.md" "api/sessions.md" +"reference/windows.md" "api/windows.md" +"pytest-plugin/test.md" "test-helpers/index.md" diff --git a/docs/test-helpers/constants.md b/docs/test-helpers/constants.md new file mode 100644 index 000000000..b7583a251 --- /dev/null +++ b/docs/test-helpers/constants.md @@ -0,0 +1,13 @@ +(test_helpers_constants)= + +# Constants + +Test-related constants used across libtmux test helpers. + +```{eval-rst} +.. automodule:: libtmux.test.constants + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/test-helpers/environment.md b/docs/test-helpers/environment.md new file mode 100644 index 000000000..58b4bb549 --- /dev/null +++ b/docs/test-helpers/environment.md @@ -0,0 +1,13 @@ +(test_helpers_environment)= + +# Environment + +Environment variable mocking utilities for tests. + +```{eval-rst} +.. automodule:: libtmux.test.environment + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/test-helpers/index.md b/docs/test-helpers/index.md new file mode 100644 index 000000000..dd99384bf --- /dev/null +++ b/docs/test-helpers/index.md @@ -0,0 +1,18 @@ +# Test helpers + +Test helpers for libtmux and downstream libraries. + +```{toctree} +:maxdepth: 2 + +constants +environment +random +retry +temporary +``` + +```{eval-rst} +.. automodule:: libtmux.test + :members: +``` diff --git a/docs/test-helpers/random.md b/docs/test-helpers/random.md new file mode 100644 index 000000000..e4248a7fc --- /dev/null +++ b/docs/test-helpers/random.md @@ -0,0 +1,13 @@ +(test_helpers_random)= + +# Random + +Random string generation utilities for test names. + +```{eval-rst} +.. automodule:: libtmux.test.random + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/test-helpers/retry.md b/docs/test-helpers/retry.md new file mode 100644 index 000000000..6ec72e3c4 --- /dev/null +++ b/docs/test-helpers/retry.md @@ -0,0 +1,15 @@ +(test_helpers_retry)= + +# Retry Utilities + +Retry helper functions for libtmux test utilities. These utilities help manage testing operations that may require multiple attempts before succeeding. + +## Basic Retry Functionality + +```{eval-rst} +.. automodule:: libtmux.test.retry + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/test-helpers/temporary.md b/docs/test-helpers/temporary.md new file mode 100644 index 000000000..ea3b8ddf9 --- /dev/null +++ b/docs/test-helpers/temporary.md @@ -0,0 +1,13 @@ +(test_helpers_temporary_objects)= + +# Temporary Objects + +Context managers for temporary tmux objects (sessions, windows). + +```{eval-rst} +.. automodule:: libtmux.test.temporary + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md new file mode 100644 index 000000000..c0d00eb79 --- /dev/null +++ b/docs/topics/automation_patterns.md @@ -0,0 +1,481 @@ +(automation-patterns)= + +# Automation Patterns + +libtmux is ideal for automating terminal workflows, orchestrating multiple processes, +and building agentic systems that interact with terminal applications. This guide covers +practical patterns for automation use cases. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +## Process Control + +### Starting long-running processes + +```python +>>> import time + +>>> proc_window = session.new_window(window_name='process', attach=False) +>>> proc_pane = proc_window.active_pane + +>>> # Start a background process +>>> proc_pane.send_keys('sleep 2 && echo "Process complete"') + +>>> # Process is running +>>> time.sleep(0.1) +>>> proc_window.window_name +'process' + +>>> # Clean up +>>> proc_window.kill() +``` + +### Checking process status + +```python +>>> import time + +>>> status_window = session.new_window(window_name='status-check', attach=False) +>>> status_pane = status_window.active_pane + +>>> def is_process_running(pane, marker='RUNNING'): +... """Check if a marker indicates process is still running.""" +... output = pane.capture_pane() +... return marker in '\\n'.join(output) + +>>> # Start and mark a process +>>> status_pane.send_keys('echo "RUNNING"; sleep 0.3; echo "DONE"') +>>> time.sleep(0.1) + +>>> # Check while running +>>> 'RUNNING' in '\\n'.join(status_pane.capture_pane()) +True + +>>> # Wait for completion +>>> time.sleep(0.5) +>>> 'DONE' in '\\n'.join(status_pane.capture_pane()) +True + +>>> # Clean up +>>> status_window.kill() +``` + +## Output Monitoring + +### Waiting for specific output + +```python +>>> import time + +>>> monitor_window = session.new_window(window_name='monitor', attach=False) +>>> monitor_pane = monitor_window.active_pane + +>>> def wait_for_output(pane, text, timeout=5.0, poll_interval=0.1): +... """Wait for specific text to appear in pane output.""" +... start = time.time() +... while time.time() - start < timeout: +... output = '\\n'.join(pane.capture_pane()) +... if text in output: +... return True +... time.sleep(poll_interval) +... return False + +>>> monitor_pane.send_keys('sleep 0.2; echo "READY"') +>>> wait_for_output(monitor_pane, 'READY', timeout=2.0) +True + +>>> # Clean up +>>> monitor_window.kill() +``` + +### Detecting errors in output + +```python +>>> import time + +>>> error_window = session.new_window(window_name='error-check', attach=False) +>>> error_pane = error_window.active_pane + +>>> def check_for_errors(pane, patterns=None): +... """Check pane output for error patterns.""" +... if patterns is None: +... patterns = ['Error:', 'error:', 'ERROR', 'FAILED', 'Exception'] +... output = '\\n'.join(pane.capture_pane()) +... for pattern in patterns: +... if pattern in output: +... return pattern +... return None + +>>> # Test with successful output +>>> error_pane.send_keys('echo "Success!"') +>>> time.sleep(0.1) +>>> check_for_errors(error_pane) is None +True + +>>> # Clean up +>>> error_window.kill() +``` + +### Capturing output between markers + +```python +>>> import time + +>>> capture_window = session.new_window(window_name='capture', attach=False) +>>> capture_pane = capture_window.active_pane + +>>> def capture_after_marker(pane, marker, timeout=5.0): +... """Capture output after a marker appears.""" +... start_time = time.time() +... while time.time() - start_time < timeout: +... lines = pane.capture_pane() +... output = '\\n'.join(lines) +... if marker in output: +... # Return all lines after the marker +... found = False +... result = [] +... for line in lines: +... if marker in line: +... found = True +... continue +... if found: +... result.append(line) +... return result +... time.sleep(0.1) +... return None + +>>> # Test marker capture +>>> capture_pane.send_keys('echo "MARKER"; echo "captured data"') +>>> time.sleep(0.3) +>>> result = capture_after_marker(capture_pane, 'MARKER', timeout=2.0) +>>> any('captured' in line for line in (result or [])) +True + +>>> # Clean up +>>> capture_window.kill() +``` + +## Multi-Pane Orchestration + +### Running parallel tasks + +```python +>>> import time +>>> from libtmux.constants import PaneDirection + +>>> parallel_window = session.new_window(window_name='parallel', attach=False) +>>> parallel_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> pane1 = parallel_window.active_pane +>>> pane2 = pane1.split(direction=PaneDirection.Right) +>>> pane3 = pane1.split(direction=PaneDirection.Below) + +>>> # Start tasks in parallel +>>> tasks = [ +... (pane1, 'echo "Task 1"; sleep 0.2; echo "DONE1"'), +... (pane2, 'echo "Task 2"; sleep 0.1; echo "DONE2"'), +... (pane3, 'echo "Task 3"; sleep 0.3; echo "DONE3"'), +... ] + +>>> for pane, cmd in tasks: +... pane.send_keys(cmd) + +>>> # Wait for all tasks +>>> time.sleep(0.5) + +>>> # Verify all completed +>>> all('DONE' in '\\n'.join(p.capture_pane()) for p, _ in tasks) +True + +>>> # Clean up +>>> parallel_window.kill() +``` + +### Monitoring multiple panes for completion + +```python +>>> import time +>>> from libtmux.constants import PaneDirection + +>>> multi_window = session.new_window(window_name='multi-monitor', attach=False) +>>> multi_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> panes = [multi_window.active_pane] +>>> panes.append(panes[0].split(direction=PaneDirection.Right)) +>>> panes.append(panes[0].split(direction=PaneDirection.Below)) + +>>> def wait_all_complete(panes, marker='COMPLETE', timeout=10.0): +... """Wait for all panes to show completion marker.""" +... start = time.time() +... remaining = set(range(len(panes))) +... while remaining and time.time() - start < timeout: +... for i in list(remaining): +... if marker in '\\n'.join(panes[i].capture_pane()): +... remaining.remove(i) +... time.sleep(0.1) +... return len(remaining) == 0 + +>>> # Start tasks with different durations +>>> for i, pane in enumerate(panes): +... pane.send_keys(f'sleep 0.{i+1}; echo "COMPLETE"') + +>>> # Wait for all +>>> wait_all_complete(panes, 'COMPLETE', timeout=2.0) +True + +>>> # Clean up +>>> multi_window.kill() +``` + +## Context Manager Patterns + +### Temporary session for isolated work + +```python +>>> # Create isolated session for a task +>>> with server.new_session(session_name='temp-work') as temp_session: +... window = temp_session.new_window(window_name='task') +... pane = window.active_pane +... pane.send_keys('echo "Isolated work"') +... # Session exists during work +... temp_session in server.sessions +True + +>>> # Session automatically killed after context +>>> temp_session not in server.sessions +True +``` + +### Temporary window for subtask + +```python +>>> import time + +>>> with session.new_window(window_name='subtask') as sub_window: +... pane = sub_window.active_pane +... pane.send_keys('echo "Subtask running"') +... time.sleep(0.1) +... 'Subtask' in '\\n'.join(pane.capture_pane()) +True + +>>> # Window cleaned up automatically +>>> sub_window not in session.windows +True +``` + +## Timeout Handling + +### Command with timeout + +```python +>>> import time + +>>> timeout_window = session.new_window(window_name='timeout-demo', attach=False) +>>> timeout_pane = timeout_window.active_pane + +>>> class CommandTimeout(Exception): +... """Raised when a command times out.""" +... pass + +>>> def run_with_timeout(pane, command, marker='__DONE__', timeout=5.0): +... """Run command and wait for completion with timeout.""" +... pane.send_keys(f'{command}; echo {marker}') +... start = time.time() +... while time.time() - start < timeout: +... output = '\\n'.join(pane.capture_pane()) +... if marker in output: +... return output +... time.sleep(0.1) +... raise CommandTimeout(f'Command timed out after {timeout}s') + +>>> # Test successful command +>>> result = run_with_timeout(timeout_pane, 'echo "fast"', timeout=2.0) +>>> 'fast' in result +True + +>>> # Clean up +>>> timeout_window.kill() +``` + +### Retry pattern + +```python +>>> import time + +>>> retry_window = session.new_window(window_name='retry-demo', attach=False) +>>> retry_pane = retry_window.active_pane + +>>> def retry_until_success(pane, command, success_marker, max_retries=3, delay=0.5): +... """Retry command until success marker appears.""" +... for attempt in range(max_retries): +... pane.send_keys(command) +... time.sleep(delay) +... output = '\\n'.join(pane.capture_pane()) +... if success_marker in output: +... return True, attempt + 1 +... return False, max_retries + +>>> # Test retry +>>> success, attempts = retry_until_success( +... retry_pane, 'echo "OK"', 'OK', max_retries=3, delay=0.2 +... ) +>>> success +True +>>> attempts +1 + +>>> # Clean up +>>> retry_window.kill() +``` + +## Agentic Workflow Patterns + +### Task queue processor + +```python +>>> import time + +>>> queue_window = session.new_window(window_name='queue', attach=False) +>>> queue_pane = queue_window.active_pane + +>>> def process_task_queue(pane, tasks, completion_marker='TASK_DONE'): +... """Process a queue of tasks sequentially.""" +... results = [] +... for i, task in enumerate(tasks): +... pane.send_keys(f'{task}; echo "{completion_marker}_{i}"') +... # Wait for this task to complete +... start = time.time() +... while time.time() - start < 5.0: +... output = '\\n'.join(pane.capture_pane()) +... if f'{completion_marker}_{i}' in output: +... results.append((i, True)) +... break +... time.sleep(0.1) +... else: +... results.append((i, False)) +... return results + +>>> tasks = ['echo "Step 1"', 'echo "Step 2"', 'echo "Step 3"'] +>>> results = process_task_queue(queue_pane, tasks) +>>> all(success for _, success in results) +True + +>>> # Clean up +>>> queue_window.kill() +``` + +### State machine runner + +```python +>>> import time + +>>> state_window = session.new_window(window_name='state-machine', attach=False) +>>> state_pane = state_window.active_pane + +>>> def run_state_machine(pane, states, timeout_per_state=2.0): +... """Run through a series of states with transitions.""" +... current_state = 0 +... history = [] +... +... while current_state < len(states): +... state_name, command, next_marker = states[current_state] +... pane.send_keys(command) +... +... start = time.time() +... while time.time() - start < timeout_per_state: +... output = '\\n'.join(pane.capture_pane()) +... if next_marker in output: +... history.append(state_name) +... current_state += 1 +... break +... time.sleep(0.1) +... else: +... return history, False # Timeout +... +... return history, True + +>>> states = [ +... ('init', 'echo "INIT_DONE"', 'INIT_DONE'), +... ('process', 'echo "PROCESS_DONE"', 'PROCESS_DONE'), +... ('cleanup', 'echo "CLEANUP_DONE"', 'CLEANUP_DONE'), +... ] + +>>> history, success = run_state_machine(state_pane, states) +>>> success +True +>>> len(history) +3 + +>>> # Clean up +>>> state_window.kill() +``` + +## Best Practices + +### 1. Always use markers for completion detection + +Instead of relying on timing, use explicit markers: + +```python +>>> bp_window = session.new_window(window_name='best-practice', attach=False) +>>> bp_pane = bp_window.active_pane + +>>> # Good: Use completion marker +>>> bp_pane.send_keys('long_command; echo "__DONE__"') + +>>> # Then poll for marker +>>> import time +>>> time.sleep(0.2) +>>> '__DONE__' in '\\n'.join(bp_pane.capture_pane()) +True + +>>> bp_window.kill() +``` + +### 2. Clean up resources + +Always clean up windows and sessions when done: + +```python +>>> cleanup_window = session.new_window(window_name='cleanup-demo', attach=False) +>>> cleanup_window # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Do work... + +>>> # Always clean up +>>> cleanup_window.kill() +>>> cleanup_window not in session.windows +True +``` + +### 3. Use context managers for automatic cleanup + +```python +>>> # Context managers ensure cleanup even on exceptions +>>> with session.new_window(window_name='safe-work') as safe_window: +... pane = safe_window.active_pane +... # Work happens here +... pass # Even if exception occurs, window is cleaned up +``` + +:::{seealso} +- {ref}`pane-interaction` for basic pane operations +- {ref}`workspace-setup` for creating workspace layouts +- {ref}`context-managers` for resource management patterns +- {class}`~libtmux.Pane` for all pane methods +::: diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md new file mode 100644 index 000000000..60b710ad9 --- /dev/null +++ b/docs/topics/context_managers.md @@ -0,0 +1,128 @@ +(context_managers)= + +# Context Managers + +libtmux provides context managers for all main tmux objects to ensure proper cleanup of resources. This is done through Python's `with` statement, which automatically handles cleanup when you're done with the tmux objects. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +Import `libtmux`: + +```python +import libtmux +``` + +## Server Context Manager + +Create a temporary server that will be killed when you're done: + +```python +>>> with Server() as server: +... session = server.new_session() +... print(server.is_alive()) +True +>>> print(server.is_alive()) # Server is killed after exiting context +False +``` + +## Session Context Manager + +Create a temporary session that will be killed when you're done: + +```python +>>> server = Server() +>>> with server.new_session() as session: +... print(session in server.sessions) +... window = session.new_window() +True +>>> print(session in server.sessions) # Session is killed after exiting context +False +``` + +## Window Context Manager + +Create a temporary window that will be killed when you're done: + +```python +>>> server = Server() +>>> session = server.new_session() +>>> with session.new_window() as window: +... print(window in session.windows) +... pane = window.split() +True +>>> print(window in session.windows) # Window is killed after exiting context +False +``` + +## Pane Context Manager + +Create a temporary pane that will be killed when you're done: + +```python +>>> server = Server() +>>> session = server.new_session() +>>> window = session.new_window() +>>> with window.split() as pane: +... print(pane in window.panes) +... pane.send_keys('echo "Hello"') +True +>>> print(pane in window.panes) # Pane is killed after exiting context +False +``` + +## Nested Context Managers + +Context managers can be nested to create a clean hierarchy of tmux objects that are automatically cleaned up: + +```python +>>> with Server() as server: +... with server.new_session() as session: +... with session.new_window() as window: +... with window.split() as pane: +... pane.send_keys('echo "Hello"') +... # Do work with the pane +... # Everything is cleaned up automatically when exiting contexts +``` + +This ensures that: + +1. The pane is killed when exiting its context +2. The window is killed when exiting its context +3. The session is killed when exiting its context +4. The server is killed when exiting its context + +The cleanup happens in reverse order (pane → window → session → server), ensuring proper resource management. + +## Benefits + +Using context managers provides several advantages: + +1. **Automatic Cleanup**: Resources are automatically cleaned up when you're done with them +2. **Clean Code**: No need to manually call `kill()` methods +3. **Exception Safety**: Resources are cleaned up even if an exception occurs +4. **Hierarchical Cleanup**: Nested contexts ensure proper cleanup order +5. **Resource Management**: Prevents resource leaks by ensuring tmux objects are properly destroyed + +## When to Use + +Context managers are particularly useful when: + +1. Creating temporary tmux objects for testing +2. Running short-lived tmux sessions +3. Managing multiple tmux servers +4. Ensuring cleanup in scripts that may raise exceptions +5. Creating isolated environments that need to be cleaned up afterward + +[target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md new file mode 100644 index 000000000..bb71f81b7 --- /dev/null +++ b/docs/topics/filtering.md @@ -0,0 +1,263 @@ +(querylist-filtering)= + +# QueryList Filtering + +libtmux uses `QueryList` to enable Django-style filtering on tmux objects. +Every collection (`server.sessions`, `session.windows`, `window.panes`) returns +a `QueryList`, letting you filter sessions, windows, and panes with a fluent, +chainable API. + +## Basic Filtering + +The `filter()` method accepts keyword arguments with optional lookup suffixes: + +```python +>>> server.sessions # doctest: +ELLIPSIS +[Session($... ...)] +``` + +### Exact Match + +The default lookup is `exact`: + +```python +>>> # These are equivalent +>>> server.sessions.filter(session_name=session.session_name) # doctest: +ELLIPSIS +[Session($... ...)] +>>> server.sessions.filter(session_name__exact=session.session_name) # doctest: +ELLIPSIS +[Session($... ...)] +``` + +### Contains and Startswith + +Use suffixes for partial matching: + +```python +>>> # Create windows for this example +>>> w1 = session.new_window(window_name="api-server") +>>> w2 = session.new_window(window_name="api-worker") +>>> w3 = session.new_window(window_name="web-frontend") + +>>> # Windows containing 'api' +>>> api_windows = session.windows.filter(window_name__contains='api') +>>> len(api_windows) >= 2 +True + +>>> # Windows starting with 'web' +>>> web_windows = session.windows.filter(window_name__startswith='web') +>>> len(web_windows) >= 1 +True + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## Available Lookups + +| Lookup | Description | +|--------|-------------| +| `exact` | Exact match (default) | +| `iexact` | Case-insensitive exact match | +| `contains` | Substring match | +| `icontains` | Case-insensitive substring | +| `startswith` | Prefix match | +| `istartswith` | Case-insensitive prefix | +| `endswith` | Suffix match | +| `iendswith` | Case-insensitive suffix | +| `in` | Value in list | +| `nin` | Value not in list | +| `regex` | Regular expression match | +| `iregex` | Case-insensitive regex | + +## Getting a Single Item + +Use `get()` to retrieve exactly one matching item: + +```python +>>> window = session.windows.get(window_id=session.active_window.window_id) +>>> window # doctest: +ELLIPSIS +Window(@... ..., Session($... ...)) +``` + +If no match or multiple matches are found, `get()` raises an exception: + +- `ObjectDoesNotExist` - no matching object found +- `MultipleObjectsReturned` - more than one object matches + +You can provide a default value to avoid the exception: + +```python +>>> session.windows.get(window_name="nonexistent", default=None) is None +True +``` + +## Chaining Filters + +Filters can be chained for complex queries: + +```python +>>> # Create windows for this example +>>> w1 = session.new_window(window_name="feature-login") +>>> w2 = session.new_window(window_name="feature-signup") +>>> w3 = session.new_window(window_name="bugfix-typo") + +>>> # Multiple conditions in one filter (AND) +>>> session.windows.filter( +... window_name__startswith='feature', +... window_name__endswith='signup' +... ) # doctest: +ELLIPSIS +[Window(@... ...:feature-signup, Session($... ...))] + +>>> # Chained filters (also AND) +>>> session.windows.filter( +... window_name__contains='feature' +... ).filter( +... window_name__contains='login' +... ) # doctest: +ELLIPSIS +[Window(@... ...:feature-login, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## Case-Insensitive Filtering + +Use `i` prefix variants for case-insensitive matching: + +```python +>>> # Create windows with mixed case +>>> w1 = session.new_window(window_name="MyApp-Server") +>>> w2 = session.new_window(window_name="myapp-worker") + +>>> # Case-insensitive contains +>>> myapp_windows = session.windows.filter(window_name__icontains='MYAPP') +>>> len(myapp_windows) >= 2 +True + +>>> # Case-insensitive startswith +>>> session.windows.filter(window_name__istartswith='myapp') # doctest: +ELLIPSIS +[Window(@... ...:MyApp-Server, Session($... ...)), Window(@... ...:myapp-worker, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +``` + +## Regex Filtering + +For complex patterns, use regex lookups: + +```python +>>> # Create windows with version-like names +>>> w1 = session.new_window(window_name="app-v1.0") +>>> w2 = session.new_window(window_name="app-v2.0") +>>> w3 = session.new_window(window_name="app-beta") + +>>> # Match version pattern +>>> versioned = session.windows.filter(window_name__regex=r'v\d+\.\d+$') +>>> len(versioned) >= 2 +True + +>>> # Case-insensitive regex +>>> session.windows.filter(window_name__iregex=r'BETA') # doctest: +ELLIPSIS +[Window(@... ...:app-beta, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## Filtering by List Membership + +Use `in` and `nin` (not in) for list-based filtering: + +```python +>>> # Create test windows +>>> w1 = session.new_window(window_name="dev") +>>> w2 = session.new_window(window_name="staging") +>>> w3 = session.new_window(window_name="prod") + +>>> # Filter windows in a list of names +>>> target_envs = ["dev", "prod"] +>>> session.windows.filter(window_name__in=target_envs) # doctest: +ELLIPSIS +[Window(@... ...:dev, Session($... ...)), Window(@... ...:prod, Session($... ...))] + +>>> # Filter windows NOT in a list +>>> non_prod = session.windows.filter(window_name__nin=["prod"]) +>>> any(w.window_name == "prod" for w in non_prod) +False + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## Filtering Across the Hierarchy + +Filter at any level of the tmux hierarchy: + +```python +>>> # All panes across all windows in the server +>>> server.panes # doctest: +ELLIPSIS +[Pane(%... Window(@... ..., Session($... ...)))] + +>>> # Filter panes by their window's name +>>> pane = session.active_pane +>>> pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Real-World Examples + +### Find all editor windows + +```python +>>> # Create sample windows +>>> w1 = session.new_window(window_name="vim-main") +>>> w2 = session.new_window(window_name="nvim-config") +>>> w3 = session.new_window(window_name="shell") + +>>> # Find vim/nvim windows +>>> editors = session.windows.filter(window_name__iregex=r'n?vim') +>>> len(editors) >= 2 +True + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +### Find windows by naming convention + +```python +>>> # Create windows following a naming convention +>>> w1 = session.new_window(window_name="project:frontend") +>>> w2 = session.new_window(window_name="project:backend") +>>> w3 = session.new_window(window_name="logs") + +>>> # Find all project windows +>>> project_windows = session.windows.filter(window_name__startswith='project:') +>>> len(project_windows) >= 2 +True + +>>> # Get specific project window +>>> backend = session.windows.get(window_name='project:backend') +>>> backend.window_name +'project:backend' + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## API Reference + +See {class}`~libtmux._internal.query_list.QueryList` for the complete API. diff --git a/docs/topics/index.md b/docs/topics/index.md index 7fe9893b8..f22e7f81b 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -2,9 +2,17 @@ orphan: true --- -# Topic Guides +# Topics + +Explore libtmux's core functionalities and underlying principles at a high level, while providing essential context and detailed explanations to help you understand its design and usage. ```{toctree} traversal +filtering +pane_interaction +workspace_setup +automation_patterns +context_managers +options_and_hooks ``` diff --git a/docs/topics/options_and_hooks.md b/docs/topics/options_and_hooks.md new file mode 100644 index 000000000..b95eeeda9 --- /dev/null +++ b/docs/topics/options_and_hooks.md @@ -0,0 +1,162 @@ +(options-and-hooks)= + +# Options and Hooks + +libtmux provides a unified API for managing tmux options and hooks across all +object types (Server, Session, Window, Pane). + +## Options + +tmux options control the behavior and appearance of sessions, windows, and +panes. libtmux provides a consistent interface through +{class}`~libtmux.options.OptionsMixin`. + +### Getting options + +Use {meth}`~libtmux.options.OptionsMixin.show_options` to get all options: + +```python +>>> session.show_options() # doctest: +ELLIPSIS +{...} +``` + +Use {meth}`~libtmux.options.OptionsMixin.show_option` to get a single option: + +```python +>>> server.show_option('buffer-limit') +50 +``` + +### Setting options + +Use {meth}`~libtmux.options.OptionsMixin.set_option` to set an option: + +```python +>>> window.set_option('automatic-rename', False) # doctest: +ELLIPSIS +Window(@... ...) + +>>> window.show_option('automatic-rename') +False +``` + +### Unsetting options + +Use {meth}`~libtmux.options.OptionsMixin.unset_option` to revert an option to +its default: + +```python +>>> window.unset_option('automatic-rename') # doctest: +ELLIPSIS +Window(@... ...) +``` + +### Option scopes + +tmux options exist at different scopes. Use the `scope` parameter to specify: + +```python +>>> from libtmux.constants import OptionScope + +>>> # Get window-scoped options from a session +>>> session.show_options(scope=OptionScope.Window) # doctest: +ELLIPSIS +{...} +``` + +### Global options + +Use `global_=True` to work with global options: + +```python +>>> server.show_option('buffer-limit', global_=True) +50 +``` + +## Hooks + +tmux hooks allow you to run commands when specific events occur. libtmux +provides hook management through {class}`~libtmux.hooks.HooksMixin`. + +### Setting and getting hooks + +Use {meth}`~libtmux.hooks.HooksMixin.set_hook` to set a hook and +{meth}`~libtmux.hooks.HooksMixin.show_hook` to get its value: + +```python +>>> session.set_hook('session-renamed', 'display-message "Session renamed"') # doctest: +ELLIPSIS +Session(...) + +>>> session.show_hook('session-renamed') # doctest: +ELLIPSIS +{0: 'display-message "Session renamed"'} + +>>> session.show_hooks() # doctest: +ELLIPSIS +{...} +``` + +Note that hooks are stored as indexed arrays in tmux, so `show_hook()` returns a +{class}`~libtmux._internal.sparse_array.SparseArray` (dict-like) with index keys. + +### Removing hooks + +Use {meth}`~libtmux.hooks.HooksMixin.unset_hook` to remove a hook: + +```python +>>> session.unset_hook('session-renamed') # doctest: +ELLIPSIS +Session(...) +``` + +### Indexed hooks + +tmux hooks support multiple values via indices (e.g., `session-renamed[0]`, +`session-renamed[1]`). This allows multiple commands to run for the same event: + +```python +>>> session.set_hook('after-split-window[0]', 'display-message "Split 0"') # doctest: +ELLIPSIS +Session(...) + +>>> session.set_hook('after-split-window[1]', 'display-message "Split 1"') # doctest: +ELLIPSIS +Session(...) + +>>> hooks = session.show_hook('after-split-window') +>>> sorted(hooks.keys()) +[0, 1] +``` + +The return value is a {class}`~libtmux._internal.sparse_array.SparseArray`, +which preserves sparse indices (e.g., indices 0 and 5 with no 1-4). + +### Bulk hook operations + +Use {meth}`~libtmux.hooks.HooksMixin.set_hooks` to set multiple indexed hooks: + +```python +>>> session.set_hooks('window-linked', { +... 0: 'display-message "Window linked 0"', +... 1: 'display-message "Window linked 1"', +... }) # doctest: +ELLIPSIS +Session(...) + +>>> # Clean up +>>> session.unset_hook('after-split-window[0]') # doctest: +ELLIPSIS +Session(...) +>>> session.unset_hook('after-split-window[1]') # doctest: +ELLIPSIS +Session(...) +>>> session.unset_hook('window-linked[0]') # doctest: +ELLIPSIS +Session(...) +>>> session.unset_hook('window-linked[1]') # doctest: +ELLIPSIS +Session(...) +``` + +## tmux version compatibility + +| Feature | Minimum tmux | +|---------|-------------| +| All options/hooks features | 3.2+ | +| Window/Pane hook scopes (`-w`, `-p`) | 3.2+ | +| `client-active`, `window-resized` hooks | 3.3+ | +| `pane-title-changed` hook | 3.5+ | + +:::{seealso} +- {ref}`api` for the full API reference +- {class}`~libtmux.options.OptionsMixin` for options methods +- {class}`~libtmux.hooks.HooksMixin` for hooks methods +- {class}`~libtmux._internal.sparse_array.SparseArray` for sparse array handling +::: diff --git a/docs/topics/pane_interaction.md b/docs/topics/pane_interaction.md new file mode 100644 index 000000000..5163023f2 --- /dev/null +++ b/docs/topics/pane_interaction.md @@ -0,0 +1,423 @@ +(pane-interaction)= + +# Pane Interaction + +libtmux provides powerful methods for interacting with tmux panes programmatically. +This is especially useful for automation, testing, and orchestrating terminal-based +workflows. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +## Sending Commands + +The {meth}`~libtmux.Pane.send_keys` method sends text to a pane, optionally pressing +Enter to execute it. + +### Basic command execution + +```python +>>> pane = window.split(shell='sh') + +>>> pane.send_keys('echo "Hello from libtmux"') + +>>> import time; time.sleep(0.1) # Allow command to execute + +>>> output = pane.capture_pane() +>>> 'Hello from libtmux' in '\\n'.join(output) +True +``` + +### Send without pressing Enter + +Use `enter=False` to type text without executing: + +```python +>>> pane.send_keys('echo "waiting"', enter=False) + +>>> # Text is typed but not executed +>>> output = pane.capture_pane() +>>> 'waiting' in '\\n'.join(output) +True +``` + +Press Enter separately with {meth}`~libtmux.Pane.enter`: + +```python +>>> import time + +>>> # First type something without pressing Enter +>>> pane.send_keys('echo "execute me"', enter=False) + +>>> pane.enter() # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> time.sleep(0.2) + +>>> output = pane.capture_pane() +>>> 'execute me' in '\\n'.join(output) +True +``` + +### Literal mode for special characters + +Use `literal=True` to send special characters without interpretation: + +```python +>>> import time + +>>> pane.send_keys('echo "Tab:\\tNewline:\\n"', literal=True) + +>>> time.sleep(0.1) +``` + +### Suppress shell history + +Use `suppress_history=True` to prepend a space (prevents command from being +saved in shell history): + +```python +>>> import time + +>>> pane.send_keys('echo "secret command"', suppress_history=True) + +>>> time.sleep(0.1) +``` + +## Capturing Output + +The {meth}`~libtmux.Pane.capture_pane` method captures text from a pane's buffer. + +### Basic capture + +```python +>>> import time + +>>> pane.send_keys('echo "Line 1"; echo "Line 2"; echo "Line 3"') + +>>> time.sleep(0.1) + +>>> output = pane.capture_pane() +>>> isinstance(output, list) +True +>>> any('Line 2' in line for line in output) +True +``` + +### Capture with line ranges + +Capture specific line ranges using `start` and `end` parameters: + +```python +>>> # Capture last 5 lines of visible pane +>>> recent = pane.capture_pane(start=-5, end='-') +>>> isinstance(recent, list) +True + +>>> # Capture from start of history to current +>>> full_history = pane.capture_pane(start='-', end='-') +>>> len(full_history) >= 0 +True +``` + +### Capture with ANSI escape sequences + +Capture colored output with escape sequences preserved using `escape_sequences=True`: + +```python +>>> import time + +>>> pane.send_keys('printf "\\033[31mRED\\033[0m \\033[32mGREEN\\033[0m"') +>>> time.sleep(0.1) + +>>> # Capture with ANSI codes stripped (default) +>>> output = pane.capture_pane() +>>> 'RED' in '\\n'.join(output) +True + +>>> # Capture with ANSI escape sequences preserved +>>> colored_output = pane.capture_pane(escape_sequences=True) +>>> isinstance(colored_output, list) +True +``` + +### Join wrapped lines + +Long lines that wrap in the terminal can be joined back together: + +```python +>>> import time + +>>> # Send a very long line that will wrap +>>> pane.send_keys('echo "' + 'x' * 200 + '"') +>>> time.sleep(0.1) + +>>> # Capture with wrapped lines joined +>>> output = pane.capture_pane(join_wrapped=True) +>>> isinstance(output, list) +True +``` + +### Preserve trailing spaces + +By default, trailing spaces are trimmed. Use `preserve_trailing=True` to keep them: + +```python +>>> import time + +>>> pane.send_keys('printf "text \\n"') # 3 trailing spaces +>>> time.sleep(0.1) + +>>> # Capture with trailing spaces preserved +>>> output = pane.capture_pane(preserve_trailing=True) +>>> isinstance(output, list) +True +``` + +### Capture flags summary + +| Parameter | tmux Flag | Description | +|-----------|-----------|-------------| +| `escape_sequences` | `-e` | Include ANSI escape sequences (colors, attributes) | +| `escape_non_printable` | `-C` | Escape non-printable chars as octal `\xxx` | +| `join_wrapped` | `-J` | Join wrapped lines back together | +| `preserve_trailing` | `-N` | Preserve trailing spaces at line ends | +| `trim_trailing` | `-T` | Trim trailing empty positions (tmux 3.4+) | + +:::{note} +The `trim_trailing` parameter requires tmux 3.4+. If used with an older version, +a warning is issued and the flag is ignored. +::: + +## Waiting for Output + +A common pattern in automation is waiting for a command to complete. + +### Polling for completion marker + +```python +>>> import time + +>>> pane.send_keys('sleep 0.2; echo "TASK_COMPLETE"') + +>>> # Poll for completion +>>> for _ in range(30): +... output = pane.capture_pane() +... if 'TASK_COMPLETE' in '\\n'.join(output): +... break +... time.sleep(0.1) + +>>> 'TASK_COMPLETE' in '\\n'.join(output) +True +``` + +### Helper function for waiting + +```python +>>> import time + +>>> def wait_for_text(pane, text, timeout=5.0): +... """Wait for text to appear in pane output.""" +... start = time.time() +... while time.time() - start < timeout: +... output = pane.capture_pane() +... if text in '\\n'.join(output): +... return True +... time.sleep(0.1) +... return False + +>>> pane.send_keys('echo "READY"') +>>> wait_for_text(pane, 'READY', timeout=2.0) +True +``` + +## Querying Pane State + +The {meth}`~libtmux.Pane.display_message` method queries tmux format variables. + +### Get pane dimensions + +```python +>>> width = pane.display_message('#{pane_width}', get_text=True) +>>> isinstance(width, list) and len(width) > 0 +True + +>>> height = pane.display_message('#{pane_height}', get_text=True) +>>> isinstance(height, list) and len(height) > 0 +True +``` + +### Get pane information + +```python +>>> # Current working directory +>>> cwd = pane.display_message('#{pane_current_path}', get_text=True) +>>> isinstance(cwd, list) +True + +>>> # Pane ID +>>> pane_id = pane.display_message('#{pane_id}', get_text=True) +>>> pane_id[0].startswith('%') +True +``` + +### Common format variables + +| Variable | Description | +|----------|-------------| +| `#{pane_width}` | Pane width in characters | +| `#{pane_height}` | Pane height in characters | +| `#{pane_current_path}` | Current working directory | +| `#{pane_pid}` | PID of the pane's shell | +| `#{pane_id}` | Unique pane ID (e.g., `%0`) | +| `#{pane_index}` | Pane index in window | + +## Resizing Panes + +The {meth}`~libtmux.Pane.resize` method adjusts pane dimensions. + +### Resize by specific dimensions + +```python +>>> # Make pane larger +>>> pane.resize(height=20, width=80) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +### Resize by adjustment + +```python +>>> from libtmux.constants import ResizeAdjustmentDirection + +>>> # Increase height by 5 rows +>>> pane.resize(adjustment_direction=ResizeAdjustmentDirection.Up, adjustment=5) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> # Decrease width by 10 columns +>>> pane.resize(adjustment_direction=ResizeAdjustmentDirection.Left, adjustment=10) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +### Zoom toggle + +```python +>>> # Zoom pane to fill window +>>> pane.resize(zoom=True) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> # Unzoom +>>> pane.resize(zoom=True) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Clearing the Pane + +The {meth}`~libtmux.Pane.clear` method clears the pane's screen: + +```python +>>> pane.clear() # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Killing Panes + +The {meth}`~libtmux.Pane.kill` method destroys a pane: + +```python +>>> # Create a temporary pane +>>> temp_pane = pane.split() +>>> temp_pane in window.panes +True + +>>> # Kill it +>>> temp_pane.kill() +>>> temp_pane not in window.panes +True +``` + +### Kill all except current + +```python +>>> # Setup: create multiple panes +>>> pane.window.resize(height=60, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> keep_pane = pane.split() +>>> extra1 = pane.split() +>>> extra2 = pane.split() + +>>> # Kill all except keep_pane +>>> keep_pane.kill(all_except=True) + +>>> keep_pane in window.panes +True +>>> extra1 not in window.panes +True +>>> extra2 not in window.panes +True + +>>> # Cleanup +>>> keep_pane.kill() +``` + +## Practical Recipes + +### Recipe: Run command and capture output + +```python +>>> import time + +>>> def run_and_capture(pane, command, marker='__DONE__', timeout=5.0): +... """Run a command and return its output.""" +... pane.send_keys(f'{command}; echo {marker}') +... start = time.time() +... while time.time() - start < timeout: +... output = pane.capture_pane() +... output_str = '\\n'.join(output) +... if marker in output_str: +... return output # Return all captured output +... time.sleep(0.1) +... raise TimeoutError(f'Command did not complete within {timeout}s') + +>>> result = run_and_capture(pane, 'echo "captured text"', timeout=2.0) +>>> 'captured text' in '\\n'.join(result) +True +``` + +### Recipe: Check for error patterns + +```python +>>> import time + +>>> def check_for_errors(pane, error_patterns=None): +... """Check pane output for error patterns.""" +... if error_patterns is None: +... error_patterns = ['error:', 'Error:', 'ERROR', 'failed', 'FAILED'] +... output = '\\n'.join(pane.capture_pane()) +... for pattern in error_patterns: +... if pattern in output: +... return True +... return False + +>>> pane.send_keys('echo "All good"') +>>> time.sleep(0.1) +>>> check_for_errors(pane) +False +``` + +:::{seealso} +- {ref}`api` for the full API reference +- {class}`~libtmux.Pane` for all pane methods +- {ref}`automation-patterns` for advanced orchestration patterns +::: diff --git a/docs/topics/traversal.md b/docs/topics/traversal.md index 77ace139a..214bbc861 100644 --- a/docs/topics/traversal.md +++ b/docs/topics/traversal.md @@ -1,8 +1,8 @@ (traversal)= -# Usage +# Traversal -libtmux convenient access to move around the hierarchy of sessions, +libtmux provides convenient access to move around the hierarchy of sessions, windows and panes in tmux. This is done by libtmux's object abstraction of {term}`target`s (the `-t` @@ -22,81 +22,267 @@ Terminal two, `python` or `ptpython` if you have it: $ python ``` -Import `libtmux`: +## Setup + +First, create a test session: + +```python +>>> session = server.new_session() # Create a test session using existing server +``` + +## Server Level + +View the server's representation: + +```python +>>> server # doctest: +ELLIPSIS +Server(socket_name=...) +``` + +Get all sessions in the server: ```python -import libtmux +>>> server.sessions # doctest: +ELLIPSIS +[Session($... ...)] ``` -Attach default tmux {class}`~libtmux.Server` to `t`: +Get all windows across all sessions: ```python ->>> import libtmux ->>> t = libtmux.Server(); ->>> t -Server(socket_path=/tmp/tmux-.../default) +>>> server.windows # doctest: +ELLIPSIS +[Window(@... ..., Session($... ...))] ``` -Get first session {class}`~libtmux.Session` to `session`: +Get all panes across all windows: + +```python +>>> server.panes # doctest: +ELLIPSIS +[Pane(%... Window(@... ..., Session($... ...)))] +``` + +## Session Level + +Get first session: ```python >>> session = server.sessions[0] ->>> session -Session($1 ...) +>>> session # doctest: +ELLIPSIS +Session($... ...) +``` + +Get windows in a session: + +```python +>>> session.windows # doctest: +ELLIPSIS +[Window(@... ..., Session($... ...))] +``` + +Get active window and pane: + +```python +>>> session.active_window # doctest: +ELLIPSIS +Window(@... ..., Session($... ...)) + +>>> session.active_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Window Level + +Get a window and inspect its properties: + +```python +>>> window = session.windows[0] +>>> window.window_index # doctest: +ELLIPSIS +'...' +``` + +Access the window's parent session: + +```python +>>> window.session # doctest: +ELLIPSIS +Session($... ...) +>>> window.session.session_id == session.session_id +True +``` + +Get panes in a window: + +```python +>>> window.panes # doctest: +ELLIPSIS +[Pane(%... Window(@... ..., Session($... ...)))] +``` + +Get active pane: + +```python +>>> window.active_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Pane Level + +Get a pane and traverse upwards: + +```python +>>> pane = window.panes[0] +>>> pane.window.window_id == window.window_id +True +>>> pane.session.session_id == session.session_id +True +>>> pane.server is server +True +``` + +## Filtering and Finding Objects + +libtmux collections support Django-style filtering with `filter()` and `get()`. +For comprehensive coverage of all lookup operators, see {ref}`querylist-filtering`. + +### Basic Filtering + +Find windows by exact attribute match: + +```python +>>> session.windows.filter(window_index=window.window_index) # doctest: +ELLIPSIS +[Window(@... ..., Session($... ...))] +``` + +Get a specific pane by ID: + +```python +>>> window.panes.get(pane_id=pane.pane_id) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) ``` -Get a list of sessions: +### Partial Matching + +Use lookup suffixes like `__contains`, `__startswith`, `__endswith`: ```python ->>> server.sessions -[Session($1 ...), Session($0 ...)] +>>> # Create windows to demonstrate filtering +>>> w1 = session.new_window(window_name="app-frontend") +>>> w2 = session.new_window(window_name="app-backend") +>>> w3 = session.new_window(window_name="logs") + +>>> # Find windows starting with 'app-' +>>> session.windows.filter(window_name__startswith='app-') # doctest: +ELLIPSIS +[Window(@... ...:app-frontend, Session($... ...)), Window(@... ...:app-backend, Session($... ...))] + +>>> # Find windows containing 'end' +>>> session.windows.filter(window_name__contains='end') # doctest: +ELLIPSIS +[Window(@... ...:app-frontend, Session($... ...)), Window(@... ...:app-backend, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() ``` -Iterate through sessions in a server: +### Case-Insensitive Matching + +Prefix any lookup with `i` for case-insensitive matching: ```python ->>> for sess in server.sessions: -... print(sess) -Session($1 ...) -Session($0 ...) +>>> # Create windows with mixed case +>>> w1 = session.new_window(window_name="MyApp") +>>> w2 = session.new_window(window_name="myapp-worker") + +>>> # Case-insensitive search +>>> session.windows.filter(window_name__istartswith='myapp') # doctest: +ELLIPSIS +[Window(@... ...:MyApp, Session($... ...)), Window(@... ...:myapp-worker, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() ``` -Grab a {class}`~libtmux.Window` from a session: +### Regex Filtering + +For complex patterns, use `__regex` or `__iregex`: ```python ->>> session.windows[0] -Window(@1 ...:..., Session($1 ...)) +>>> # Create versioned windows +>>> w1 = session.new_window(window_name="release-v1.0") +>>> w2 = session.new_window(window_name="release-v2.0") +>>> w3 = session.new_window(window_name="dev") + +>>> # Match semantic version pattern +>>> session.windows.filter(window_name__regex=r'v\d+\.\d+') # doctest: +ELLIPSIS +[Window(@... ...:release-v1.0, Session($... ...)), Window(@... ...:release-v2.0, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() ``` -Grab the currently focused window from session: +### Chaining Filters + +Multiple conditions can be combined: ```python ->>> session.attached_window -Window(@1 ...:..., Session($1 ...)) +>>> # Create windows for chaining example +>>> w1 = session.new_window(window_name="api-prod") +>>> w2 = session.new_window(window_name="api-staging") +>>> w3 = session.new_window(window_name="web-prod") + +>>> # Multiple conditions in one call (AND) +>>> session.windows.filter( +... window_name__startswith='api', +... window_name__endswith='prod' +... ) # doctest: +ELLIPSIS +[Window(@... ...:api-prod, Session($... ...))] + +>>> # Chained calls (also AND) +>>> session.windows.filter( +... window_name__contains='api' +... ).filter( +... window_name__contains='staging' +... ) # doctest: +ELLIPSIS +[Window(@... ...:api-staging, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() ``` -Grab the currently focused {class}`Pane` from session: +### Get with Default + +Avoid exceptions when an object might not exist: ```python ->>> session.attached_pane -Pane(%1 Window(@1 ...:..., Session($1 ...))) +>>> # Returns None instead of raising ObjectDoesNotExist +>>> session.windows.get(window_name="nonexistent", default=None) is None +True ``` -Assign the attached {class}`~libtmux.Pane` to `p`: +## Checking Relationships + +Check if objects are related: ```python ->>> p = session.attached_pane +>>> window in session.windows +True +>>> pane in window.panes +True +>>> session in server.sessions +True ``` -Access the window/server of a pane: +Check if a window is active: ```python ->>> p = session.attached_pane ->>> p.window -Window(@1 ...:..., Session($1 ...)) +>>> window.window_id == session.active_window.window_id +True +``` ->>> p.server -Server(socket_name=libtmux_test...) +Check if a pane is active: + +```python +>>> pane.pane_id == window.active_pane.pane_id +True ``` [target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS diff --git a/docs/topics/workspace_setup.md b/docs/topics/workspace_setup.md new file mode 100644 index 000000000..673e26db9 --- /dev/null +++ b/docs/topics/workspace_setup.md @@ -0,0 +1,353 @@ +(workspace-setup)= + +# Workspace Setup + +libtmux makes it easy to create and configure multi-pane workspaces programmatically. +This is useful for setting up development environments, running parallel tasks, +and orchestrating terminal-based workflows. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +## Creating Windows + +The {meth}`~libtmux.Session.new_window` method creates new windows within a session. + +### Basic window creation + +```python +>>> new_window = session.new_window(window_name='workspace') +>>> new_window # doctest: +ELLIPSIS +Window(@... ...:workspace, Session($... ...)) + +>>> # Window is part of the session +>>> new_window in session.windows +True +``` + +### Create without attaching + +Use `attach=False` to create a window in the background: + +```python +>>> background_window = session.new_window( +... window_name='background-task', +... attach=False, +... ) +>>> background_window # doctest: +ELLIPSIS +Window(@... ...:background-task, Session($... ...)) + +>>> # Clean up +>>> background_window.kill() +``` + +### Create with specific shell + +```python +>>> shell_window = session.new_window( +... window_name='shell-test', +... attach=False, +... window_shell='sh -c "echo Hello; exec sh"', +... ) +>>> shell_window # doctest: +ELLIPSIS +Window(@... ...:shell-test, Session($... ...)) + +>>> # Clean up +>>> shell_window.kill() +``` + +## Splitting Panes + +The {meth}`~libtmux.Window.split` method divides windows into multiple panes. + +### Vertical split (top/bottom) + +```python +>>> import time +>>> from libtmux.constants import PaneDirection + +>>> # Create a window with enough space +>>> v_split_window = session.new_window(window_name='v-split-demo', attach=False) +>>> v_split_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Default split is vertical (creates pane below) +>>> top_pane = v_split_window.active_pane +>>> bottom_pane = v_split_window.split() +>>> bottom_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> len(v_split_window.panes) +2 + +>>> # Clean up +>>> v_split_window.kill() +``` + +### Horizontal split (left/right) + +```python +>>> from libtmux.constants import PaneDirection + +>>> # Create a fresh window for this demo +>>> h_split_window = session.new_window(window_name='h-split', attach=False) +>>> h_split_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> left_pane = h_split_window.active_pane +>>> right_pane = left_pane.split(direction=PaneDirection.Right) +>>> right_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> len(h_split_window.panes) +2 + +>>> # Clean up +>>> h_split_window.kill() +``` + +### Split with specific size + +```python +>>> # Create a fresh window for size demo +>>> size_window = session.new_window(window_name='size-demo', attach=False) +>>> size_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> main_pane = size_window.active_pane +>>> # Create pane with specific percentage +>>> small_pane = main_pane.split(size='20%') +>>> small_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> # Clean up +>>> size_window.kill() +``` + +## Layout Management + +The {meth}`~libtmux.Window.select_layout` method arranges panes using built-in layouts. + +### Available layouts + +tmux provides five built-in layouts: + +| Layout | Description | +|--------|-------------| +| `even-horizontal` | Panes spread evenly left to right | +| `even-vertical` | Panes spread evenly top to bottom | +| `main-horizontal` | Large pane on top, others below | +| `main-vertical` | Large pane on left, others on right | +| `tiled` | Panes spread evenly in rows and columns | + +### Applying layouts + +```python +>>> # Create window with multiple panes +>>> layout_window = session.new_window(window_name='layout-demo', attach=False) +>>> layout_window.resize(height=60, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> pane1 = layout_window.active_pane +>>> pane2 = layout_window.split() +>>> pane3 = layout_window.split() +>>> pane4 = layout_window.split() + +>>> # Apply tiled layout +>>> layout_window.select_layout('tiled') # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Apply even-horizontal layout +>>> layout_window.select_layout('even-horizontal') # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Apply main-vertical layout +>>> layout_window.select_layout('main-vertical') # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Clean up +>>> layout_window.kill() +``` + +## Renaming and Organizing + +### Rename windows + +```python +>>> rename_window = session.new_window(window_name='old-name', attach=False) +>>> rename_window.rename_window('new-name') # doctest: +ELLIPSIS +Window(@... ...:new-name, Session($... ...)) + +>>> rename_window.window_name +'new-name' + +>>> # Clean up +>>> rename_window.kill() +``` + +### Access window properties + +```python +>>> demo_window = session.new_window(window_name='props-demo', attach=False) + +>>> # Window index +>>> demo_window.window_index # doctest: +ELLIPSIS +'...' + +>>> # Window ID +>>> demo_window.window_id # doctest: +ELLIPSIS +'@...' + +>>> # Parent session +>>> demo_window.session # doctest: +ELLIPSIS +Session($... ...) + +>>> # Clean up +>>> demo_window.kill() +``` + +## Practical Recipes + +### Recipe: Create a development workspace + +```python +>>> import time +>>> from libtmux.constants import PaneDirection + +>>> def create_dev_workspace(session, name='dev'): +... """Create a typical development workspace layout.""" +... window = session.new_window(window_name=name, attach=False) +... window.resize(height=50, width=160) +... +... # Main editing pane (large, left side) +... main_pane = window.active_pane +... +... # Terminal pane (bottom) +... terminal_pane = main_pane.split(size='30%') +... +... # Logs pane (right side of terminal) +... log_pane = terminal_pane.split(direction=PaneDirection.Right) +... +... return { +... 'window': window, +... 'main': main_pane, +... 'terminal': terminal_pane, +... 'logs': log_pane, +... } + +>>> workspace = create_dev_workspace(session, 'my-project') +>>> len(workspace['window'].panes) +3 + +>>> # Clean up +>>> workspace['window'].kill() +``` + +### Recipe: Create a grid of panes + +```python +>>> from libtmux.constants import PaneDirection + +>>> def create_pane_grid(session, rows=2, cols=2, name='grid'): +... """Create an NxM grid of panes.""" +... window = session.new_window(window_name=name, attach=False) +... window.resize(height=50, width=160) +... +... panes = [] +... base_pane = window.active_pane +... panes.append(base_pane) +... +... # Create first row of panes +... current = base_pane +... for _ in range(cols - 1): +... new_pane = current.split(direction=PaneDirection.Right) +... panes.append(new_pane) +... current = new_pane +... +... # Create additional rows +... for _ in range(rows - 1): +... row_start = panes[-cols] +... current = row_start +... for col in range(cols): +... new_pane = panes[-cols + col].split(direction=PaneDirection.Below) +... panes.append(new_pane) +... +... # Apply tiled layout for even distribution +... window.select_layout('tiled') +... return window, panes + +>>> grid_window, grid_panes = create_pane_grid(session, rows=2, cols=2, name='test-grid') +>>> len(grid_panes) >= 4 +True + +>>> # Clean up +>>> grid_window.kill() +``` + +### Recipe: Run commands in multiple panes + +```python +>>> import time + +>>> def run_in_panes(panes, commands): +... """Run different commands in each pane.""" +... for pane, cmd in zip(panes, commands): +... pane.send_keys(cmd) + +>>> multi_window = session.new_window(window_name='multi-cmd', attach=False) +>>> multi_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> pane_a = multi_window.active_pane +>>> pane_b = multi_window.split() +>>> pane_c = multi_window.split() + +>>> run_in_panes( +... [pane_a, pane_b, pane_c], +... ['echo "Task A"', 'echo "Task B"', 'echo "Task C"'], +... ) + +>>> # Give commands time to execute +>>> time.sleep(0.2) + +>>> # Verify all commands ran +>>> 'Task A' in '\\n'.join(pane_a.capture_pane()) +True + +>>> # Clean up +>>> multi_window.kill() +``` + +## Window Context Managers + +Windows can be used as context managers for automatic cleanup: + +```python +>>> with session.new_window(window_name='temp-window') as temp_win: +... pane = temp_win.active_pane +... pane.send_keys('echo "temporary workspace"') +... temp_win in session.windows +True + +>>> # Window is automatically killed after exiting context +>>> temp_win not in session.windows +True +``` + +:::{seealso} +- {ref}`pane-interaction` for working with pane content +- {ref}`automation-patterns` for advanced orchestration +- {class}`~libtmux.Window` for all window methods +- {class}`~libtmux.Session` for session management +::: diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 159c71639..000000000 --- a/poetry.lock +++ /dev/null @@ -1,1239 +0,0 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. - -[[package]] -name = "alabaster" -version = "0.7.13" -description = "A configurable sidebar-enabled Sphinx theme" -optional = false -python-versions = ">=3.6" -files = [ - {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, - {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, -] - -[[package]] -name = "babel" -version = "2.14.0" -description = "Internationalization utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, - {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, -] - -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - -[package.extras] -dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] - -[[package]] -name = "beautifulsoup4" -version = "4.12.3" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.6.0" -files = [ - {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, - {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, -] - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] - -[[package]] -name = "certifi" -version = "2023.11.17" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.3.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, -] - -[[package]] -name = "codecov" -version = "2.1.13" -description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5"}, - {file = "codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c"}, -] - -[package.dependencies] -coverage = "*" -requests = ">=2.7.9" - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.4.0" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, - {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, - {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, - {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, - {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, - {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, - {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, - {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, - {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, - {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, - {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, - {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, - {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, - {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "docutils" -version = "0.20.1" -description = "Docutils -- Python Documentation Utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, - {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.0" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "furo" -version = "2023.9.10" -description = "A clean customisable Sphinx documentation theme." -optional = false -python-versions = ">=3.8" -files = [ - {file = "furo-2023.9.10-py3-none-any.whl", hash = "sha256:513092538537dc5c596691da06e3c370714ec99bc438680edc1debffb73e5bfc"}, - {file = "furo-2023.9.10.tar.gz", hash = "sha256:5707530a476d2a63b8cad83b4f961f3739a69f4b058bcf38a03a39fa537195b2"}, -] - -[package.dependencies] -beautifulsoup4 = "*" -pygments = ">=2.7" -sphinx = ">=6.0,<8.0" -sphinx-basic-ng = "*" - -[[package]] -name = "gp-libs" -version = "0.0.5" -description = "Internal utilities for projects following git-pull python package spec" -optional = false -python-versions = ">=3.8,<4.0" -files = [ - {file = "gp_libs-0.0.5-py3-none-any.whl", hash = "sha256:aaadd9299c74f3187db025a164a473b52c3bae71e2f68b84e7ccd8c9863d49ba"}, - {file = "gp_libs-0.0.5.tar.gz", hash = "sha256:6e5f43d2d362be9f8fb4bac0ccaaf27dd17f5642fee0f3f948e80375e2554a13"}, -] - -[package.dependencies] -docutils = "*" -myst_parser = ">=0.18.1" - -[[package]] -name = "idna" -version = "3.6" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.5" -files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, -] - -[[package]] -name = "imagesize" -version = "1.4.1" -description = "Getting image size from png/jpeg/jpeg2000/gif file" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, - {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, -] - -[[package]] -name = "importlib-metadata" -version = "7.0.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, - {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "jinja2" -version = "3.1.3" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, - {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "livereload" -version = "2.6.3" -description = "Python LiveReload is an awesome tool for web developers" -optional = false -python-versions = "*" -files = [ - {file = "livereload-2.6.3-py2.py3-none-any.whl", hash = "sha256:ad4ac6f53b2d62bb6ce1a5e6e96f1f00976a32348afedcb4b6d68df2a1d346e4"}, - {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, -] - -[package.dependencies] -six = "*" -tornado = {version = "*", markers = "python_version > \"2.7\""} - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.8" -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "2.1.4" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win32.whl", hash = "sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win32.whl", hash = "sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win32.whl", hash = "sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a07f40ef8f0fbc5ef1000d0c78771f4d5ca03b4953fc162749772916b298fc4"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d18b66fe626ac412d96c2ab536306c736c66cf2a31c243a45025156cc190dc8a"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:698e84142f3f884114ea8cf83e7a67ca8f4ace8454e78fe960646c6c91c63bfa"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a3b78a5af63ec10d8604180380c13dcd870aba7928c1fe04e881d5c792dc4e"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:15866d7f2dc60cfdde12ebb4e75e41be862348b4728300c36cdf405e258415ec"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6aa5e2e7fc9bc042ae82d8b79d795b9a62bd8f15ba1e7594e3db243f158b5565"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:54635102ba3cf5da26eb6f96c4b8c53af8a9c0d97b64bdcb592596a6255d8518"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-win32.whl", hash = "sha256:3583a3a3ab7958e354dc1d25be74aee6228938312ee875a22330c4dc2e41beb0"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-win_amd64.whl", hash = "sha256:d6e427c7378c7f1b2bef6a344c925b8b63623d3321c09a237b7cc0e77dd98ceb"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bf1196dcc239e608605b716e7b166eb5faf4bc192f8a44b81e85251e62584bd2"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df98d4a9cd6a88d6a585852f56f2155c9cdb6aec78361a19f938810aa020954"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b835aba863195269ea358cecc21b400276747cc977492319fd7682b8cd2c253d"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23984d1bdae01bee794267424af55eef4dfc038dc5d1272860669b2aa025c9e3"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c98c33ffe20e9a489145d97070a435ea0679fddaabcafe19982fe9c971987d5"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9896fca4a8eb246defc8b2a7ac77ef7553b638e04fbf170bff78a40fa8a91474"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b0fe73bac2fed83839dbdbe6da84ae2a31c11cfc1c777a40dbd8ac8a6ed1560f"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7556bafeaa0a50e2fe7dc86e0382dea349ebcad8f010d5a7dc6ba568eaaa789"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-win32.whl", hash = "sha256:fc1a75aa8f11b87910ffd98de62b29d6520b6d6e8a3de69a70ca34dea85d2a8a"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-win_amd64.whl", hash = "sha256:3a66c36a3864df95e4f62f9167c734b3b1192cb0851b43d7cc08040c074c6279"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:765f036a3d00395a326df2835d8f86b637dbaf9832f90f5d196c3b8a7a5080cb"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21e7af8091007bf4bebf4521184f4880a6acab8df0df52ef9e513d8e5db23411"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5c31fe855c77cad679b302aabc42d724ed87c043b1432d457f4976add1c2c3e"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7653fa39578957bc42e5ebc15cf4361d9e0ee4b702d7d5ec96cdac860953c5b4"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47bb5f0142b8b64ed1399b6b60f700a580335c8e1c57f2f15587bd072012decc"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:fe8512ed897d5daf089e5bd010c3dc03bb1bdae00b35588c49b98268d4a01e00"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:36d7626a8cca4d34216875aee5a1d3d654bb3dac201c1c003d182283e3205949"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b6f14a9cd50c3cb100eb94b3273131c80d102e19bb20253ac7bd7336118a673a"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-win32.whl", hash = "sha256:c8f253a84dbd2c63c19590fa86a032ef3d8cc18923b8049d91bcdeeb2581fbf6"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-win_amd64.whl", hash = "sha256:8b570a1537367b52396e53325769608f2a687ec9a4363647af1cded8928af959"}, - {file = "MarkupSafe-2.1.4.tar.gz", hash = "sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f"}, -] - -[[package]] -name = "mdit-py-plugins" -version = "0.4.0" -description = "Collection of plugins for markdown-it-py" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mdit_py_plugins-0.4.0-py3-none-any.whl", hash = "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9"}, - {file = "mdit_py_plugins-0.4.0.tar.gz", hash = "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b"}, -] - -[package.dependencies] -markdown-it-py = ">=1.0.0,<4.0.0" - -[package.extras] -code-style = ["pre-commit"] -rtd = ["myst-parser", "sphinx-book-theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mypy" -version = "1.8.0" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, - {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, - {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, - {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, - {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, - {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, - {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, - {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, - {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, - {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, - {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, - {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, - {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, - {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, - {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, - {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, - {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, - {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, - {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, -] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.1.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "myst-parser" -version = "2.0.0" -description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," -optional = false -python-versions = ">=3.8" -files = [ - {file = "myst_parser-2.0.0-py3-none-any.whl", hash = "sha256:7c36344ae39c8e740dad7fdabf5aa6fc4897a813083c6cc9990044eb93656b14"}, - {file = "myst_parser-2.0.0.tar.gz", hash = "sha256:ea929a67a6a0b1683cdbe19b8d2e724cd7643f8aa3e7bb18dd65beac3483bead"}, -] - -[package.dependencies] -docutils = ">=0.16,<0.21" -jinja2 = "*" -markdown-it-py = ">=3.0,<4.0" -mdit-py-plugins = ">=0.4,<1.0" -pyyaml = "*" -sphinx = ">=6,<8" - -[package.extras] -code-style = ["pre-commit (>=3.0,<4.0)"] -linkify = ["linkify-it-py (>=2.0,<3.0)"] -rtd = ["ipython", "pydata-sphinx-theme (==v0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.8.2,<0.9.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] -testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=7,<8)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx-pytest"] -testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4,<0.4.0)"] - -[[package]] -name = "packaging" -version = "23.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, -] - -[[package]] -name = "pluggy" -version = "1.3.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pygments" -version = "2.17.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, - {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, -] - -[package.extras] -plugins = ["importlib-metadata"] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pytest" -version = "7.4.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-cov" -version = "4.1.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, -] - -[package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] - -[[package]] -name = "pytest-mock" -version = "3.12.0" -description = "Thin-wrapper around the mock package for easier use with pytest" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"}, - {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"}, -] - -[package.dependencies] -pytest = ">=5.0" - -[package.extras] -dev = ["pre-commit", "pytest-asyncio", "tox"] - -[[package]] -name = "pytest-rerunfailures" -version = "13.0" -description = "pytest plugin to re-run tests to eliminate flaky failures" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-rerunfailures-13.0.tar.gz", hash = "sha256:e132dbe420bc476f544b96e7036edd0a69707574209b6677263c950d19b09199"}, - {file = "pytest_rerunfailures-13.0-py3-none-any.whl", hash = "sha256:34919cb3fcb1f8e5d4b940aa75ccdea9661bade925091873b7c6fa5548333069"}, -] - -[package.dependencies] -packaging = ">=17.1" -pytest = ">=7" - -[[package]] -name = "pytest-watcher" -version = "0.3.4" -description = "Automatically rerun your tests on file modifications" -optional = false -python-versions = ">=3.7.0,<4.0.0" -files = [ - {file = "pytest_watcher-0.3.4-py3-none-any.whl", hash = "sha256:edd2bd9c8a1fb14d48c9f4947234065eb9b4c1acedc0bf213b1f12501dfcffd3"}, - {file = "pytest_watcher-0.3.4.tar.gz", hash = "sha256:d39491ba15b589221bb9a78ef4bed3d5d1503aed08209b1a138aeb95b9117a18"}, -] - -[package.dependencies] -tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""} -watchdog = ">=2.0.0" - -[[package]] -name = "pytz" -version = "2023.3.post1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, - {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.1" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, -] - -[[package]] -name = "requests" -version = "2.31.0" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.7" -files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "ruff" -version = "0.1.14" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -files = [ - {file = "ruff-0.1.14-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:96f76536df9b26622755c12ed8680f159817be2f725c17ed9305b472a757cdbb"}, - {file = "ruff-0.1.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab3f71f64498c7241123bb5a768544cf42821d2a537f894b22457a543d3ca7a9"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7060156ecc572b8f984fd20fd8b0fcb692dd5d837b7606e968334ab7ff0090ab"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a53d8e35313d7b67eb3db15a66c08434809107659226a90dcd7acb2afa55faea"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bea9be712b8f5b4ebed40e1949379cfb2a7d907f42921cf9ab3aae07e6fba9eb"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2270504d629a0b064247983cbc495bed277f372fb9eaba41e5cf51f7ba705a6a"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80258bb3b8909b1700610dfabef7876423eed1bc930fe177c71c414921898efa"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:653230dd00aaf449eb5ff25d10a6e03bc3006813e2cb99799e568f55482e5cae"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b3acc6c4e6928459ba9eb7459dd4f0c4bf266a053c863d72a44c33246bfdbf"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b3dadc9522d0eccc060699a9816e8127b27addbb4697fc0c08611e4e6aeb8b5"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1c8eca1a47b4150dc0fbec7fe68fc91c695aed798532a18dbb1424e61e9b721f"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:62ce2ae46303ee896fc6811f63d6dabf8d9c389da0f3e3f2bce8bc7f15ef5488"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b2027dde79d217b211d725fc833e8965dc90a16d0d3213f1298f97465956661b"}, - {file = "ruff-0.1.14-py3-none-win32.whl", hash = "sha256:722bafc299145575a63bbd6b5069cb643eaa62546a5b6398f82b3e4403329cab"}, - {file = "ruff-0.1.14-py3-none-win_amd64.whl", hash = "sha256:e3d241aa61f92b0805a7082bd89a9990826448e4d0398f0e2bc8f05c75c63d99"}, - {file = "ruff-0.1.14-py3-none-win_arm64.whl", hash = "sha256:269302b31ade4cde6cf6f9dd58ea593773a37ed3f7b97e793c8594b262466b67"}, - {file = "ruff-0.1.14.tar.gz", hash = "sha256:ad3f8088b2dfd884820289a06ab718cde7d38b94972212cc4ba90d5fbc9955f3"}, -] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -optional = false -python-versions = "*" -files = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] - -[[package]] -name = "soupsieve" -version = "2.5" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.8" -files = [ - {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, - {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, -] - -[[package]] -name = "sphinx" -version = "7.1.2" -description = "Python documentation generator" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"}, - {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"}, -] - -[package.dependencies] -alabaster = ">=0.7,<0.8" -babel = ">=2.9" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.18.1,<0.21" -imagesize = ">=1.3" -importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} -Jinja2 = ">=3.0" -packaging = ">=21.0" -Pygments = ">=2.13" -requests = ">=2.25.0" -snowballstemmer = ">=2.0" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = ">=2.0.0" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = ">=1.1.5" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"] -test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"] - -[[package]] -name = "sphinx-autobuild" -version = "2021.3.14" -description = "Rebuild Sphinx documentation on changes, with live-reload in the browser." -optional = false -python-versions = ">=3.6" -files = [ - {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"}, - {file = "sphinx_autobuild-2021.3.14-py3-none-any.whl", hash = "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac"}, -] - -[package.dependencies] -colorama = "*" -livereload = "*" -sphinx = "*" - -[package.extras] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "sphinx-autodoc-typehints" -version = "1.25.2" -description = "Type hints (PEP 484) support for the Sphinx autodoc extension" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sphinx_autodoc_typehints-1.25.2-py3-none-any.whl", hash = "sha256:5ed05017d23ad4b937eab3bee9fae9ab0dd63f0b42aa360031f1fad47e47f673"}, - {file = "sphinx_autodoc_typehints-1.25.2.tar.gz", hash = "sha256:3cabc2537e17989b2f92e64a399425c4c8bf561ed73f087bc7414a5003616a50"}, -] - -[package.dependencies] -sphinx = ">=7.1.2" - -[package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)"] -numpy = ["nptyping (>=2.5)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "sphobjinv (>=2.3.1)", "typing-extensions (>=4.7.1)"] - -[[package]] -name = "sphinx-basic-ng" -version = "1.0.0b2" -description = "A modern skeleton for Sphinx themes." -optional = false -python-versions = ">=3.7" -files = [ - {file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"}, - {file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"}, -] - -[package.dependencies] -sphinx = ">=4.0" - -[package.extras] -docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"] - -[[package]] -name = "sphinx-copybutton" -version = "0.5.2" -description = "Add a copy button to each of your code cells." -optional = false -python-versions = ">=3.7" -files = [ - {file = "sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd"}, - {file = "sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e"}, -] - -[package.dependencies] -sphinx = ">=1.8" - -[package.extras] -code-style = ["pre-commit (==2.12.1)"] -rtd = ["ipython", "myst-nb", "sphinx", "sphinx-book-theme", "sphinx-examples"] - -[[package]] -name = "sphinx-inline-tabs" -version = "2023.4.21" -description = "Add inline tabbed content to your Sphinx documentation." -optional = false -python-versions = ">=3.8" -files = [ - {file = "sphinx_inline_tabs-2023.4.21-py3-none-any.whl", hash = "sha256:06809ac613f7c48ddd6e2fa588413e3fe92cff2397b56e2ccf0b0218f9ef6a78"}, - {file = "sphinx_inline_tabs-2023.4.21.tar.gz", hash = "sha256:5df2f13f602c158f3f5f6c509e008aeada199a8c76d97ba3aa2822206683bebc"}, -] - -[package.dependencies] -sphinx = ">=3" - -[package.extras] -doc = ["furo", "myst-parser"] -test = ["pytest", "pytest-cov", "pytest-xdist"] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "1.0.4" -description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, - {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "1.0.2" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -optional = false -python-versions = ">=3.5" -files = [ - {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"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.0.1" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, - {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["html5lib", "pytest"] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -description = "A sphinx extension which renders display math in HTML via JavaScript" -optional = false -python-versions = ">=3.5" -files = [ - {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"}, -] - -[package.extras] -test = ["flake8", "mypy", "pytest"] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "1.0.3" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -optional = false -python-versions = ">=3.5" -files = [ - {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"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -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)." -optional = false -python-versions = ">=3.5" -files = [ - {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"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxext-opengraph" -version = "0.7.5" -description = "Sphinx Extension to enable OGP support" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sphinxext-opengraph-0.7.5.tar.gz", hash = "sha256:caf061fb3bea8d8f2228f7a1d55cb8f6809f2b5c806bf3600e21ce1a3cf906d1"}, - {file = "sphinxext_opengraph-0.7.5-py3-none-any.whl", hash = "sha256:d7fcf48b5d6477292492c0c7da6ddc469e3657159952e2a0a0df074f54aad99e"}, -] - -[package.dependencies] -sphinx = ">=4.0" - -[[package]] -name = "sphinxext-rediraffe" -version = "0.2.7" -description = "Sphinx Extension that redirects non-existent pages to working pages." -optional = false -python-versions = ">=3.6" -files = [ - {file = "sphinxext-rediraffe-0.2.7.tar.gz", hash = "sha256:651dcbfae5ffda9ffd534dfb8025f36120e5efb6ea1a33f5420023862b9f725d"}, - {file = "sphinxext_rediraffe-0.2.7-py3-none-any.whl", hash = "sha256:9e430a52d4403847f4ffb3a8dd6dfc34a9fe43525305131f52ed899743a5fd8c"}, -] - -[package.dependencies] -sphinx = ">=2.0" - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "tornado" -version = "6.4" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -optional = false -python-versions = ">= 3.8" -files = [ - {file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"}, - {file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"}, - {file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"}, - {file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"}, - {file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"}, -] - -[[package]] -name = "typing-extensions" -version = "4.9.0" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, -] - -[[package]] -name = "urllib3" -version = "2.1.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "watchdog" -version = "3.0.0" -description = "Filesystem events monitoring" -optional = false -python-versions = ">=3.7" -files = [ - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, - {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, - {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, - {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, - {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, - {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, - {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, - {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, - {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, -] - -[package.extras] -watchmedo = ["PyYAML (>=3.10)"] - -[[package]] -name = "zipp" -version = "3.17.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, - {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.8" -content-hash = "1bd8bf7d9202bea15325eb94e8a9038bb0fb711590fccfdcfe9afa3ae64068cf" diff --git a/poetry.toml b/poetry.toml deleted file mode 100644 index d11b90b04..000000000 --- a/poetry.toml +++ /dev/null @@ -1,2 +0,0 @@ -[virtualenvs] -prefer-active-python = true diff --git a/pyproject.toml b/pyproject.toml index 4d110561a..4f9a7d412 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,12 @@ -[tool.poetry] +[project] name = "libtmux" -version = "0.25.0" +version = "0.53.0" description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer." -license = "MIT" -authors = ["Tony Narlock "] +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", @@ -13,11 +16,11 @@ classifiers = [ "Framework :: Pytest", "Intended Audience :: Developers", "Programming Language :: Python", - "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 :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities", @@ -33,59 +36,92 @@ packages = [ ] include = [ { path = "CHANGES", format = "sdist" }, + { path = "MIGRATION", format = "sdist" }, { path = ".tmuxp.yaml", format = "sdist" }, { path = "tests", format = "sdist" }, { path = "docs", format = "sdist" }, { path = "conftest.py", format = "sdist" }, ] -[tool.poetry.urls] +[project.urls] "Bug Tracker" = "https://github.com/tmux-python/libtmux/issues" Documentation = "https://libtmux.git-pull.com/" Repository = "https://github.com/tmux-python/libtmux" Changes = "https://github.com/tmux-python/libtmux/blob/master/CHANGES" -[tool.poetry.dependencies] -python = "^3.8" - -[tool.poetry.group.docs.dependencies] -### Docs ### -sphinx = "*" -furo = "*" -gp-libs = "*" -sphinx-autobuild = "*" -sphinx-autodoc-typehints = "*" -sphinx-inline-tabs = "*" -sphinxext-opengraph = "<0.8" # https://github.com/wpilibsuite/sphinxext-opengraph/issues/100 -sphinx-copybutton = "*" -sphinxext-rediraffe = "*" -myst_parser = ">=0.18.1" -docutils = "*" - -[tool.poetry.group.test.dependencies] -### Testing ### -pytest = "*" -pytest-rerunfailures = "*" -pytest-mock = "*" -pytest-watcher = "*" -gp-libs = "~0.0.4" - -[tool.poetry.group.coverage.dependencies] -### Coverage ### -codecov = "*" -coverage = "*" -pytest-cov = "*" - -[tool.poetry.group.lint.dependencies] -### Lint ### -ruff = "*" -mypy = "*" - -[tool.poetry.plugins.pytest11] +[dependency-groups] +dev = [ + # Docs + "sphinx", + "furo", + "gp-libs", + "sphinx-autobuild", + "sphinx-autodoc-typehints", + "sphinx-inline-tabs", + "sphinxext-opengraph", + "sphinx-copybutton", + "sphinxext-rediraffe", + "myst-parser", + "linkify-it-py", + # Testing + "typing-extensions; python_version < '3.11'", + "gp-libs", + "pytest", + "pytest-rerunfailures", + "pytest-mock", + "pytest-watcher", + "pytest-xdist", + # Coverage + "codecov", + "coverage", + "pytest-cov", + # Lint + "ruff", + "mypy", +] + +docs = [ + "sphinx", + "furo", + "gp-libs", + "sphinx-autobuild", + "sphinx-autodoc-typehints", + "sphinx-inline-tabs", + "sphinxext-opengraph", + "sphinx-copybutton", + "sphinxext-rediraffe", + "myst-parser", + "linkify-it-py", +] +testing = [ + "typing-extensions; python_version < '3.11'", + "gp-libs", + "pytest", + "pytest-rerunfailures", + "pytest-mock", + "pytest-watcher", +] +coverage =[ + "codecov", + "coverage", + "pytest-cov", +] +lint = [ + "typing-extensions; python_version < '3.11'", + "ruff", + "mypy", +] + +[project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [tool.mypy] strict = true +python_version = "3.10" files = [ "src", "tests", @@ -97,6 +133,8 @@ parallel = true omit = [ "*/_compat.py", "docs/conf.py", + "tests/test_*.py", + "tests/*/test_*.py", ] [tool.coverage.report] @@ -111,17 +149,33 @@ exclude_lines = [ "if TYPE_CHECKING:", "if t.TYPE_CHECKING:", "@overload( |$)", + 'class .*\bProtocol\):', + "from __future__ import annotations", + "import typing as t", + "^\\s*\\.\\.\\.$", + "^\\s*pass$", + "^\\s*assert ", + "^\\s*logger\\s*=", + "^import ", + "^from .* import", + ": TypeAlias = ", + "= t\\.TypeVar\\(", ] [tool.ruff] -target-version = "py38" +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 @@ -129,23 +183,52 @@ select = [ "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.isort] +[tool.ruff.lint.isort] known-first-party = [ "libtmux", ] combine-as-imports = true +required-imports = [ + "from __future__ import annotations", +] + +[tool.ruff.lint.flake8-builtins] +builtins-allowed-modules = [ + "dataclasses", + "random", + "types", +] -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] convention = "numpy" -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "*/__init__.py" = ["F401"] [tool.pytest.ini_options] -addopts = "--tb=short --no-header --showlocals --doctest-docutils-modules --reruns 2 -p no:doctest" -doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE" +addopts = [ + "--tb=short", + "--no-header", + "--showlocals", + "--doctest-docutils-modules", + "-p no:doctest", + "--reruns=2" +] +doctest_optionflags = [ + "ELLIPSIS", + "NORMALIZE_WHITESPACE" +] testpaths = [ "src/libtmux", "tests", @@ -154,8 +237,6 @@ testpaths = [ ] filterwarnings = [ "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", + "ignore::DeprecationWarning:libtmux.*:", + "ignore::DeprecationWarning:tests:", # tests/ ] - -[build-system] -requires = ["poetry_core>=1.0.0"] -build-backend = "poetry.core.masonry.api" diff --git a/src/libtmux/__about__.py b/src/libtmux/__about__.py index c169c7daf..a71e91fc6 100644 --- a/src/libtmux/__about__.py +++ b/src/libtmux/__about__.py @@ -1,7 +1,10 @@ """Metadata package for libtmux.""" + +from __future__ import annotations + __title__ = "libtmux" __package_name__ = "libtmux" -__version__ = "0.25.0" +__version__ = "0.53.0" __description__ = "Typed scripting library / ORM / API wrapper for tmux" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/src/libtmux/__init__.py b/src/libtmux/__init__.py index 4c2847c93..9e3bb7693 100644 --- a/src/libtmux/__init__.py +++ b/src/libtmux/__init__.py @@ -1,4 +1,7 @@ -# flake8: NOQA +"""libtmux, a typed, pythonic API wrapper for the tmux terminal multiplexer.""" + +from __future__ import annotations + from .__about__ import ( __author__, __copyright__, @@ -13,3 +16,18 @@ from .server import Server from .session import Session from .window import Window + +__all__ = ( + "Pane", + "Server", + "Session", + "Window", + "__author__", + "__copyright__", + "__description__", + "__email__", + "__license__", + "__package_name__", + "__title__", + "__version__", +) diff --git a/src/libtmux/_compat.py b/src/libtmux/_compat.py index 5857255c9..acf5ddbe6 100644 --- a/src/libtmux/_compat.py +++ b/src/libtmux/_compat.py @@ -4,15 +4,7 @@ import types import typing as t -console_encoding = sys.__stdout__.encoding - - -def console_to_str(s: bytes) -> str: - """From pypa/pip project, pip.backwardwardcompat. License MIT.""" - try: - return s.decode(console_encoding, "ignore") - except UnicodeDecodeError: - return s.decode("utf_8", "ignore") +console_encoding = sys.stdout.encoding # TODO Consider removing, reraise does not seem to be called anywhere @@ -26,13 +18,6 @@ def reraise( raise value -def str_from_console(s: t.Union[str, bytes]) -> str: - try: - return str(s) - except UnicodeDecodeError: - return str(s, encoding="utf_8") if isinstance(s, bytes) else s - - import re from typing import Iterator, List, Tuple diff --git a/src/libtmux/_internal/constants.py b/src/libtmux/_internal/constants.py new file mode 100644 index 000000000..d344c9cd9 --- /dev/null +++ b/src/libtmux/_internal/constants.py @@ -0,0 +1,599 @@ +"""Internal constants.""" + +from __future__ import annotations + +import io +import logging +import typing as t +from dataclasses import dataclass, field + +from libtmux._internal.dataclasses import SkipDefaultFieldsReprMixin +from libtmux._internal.sparse_array import SparseArray, is_sparse_array_list + +if t.TYPE_CHECKING: + from typing import TypeAlias + + +T = t.TypeVar("T") + +TerminalFeatures = dict[str, list[str]] +HookArray: TypeAlias = "dict[str, SparseArray[str]]" + +logger = logging.getLogger(__name__) + + +@dataclass(repr=False) +class ServerOptions( + SkipDefaultFieldsReprMixin, +): + """Container for tmux server options.""" + + backspace: str | None = field(default=None) + buffer_limit: int | None = field(default=None) + command_alias: SparseArray[str] = field(default_factory=SparseArray) + default_terminal: str | None = field(default=None) + copy_command: str | None = field(default=None) + escape_time: int | None = field(default=None) + editor: str | None = field(default=None) + exit_empty: t.Literal["on", "off"] | None = field(default=None) + exit_unattached: t.Literal["on", "off"] | None = field(default=None) + extended_keys: t.Literal["on", "off", "always"] | None = field(default=None) + focus_events: t.Literal["on", "off"] | None = field(default=None) + history_file: str | None = field(default=None) + message_limit: int | None = field(default=None) + prompt_history_limit: int | None = field(default=None) + set_clipboard: t.Literal["on", "external", "off"] | None = field(default=None) + terminal_features: TerminalFeatures = field(default_factory=dict) + terminal_overrides: SparseArray[str] = field(default_factory=SparseArray) + user_keys: SparseArray[str] = field(default_factory=SparseArray) + # tmux 3.5+ options + default_client_command: str | None = field(default=None) + extended_keys_format: t.Literal["csi-u", "xterm"] | None = field(default=None) + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + setattr(self, key_underscored, value) + + +@dataclass(repr=False) +class SessionOptions( + SkipDefaultFieldsReprMixin, +): + """Container for tmux session options.""" + + activity_action: t.Literal["any", "none", "current", "other"] | None = field( + default=None, + ) + assume_paste_time: int | None = field(default=None) + base_index: int | None = field(default=None) + bell_action: t.Literal["any", "none", "current", "other"] | None = field( + default=None, + ) + default_command: str | None = field(default=None) + default_shell: str | None = field(default=None) + default_size: str | None = field(default=None) # Format "XxY" + destroy_unattached: t.Literal["on", "off"] | None = field(default=None) + detach_on_destroy: ( + t.Literal["off", "on", "no-detached", "previous", "next"] | None + ) = field(default=None) + display_panes_active_colour: str | None = field(default=None) + display_panes_colour: str | None = field(default=None) + display_panes_time: int | None = field(default=None) + display_time: int | None = field(default=None) + history_limit: int | None = field(default=None) + key_table: str | None = field(default=None) + lock_after_time: int | None = field(default=None) + lock_command: str | None = field(default=None) + menu_style: str | None = field(default=None) + menu_selected_style: str | None = field(default=None) + menu_border_style: str | None = field(default=None) + menu_border_lines: ( + t.Literal["single", "rounded", "double", "heavy", "simple", "padded", "none"] + | None + ) = field(default=None) + message_command_style: str | None = field(default=None) + message_line: int | None = field(default=None) + message_style: str | None = field(default=None) + mouse: t.Literal["on", "off"] | None = field(default=None) + prefix: str | None = field(default=None) + prefix2: str | None = field(default=None) + renumber_windows: t.Literal["on", "off"] | None = field(default=None) + repeat_time: int | None = field(default=None) + set_titles: t.Literal["on", "off"] | None = field(default=None) + set_titles_string: str | None = field(default=None) + silence_action: t.Literal["any", "none", "current", "other"] | None = field( + default=None, + ) + status: t.Literal["off", "on"] | int | None = field(default=None) + status_format: list[str] | None = field(default=None) + status_interval: int | None = field(default=None) + status_justify: t.Literal["left", "centre", "right", "absolute-centre"] | None = ( + field(default=None) + ) + status_keys: t.Literal["vi", "emacs"] | None = field(default=None) + status_left: str | None = field(default=None) + status_left_length: int | None = field(default=None) + status_left_style: str | None = field(default=None) + status_position: t.Literal["top", "bottom"] | None = field(default=None) + status_right: str | None = field(default=None) + status_right_length: int | None = field(default=None) + status_right_style: str | None = field(default=None) + status_style: str | None = field(default=None) + update_environment: SparseArray[str] = field(default_factory=SparseArray) + visual_activity: t.Literal["on", "off", "both"] | None = field(default=None) + visual_bell: t.Literal["on", "off", "both"] | None = field(default=None) + visual_silence: t.Literal["on", "off", "both"] | None = field(default=None) + word_separators: str | None = field(default=None) + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + setattr(self, key_underscored, value) + + +@dataclass(repr=False) +class WindowOptions( + SkipDefaultFieldsReprMixin, +): + """Container for tmux window options.""" + + aggressive_resize: t.Literal["on", "off"] | None = field(default=None) + automatic_rename: t.Literal["on", "off"] | None = field(default=None) + automatic_rename_format: str | None = field(default=None) + clock_mode_colour: str | None = field(default=None) + clock_mode_style: t.Literal["12", "24"] | None = field(default=None) + fill_character: str | None = field(default=None) + main_pane_height: int | str | None = field(default=None) + main_pane_width: int | str | None = field(default=None) + copy_mode_match_style: str | None = field(default=None) + copy_mode_mark_style: str | None = field(default=None) + copy_mode_current_match_style: str | None = field(default=None) + mode_keys: t.Literal["vi", "emacs"] | None = field(default=None) + mode_style: str | None = field(default=None) + monitor_activity: t.Literal["on", "off"] | None = field(default=None) + monitor_bell: t.Literal["on", "off"] | None = field(default=None) + monitor_silence: int | None = field(default=None) # Assuming seconds as int + other_pane_height: int | str | None = field(default=None) + other_pane_width: int | str | None = field(default=None) + pane_active_border_style: str | None = field(default=None) + pane_base_index: int | None = field(default=None) + pane_border_format: str | None = field(default=None) + pane_border_indicators: t.Literal["off", "colour", "arrows", "both"] | None = field( + default=None, + ) + pane_border_lines: ( + t.Literal["single", "double", "heavy", "simple", "number"] | None + ) = field(default=None) + pane_border_status: t.Literal["off", "top", "bottom"] | None = field( + default=None, + ) + pane_border_style: str | None = field(default=None) + popup_style: str | None = field(default=None) + popup_border_style: str | None = field(default=None) + popup_border_lines: ( + t.Literal["single", "rounded", "double", "heavy", "simple", "padded", "none"] + | None + ) = field(default=None) + window_status_activity_style: str | None = field(default=None) + window_status_bell_style: str | None = field(default=None) + window_status_current_format: str | None = field(default=None) + window_status_current_style: str | None = field(default=None) + window_status_format: str | None = field(default=None) + window_status_last_style: str | None = field(default=None) + window_status_separator: str | None = field(default=None) + window_status_style: str | None = field(default=None) + window_size: t.Literal["largest", "smallest", "manual", "latest"] | None = field( + default=None, + ) + wrap_search: t.Literal["on", "off"] | None = field(default=None) + # tmux 3.5+ options + tiled_layout_max_columns: int | None = field(default=None) + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + setattr(self, key_underscored, value) + + +@dataclass(repr=False) +class PaneOptions( + SkipDefaultFieldsReprMixin, +): + """Container for tmux pane options.""" + + allow_passthrough: t.Literal["on", "off", "all"] | None = field(default=None) + allow_rename: t.Literal["on", "off"] | None = field(default=None) + alternate_screen: t.Literal["on", "off"] | None = field(default=None) + cursor_colour: str | None = field(default=None) + pane_colours: list[str] | None = field(default=None) + cursor_style: ( + t.Literal[ + "default", + "blinking-block", + "block", + "blinking-underline", + "underline", + "blinking-bar", + "bar", + ] + | None + ) = field(default=None) + remain_on_exit: t.Literal["on", "off", "failed"] | None = field(default=None) + remain_on_exit_format: str | None = field(default=None) + scroll_on_clear: t.Literal["on", "off"] | None = field(default=None) + synchronize_panes: t.Literal["on", "off"] | None = field(default=None) + window_active_style: str | None = field(default=None) + window_style: str | None = field(default=None) + # tmux 3.5+ options + pane_scrollbars: t.Literal["off", "modal", "on"] | None = field(default=None) + pane_scrollbars_style: str | None = field(default=None) + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + setattr(self, key_underscored, value) + + +@dataclass(repr=False) +class Options( + ServerOptions, + SessionOptions, + WindowOptions, + PaneOptions, + SkipDefaultFieldsReprMixin, +): + """Container for all tmux options (server, session, window, and pane).""" + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + # Remove asaterisk from inherited options + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + key_asterisk_removed = key_underscored.rstrip("*") + setattr(self, key_asterisk_removed, value) + + +@dataclass(repr=False) +class Hooks( + SkipDefaultFieldsReprMixin, +): + """tmux hooks data structure. + + Parses tmux hook output into typed :class:`SparseArray` fields, preserving + array indices for hooks that can have multiple commands at different indices. + + Examples + -------- + Parse raw tmux hook output: + + >>> from libtmux._internal.constants import Hooks + + >>> raw = [ + ... "session-renamed[0] set-option -g status-left-style bg=red", + ... "session-renamed[1] display-message 'session renamed'", + ... ] + >>> hooks = Hooks.from_stdout(raw) + + Access individual hook commands by index: + + >>> hooks.session_renamed[0] + 'set-option -g status-left-style bg=red' + >>> hooks.session_renamed[1] + "display-message 'session renamed'" + + Get all commands as a list (sorted by index): + + >>> hooks.session_renamed.as_list() + ['set-option -g status-left-style bg=red', "display-message 'session renamed'"] + + Sparse indices are preserved (gaps in index numbers): + + >>> raw_sparse = [ + ... "pane-focus-in[0] refresh-client", + ... "pane-focus-in[5] display-message 'focus'", + ... ] + >>> hooks_sparse = Hooks.from_stdout(raw_sparse) + >>> 0 in hooks_sparse.pane_focus_in + True + >>> 5 in hooks_sparse.pane_focus_in + True + >>> 3 in hooks_sparse.pane_focus_in + False + >>> sorted(hooks_sparse.pane_focus_in.keys()) + [0, 5] + + Iterate over values in index order: + + >>> for cmd in hooks_sparse.pane_focus_in.iter_values(): + ... print(cmd) + refresh-client + display-message 'focus' + + Multiple hook types in one parse: + + >>> raw_multi = [ + ... "after-new-window[0] select-pane -t 0", + ... "after-new-window[1] send-keys 'clear' Enter", + ... "window-renamed[0] refresh-client -S", + ... ] + >>> hooks_multi = Hooks.from_stdout(raw_multi) + >>> len(hooks_multi.after_new_window) + 2 + >>> len(hooks_multi.window_renamed) + 1 + """ + + # --- Tmux normal hooks --- + # Run when a window has activity. See monitor-activity. + alert_activity: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window has received a bell. See monitor-bell. + alert_bell: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window has been silent. See monitor-silence. + alert_silence: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client becomes the latest active client of its session. + client_active: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is attached. + client_attached: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is detached. + client_detached: SparseArray[str] = field(default_factory=SparseArray) + # Run when focus enters a client. + client_focus_in: SparseArray[str] = field(default_factory=SparseArray) + # Run when focus exits a client. + client_focus_out: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is resized. + client_resized: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client's attached session is changed. + client_session_changed: SparseArray[str] = field(default_factory=SparseArray) + # Run when the program running in a pane exits, but remain-on-exit is on so the pane + # has not closed. + pane_died: SparseArray[str] = field(default_factory=SparseArray) + # Run when the program running in a pane exits. + pane_exited: SparseArray[str] = field(default_factory=SparseArray) + # Run when the focus enters a pane, if the focus-events option is on. + pane_focus_in: SparseArray[str] = field(default_factory=SparseArray) + # Run when the focus exits a pane, if the focus-events option is on. + pane_focus_out: SparseArray[str] = field(default_factory=SparseArray) + # Run when the terminal clipboard is set using the xterm(1) escape sequence. + pane_set_clipboard: SparseArray[str] = field(default_factory=SparseArray) + # Run when a new session created. + session_created: SparseArray[str] = field(default_factory=SparseArray) + # Run when a session closed. + session_closed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a session is renamed. + session_renamed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window is linked into a session. + window_linked: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window is renamed. + window_renamed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window is resized. This may be after the client-resized hook is run. + window_resized: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window is unlinked from a session. + window_unlinked: SparseArray[str] = field(default_factory=SparseArray) + # Run when a pane title changes (tmux 3.5+) + pane_title_changed: SparseArray[str] = field(default_factory=SparseArray) + # Run when terminal reports a light theme (tmux 3.5+) + client_light_theme: SparseArray[str] = field(default_factory=SparseArray) + # Run when terminal reports a dark theme (tmux 3.5+) + client_dark_theme: SparseArray[str] = field(default_factory=SparseArray) + + # --- Tmux control mode hooks --- + # The client has detached. + client_detached_control: SparseArray[str] = field(default_factory=SparseArray) + # The client is now attached to the session with ID session-id, which is named name. + client_session_changed_control: SparseArray[str] = field( + default_factory=SparseArray, + ) + # An error has happened in a configuration file. + config_error: SparseArray[str] = field(default_factory=SparseArray) + # The pane has been continued after being paused (if the pause-after flag is set, + # see refresh-client -A). + continue_control: SparseArray[str] = field(default_factory=SparseArray) + # The tmux client is exiting immediately, either because it is not attached to any + # session or an error occurred. + exit_control: SparseArray[str] = field(default_factory=SparseArray) + # New form of %output sent when the pause-after flag is set. + extended_output: SparseArray[str] = field(default_factory=SparseArray) + # The layout of a window with ID window-id changed. + layout_change: SparseArray[str] = field(default_factory=SparseArray) + # A message sent with the display-message command. + message_control: SparseArray[str] = field(default_factory=SparseArray) + # A window pane produced output. + output: SparseArray[str] = field(default_factory=SparseArray) + # The pane with ID pane-id has changed mode. + pane_mode_changed: SparseArray[str] = field(default_factory=SparseArray) + # Paste buffer name has been changed. + paste_buffer_changed: SparseArray[str] = field(default_factory=SparseArray) + # Paste buffer name has been deleted. + paste_buffer_deleted: SparseArray[str] = field(default_factory=SparseArray) + # The pane has been paused (if the pause-after flag is set). + pause_control: SparseArray[str] = field(default_factory=SparseArray) + # The client is now attached to the session with ID session-id, which is named name. + session_changed_control: SparseArray[str] = field(default_factory=SparseArray) + # The current session was renamed to name. + session_renamed_control: SparseArray[str] = field(default_factory=SparseArray) + # The session with ID session-id changed its active window to the window with ID + # window-id. + session_window_changed: SparseArray[str] = field(default_factory=SparseArray) + # A session was created or destroyed. + sessions_changed: SparseArray[str] = field(default_factory=SparseArray) + # The value of the format associated with subscription name has changed to value. + subscription_changed: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id was created but is not linked to the current session. + unlinked_window_add: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id, which is not linked to the current session, was + # closed. + unlinked_window_close: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id, which is not linked to the current session, was + # renamed. + unlinked_window_renamed: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id was linked to the current session. + window_add: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id closed. + window_close: SparseArray[str] = field(default_factory=SparseArray) + # The layout of a window with ID window-id changed. The new layout is window-layout. + # The window's visible layout is window-visible-layout and the window flags are + # window-flags. + window_layout_changed: SparseArray[str] = field(default_factory=SparseArray) + # The active pane in the window with ID window-id changed to the pane with ID + # pane-id. + window_pane_changed: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id was renamed to name. + window_renamed_control: SparseArray[str] = field(default_factory=SparseArray) + + # --- After hooks - Run after specific tmux commands complete --- + # Runs after 'bind-key' completes + after_bind_key: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'capture-pane' completes + after_capture_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'copy-mode' completes + after_copy_mode: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'display-message' completes + after_display_message: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'display-panes' completes + after_display_panes: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'kill-pane' completes + after_kill_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-buffers' completes + after_list_buffers: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-clients' completes + after_list_clients: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-keys' completes + after_list_keys: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-panes' completes + after_list_panes: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-sessions' completes + after_list_sessions: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-windows' completes + after_list_windows: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'load-buffer' completes + after_load_buffer: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'lock-server' completes + after_lock_server: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'new-session' completes + after_new_session: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'new-window' completes + after_new_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'paste-buffer' completes + after_paste_buffer: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'pipe-pane' completes + after_pipe_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'queue' command is processed + after_queue: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'refresh-client' completes + after_refresh_client: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'rename-session' completes + after_rename_session: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'rename-window' completes + after_rename_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'resize-pane' completes + after_resize_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'resize-window' completes + after_resize_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'save-buffer' completes + after_save_buffer: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'select-layout' completes + after_select_layout: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'select-pane' completes + after_select_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'select-window' completes + after_select_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'send-keys' completes + after_send_keys: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'set-buffer' completes + after_set_buffer: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'set-environment' completes + after_set_environment: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'set-hook' completes + after_set_hook: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'set-option' completes + after_set_option: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'show-environment' completes + after_show_environment: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'show-messages' completes + after_show_messages: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'show-options' completes + after_show_options: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'split-window' completes + after_split_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'unbind-key' completes + after_unbind_key: SparseArray[str] = field(default_factory=SparseArray) + # Runs when a command fails (tmux 3.5+) + command_error: SparseArray[str] = field(default_factory=SparseArray) + + @classmethod + def from_stdout(cls, value: list[str]) -> Hooks: + """Parse raw tmux hook output into a Hooks instance. + + The parsing pipeline: + + 1. ``parse_options_to_dict()`` - Parse "key value" lines into dict + 2. ``explode_arrays(force_array=True)`` - Extract array indices into SparseArray + 3. ``explode_complex()`` - Handle complex option types + 4. Rename keys: ``session-renamed`` → ``session_renamed`` + + Parameters + ---------- + value : list[str] + Raw tmux output lines from ``show-hooks`` command. + + Returns + ------- + Hooks + Parsed hooks with SparseArray fields for each hook type. + + Examples + -------- + Basic parsing: + + >>> from libtmux._internal.constants import Hooks + + >>> raw = ["session-renamed[0] display-message 'renamed'"] + >>> hooks = Hooks.from_stdout(raw) + >>> hooks.session_renamed[0] + "display-message 'renamed'" + + The pipeline preserves sparse indices: + + >>> raw = [ + ... "after-select-window[0] refresh-client", + ... "after-select-window[10] display-message 'selected'", + ... ] + >>> hooks = Hooks.from_stdout(raw) + >>> sorted(hooks.after_select_window.keys()) + [0, 10] + + Empty input returns empty SparseArrays: + + >>> hooks_empty = Hooks.from_stdout([]) + >>> len(hooks_empty.session_renamed) + 0 + >>> hooks_empty.session_renamed.as_list() + [] + """ + from libtmux.options import ( + explode_arrays, + explode_complex, + parse_options_to_dict, + ) + + output_exploded = explode_complex( + explode_arrays( + parse_options_to_dict( + io.StringIO("\n".join(value)), + ), + force_array=True, + ), + ) + + assert is_sparse_array_list(output_exploded) + + output_renamed: HookArray = { + k.lstrip("%").replace("-", "_"): v for k, v in output_exploded.items() + } + + return cls(**output_renamed) diff --git a/src/libtmux/_internal/dataclasses.py b/src/libtmux/_internal/dataclasses.py index 1fa53f18e..19b4a2d5f 100644 --- a/src/libtmux/_internal/dataclasses.py +++ b/src/libtmux/_internal/dataclasses.py @@ -4,6 +4,9 @@ ---- This is an internal API not covered by versioning policy. """ + +from __future__ import annotations + import dataclasses import typing as t from operator import attrgetter @@ -77,7 +80,7 @@ class SkipDefaultFieldsReprMixin: ItemWithMixin(name=Test, unit_price=2.05) """ - def __repr__(self: "DataclassInstance") -> str: + def __repr__(self: DataclassInstance) -> str: """Omit default fields in object representation.""" nodef_f_vals = ( (f.name, attrgetter(f.name)(self)) diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 1113a0151..01dd6afef 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -4,10 +4,16 @@ ---- This is an internal API not covered by versioning policy. """ + +from __future__ import annotations + +import logging import re import traceback import typing as t -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence + +logger = logging.getLogger(__name__) if t.TYPE_CHECKING: @@ -16,37 +22,79 @@ class LookupProtocol(t.Protocol): def __call__( self, - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: """Return callback for :class:`QueryList` filtering operators.""" ... -T = t.TypeVar("T", t.Any, t.Any) +T = t.TypeVar("T") no_arg = object() class MultipleObjectsReturned(Exception): - """The requested object does not exist.""" + """The query returned multiple objects when only one was expected.""" class ObjectDoesNotExist(Exception): - """The query returned multiple objects when only one was expected.""" + """The requested object does not exist.""" def keygetter( - obj: "Mapping[str, t.Any]", + obj: Mapping[str, t.Any], path: str, -) -> t.Union[None, t.Any, str, t.List[str], "Mapping[str, str]"]: - """obj, "foods__breakfast", obj['foods']['breakfast']. +) -> None | t.Any | str | list[str] | Mapping[str, str]: + """Fetch values in objects and keys, supported nested data. - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods__breakfast") - 'cereal' - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods") + **With dictionaries**: + + >>> keygetter({ "food": { "breakfast": "cereal" } }, "food") {'breakfast': 'cereal'} + >>> keygetter({ "food": { "breakfast": "cereal" } }, "food__breakfast") + 'cereal' + + **With objects**: + + >>> from typing import List, Optional + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Food: + ... fruit: List[str] = field(default_factory=list) + ... breakfast: Optional[str] = None + + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... food: Food = field(default_factory=Food) + + + >>> restaurant = Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... food=Food( + ... fruit=["banana", "orange"], breakfast="cereal" + ... ) + ... ) + + >>> restaurant + Restaurant(place='Largo', + city='Tampa', + state='Florida', + food=Food(fruit=['banana', 'orange'], breakfast='cereal')) + + >>> keygetter(restaurant, "food") + Food(fruit=['banana', 'orange'], breakfast='cereal') + + >>> keygetter(restaurant, "food__breakfast") + 'cereal' """ try: sub_fields = path.split("__") @@ -59,44 +107,61 @@ def keygetter( except Exception as e: traceback.print_stack() - print(f"Above error was {e}") + logger.debug("The above error was %s", e) return None return dct def parse_lookup( - obj: "Mapping[str, t.Any]", path: str, lookup: str -) -> t.Optional[t.Any]: + obj: Mapping[str, t.Any], + path: str, + lookup: str, +) -> t.Any | None: """Check if field lookup key, e.g. "my__path__contains" has comparator, return val. If comparator not used or value not found, return None. - mykey__endswith("mykey") -> "mykey" else None - >>> parse_lookup({ "food": "red apple" }, "food__istartswith", "__istartswith") 'red apple' + + It can also look up objects: + + >>> from dataclasses import dataclass + + >>> @dataclass() + ... class Inventory: + ... food: str + + >>> item = Inventory(food="red apple") + + >>> item + Inventory(food='red apple') + + >>> parse_lookup(item, "food__istartswith", "__istartswith") + 'red apple' """ try: if isinstance(path, str) and isinstance(lookup, str) and path.endswith(lookup): - field_name = path.rsplit(lookup)[0] + field_name = path.split(lookup, maxsplit=1)[0] if field_name is not None: return keygetter(obj, field_name) - except Exception: + except Exception as e: traceback.print_stack() + logger.debug("The above error was %s", e) return None def lookup_exact( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: return rhs == data def lookup_iexact( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if not isinstance(rhs, str) or not isinstance(data, str): return False @@ -105,8 +170,8 @@ def lookup_iexact( def lookup_contains( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if not isinstance(rhs, str) or not isinstance(data, (str, Mapping, list)): return False @@ -115,8 +180,8 @@ def lookup_contains( def lookup_icontains( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if not isinstance(rhs, str) or not isinstance(data, (str, Mapping, list)): return False @@ -130,8 +195,8 @@ def lookup_icontains( def lookup_startswith( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if not isinstance(rhs, str) or not isinstance(data, str): return False @@ -140,8 +205,8 @@ def lookup_startswith( def lookup_istartswith( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if not isinstance(rhs, str) or not isinstance(data, str): return False @@ -150,8 +215,8 @@ def lookup_istartswith( def lookup_endswith( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if not isinstance(rhs, str) or not isinstance(data, str): return False @@ -160,8 +225,8 @@ def lookup_endswith( def lookup_iendswith( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if not isinstance(rhs, str) or not isinstance(data, str): return False @@ -169,8 +234,8 @@ def lookup_iendswith( def lookup_in( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if isinstance(rhs, list): return data in rhs @@ -191,8 +256,8 @@ def lookup_in( def lookup_nin( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if isinstance(rhs, list): return data not in rhs @@ -213,8 +278,8 @@ def lookup_nin( def lookup_regex( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if isinstance(data, (str, bytes, re.Pattern)) and isinstance(rhs, (str, bytes)): return bool(re.search(rhs, data)) @@ -222,15 +287,15 @@ def lookup_regex( def lookup_iregex( - data: t.Union[str, t.List[str], "Mapping[str, str]"], - rhs: t.Union[str, t.List[str], "Mapping[str, str]", "re.Pattern[str]"], + data: str | list[str] | Mapping[str, str], + rhs: str | list[str] | Mapping[str, str] | re.Pattern[str], ) -> bool: if isinstance(data, (str, bytes, re.Pattern)) and isinstance(rhs, (str, bytes)): return bool(re.search(rhs, data, re.IGNORECASE)) return False -LOOKUP_NAME_MAP: 'Mapping[str, "LookupProtocol"]' = { +LOOKUP_NAME_MAP: Mapping[str, LookupProtocol] = { "eq": lookup_exact, "exact": lookup_exact, "iexact": lookup_iexact, @@ -248,20 +313,22 @@ def lookup_iregex( class PKRequiredException(Exception): - def __init__(self, *args: object): + def __init__(self, *args: object) -> None: return super().__init__("items() require a pk_key exists") class OpNotFound(ValueError): - def __init__(self, op: str, *args: object): + def __init__(self, op: str, *args: object) -> None: return super().__init__(f"{op} not in LOOKUP_NAME_MAP") -class QueryList(t.List[T]): +class QueryList(list[T], t.Generic[T]): """Filter list of object/dictionaries. For small, local datasets. *Experimental, unstable*. + **With dictionaries**: + >>> query = QueryList( ... [ ... { @@ -278,6 +345,7 @@ class QueryList(t.List[T]): ... }, ... ] ... ) + >>> query.filter(place="Chicago suburbs")[0]['city'] 'Elmhurst' >>> query.filter(place__icontains="chicago")[0]['city'] @@ -288,27 +356,135 @@ class QueryList(t.List[T]): 'Elmhurst' >>> query.filter(foods__fruit__in="orange")[0]['city'] 'Tampa' - >>> query.get(foods__fruit__in="orange")['city'] + + >>> query.filter(foods__fruit__in="apple") + [{'place': 'Chicago suburbs', + 'city': 'Elmhurst', + 'state': 'Illinois', + 'foods': + {'fruit': ['apple', 'cantelope'], 'breakfast': 'waffles'}}] + + >>> query.filter(foods__fruit__in="non_existent") + [] + + **With objects**: + + >>> from typing import Any, Dict + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... foods: Dict[str, Any] + + >>> restaurant = Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... foods={ + ... "fruit": ["banana", "orange"], "breakfast": "cereal" + ... } + ... ) + + >>> restaurant + Restaurant(place='Largo', + city='Tampa', + state='Florida', + foods={'fruit': ['banana', 'orange'], 'breakfast': 'cereal'}) + + >>> query = QueryList([restaurant]) + + >>> query.filter(foods__fruit__in="banana") + [Restaurant(place='Largo', + city='Tampa', + state='Florida', + foods={'fruit': ['banana', 'orange'], 'breakfast': 'cereal'})] + + >>> query.filter(foods__fruit__in="banana")[0].city + 'Tampa' + + >>> query.get(foods__fruit__in="banana").city + 'Tampa' + + **With objects (nested)**: + + >>> from typing import List, Optional + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Food: + ... fruit: List[str] = field(default_factory=list) + ... breakfast: Optional[str] = None + + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... food: Food = field(default_factory=Food) + + + >>> query = QueryList([ + ... Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... food=Food( + ... fruit=["banana", "orange"], breakfast="cereal" + ... ) + ... ), + ... Restaurant( + ... place="Chicago suburbs", + ... city="Elmhurst", + ... state="Illinois", + ... food=Food( + ... fruit=["apple", "cantelope"], breakfast="waffles" + ... ) + ... ) + ... ]) + + >>> query.filter(food__fruit__in="banana") + [Restaurant(place='Largo', + city='Tampa', + state='Florida', + food=Food(fruit=['banana', 'orange'], breakfast='cereal'))] + + >>> query.filter(food__fruit__in="banana")[0].city + 'Tampa' + + >>> query.get(food__fruit__in="banana").city 'Tampa' + + >>> query.filter(food__breakfast="waffles") + [Restaurant(place='Chicago suburbs', + city='Elmhurst', + state='Illinois', + food=Food(fruit=['apple', 'cantelope'], breakfast='waffles'))] + + >>> query.filter(food__breakfast="waffles")[0].city + 'Elmhurst' + + >>> query.filter(food__breakfast="non_existent") + [] """ - data: "Sequence[T]" - pk_key: t.Optional[str] + data: Sequence[T] + pk_key: str | None + + def __init__(self, items: Iterable[T] | None = None) -> None: + super().__init__(items if items is not None else []) - def items(self) -> t.List[T]: + def items(self) -> list[tuple[str, T]]: if self.pk_key is None: - raise PKRequiredException() + raise PKRequiredException return [(getattr(item, self.pk_key), item) for item in self] def __eq__( self, other: object, - # other: t.Union[ - # "QueryList[T]", - # t.List[Mapping[str, str]], - # t.List[Mapping[str, int]], - # t.List[Mapping[str, t.Union[str, Mapping[str, t.Union[List[str], str]]]]], - # ], ) -> bool: data = other @@ -316,25 +492,24 @@ def __eq__( return False if len(self) == len(data): - for a, b in zip(self, data): + for a, b in zip(self, data, strict=False): if isinstance(a, Mapping): a_keys = a.keys() if a.keys == b.keys(): for key in a_keys: if abs(a[key] - b[key]) > 1: return False - else: - if a != b: - return False + elif a != b: + return False return True return False def filter( self, - matcher: t.Optional[t.Union[t.Callable[[T], bool], T]] = None, + matcher: Callable[[T], bool] | T | None = None, **kwargs: t.Any, - ) -> "QueryList[T]": + ) -> QueryList[T]: """Filter list of objects.""" def filter_lookup(obj: t.Any) -> bool: @@ -358,27 +533,26 @@ def filter_lookup(obj: t.Any) -> bool: return True if callable(matcher): - _filter = matcher + filter_ = matcher elif matcher is not None: - def val_match(obj: t.Union[str, t.List[t.Any]]) -> bool: + def val_match(obj: str | list[t.Any] | T) -> bool: if isinstance(matcher, list): return obj in matcher - else: - return bool(obj == matcher) + return bool(obj == matcher) - _filter = val_match + filter_ = val_match else: - _filter = filter_lookup + filter_ = filter_lookup - return self.__class__(k for k in self if _filter(k)) + return self.__class__(k for k in self if filter_(k)) def get( self, - matcher: t.Optional[t.Union[t.Callable[[T], bool], T]] = None, - default: t.Optional[t.Any] = no_arg, + matcher: Callable[[T], bool] | T | None = None, + default: t.Any | None = no_arg, **kwargs: t.Any, - ) -> t.Optional[T]: + ) -> T | None: """Retrieve one object. Raises :exc:`MultipleObjectsReturned` if multiple objects found. @@ -387,9 +561,9 @@ def get( """ objs = self.filter(matcher=matcher, **kwargs) if len(objs) > 1: - raise MultipleObjectsReturned() - elif len(objs) == 0: + raise MultipleObjectsReturned + if len(objs) == 0: if default == no_arg: - raise ObjectDoesNotExist() + raise ObjectDoesNotExist return default return objs[0] diff --git a/src/libtmux/_internal/sparse_array.py b/src/libtmux/_internal/sparse_array.py new file mode 100644 index 000000000..6e783a226 --- /dev/null +++ b/src/libtmux/_internal/sparse_array.py @@ -0,0 +1,192 @@ +"""Sparse array for libtmux options and hooks.""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from typing import TypeAlias, TypeGuard + + from libtmux.options import ExplodedComplexUntypedOptionsDict + + +T = t.TypeVar("T") +HookArray: TypeAlias = "dict[str, SparseArray[str]]" + + +def is_sparse_array_list( + items: ExplodedComplexUntypedOptionsDict, +) -> TypeGuard[HookArray]: + return all( + isinstance( + v, + SparseArray, + ) + for k, v in items.items() + ) + + +class SparseArray(dict[int, T], t.Generic[T]): + """Support non-sequential indexes while maintaining :class:`list`-like behavior. + + A normal :class:`list` would raise :exc:`IndexError`. + + There are no native sparse arrays in python that contain non-sequential indexes and + maintain list-like behavior. This is useful for handling libtmux options and hooks: + + ``command-alias[1] split-pane=split-window`` to + ``{'command-alias[1]': {'split-pane=split-window'}}`` + + :class:`list` would lose indice info, and :class:`dict` would lose list-like + behavior. + + Examples + -------- + Create a sparse array and add values at non-sequential indices: + + >>> from libtmux._internal.sparse_array import SparseArray + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.add(0, "first hook command") + >>> arr.add(5, "fifth hook command") + >>> arr.add(2, "second hook command") + + Access values by index (dict-style): + + >>> arr[0] + 'first hook command' + >>> arr[5] + 'fifth hook command' + + Check index existence: + + >>> 0 in arr + True + >>> 3 in arr + False + + Iterate values in sorted index order: + + >>> list(arr.iter_values()) + ['first hook command', 'second hook command', 'fifth hook command'] + + Convert to a list (values only, sorted by index): + + >>> arr.as_list() + ['first hook command', 'second hook command', 'fifth hook command'] + + Append adds at max index + 1: + + >>> arr.append("appended command") + >>> arr[6] + 'appended command' + + Access raw indices: + + >>> sorted(arr.keys()) + [0, 2, 5, 6] + """ + + def add(self, index: int, value: T) -> None: + """Add a value at a specific index. + + Parameters + ---------- + index : int + The index at which to store the value. + value : T + The value to store. + + Examples + -------- + >>> from libtmux._internal.sparse_array import SparseArray + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.add(0, "hook at index 0") + >>> arr.add(10, "hook at index 10") + >>> arr[0] + 'hook at index 0' + >>> arr[10] + 'hook at index 10' + >>> sorted(arr.keys()) + [0, 10] + """ + self[index] = value + + def append(self, value: T) -> None: + """Append a value at the next available index (max + 1). + + Parameters + ---------- + value : T + The value to append. + + Examples + -------- + >>> from libtmux._internal.sparse_array import SparseArray + + Appending to an empty array starts at index 0: + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.append("first") + >>> arr[0] + 'first' + + Appending to a non-empty array adds at max index + 1: + + >>> arr.add(5, "at index 5") + >>> arr.append("appended") + >>> arr[6] + 'appended' + >>> arr.append("another") + >>> arr[7] + 'another' + """ + index = max(self.keys(), default=-1) + 1 + self[index] = value + + def iter_values(self) -> t.Iterator[T]: + """Iterate over values in sorted index order. + + Yields + ------ + T + Values in ascending index order. + + Examples + -------- + >>> from libtmux._internal.sparse_array import SparseArray + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.add(5, "fifth") + >>> arr.add(0, "first") + >>> arr.add(2, "second") + >>> for val in arr.iter_values(): + ... print(val) + first + second + fifth + """ + for index in sorted(self.keys()): + yield self[index] + + def as_list(self) -> list[T]: + """Return values as a list in sorted index order. + + Returns + ------- + list[T] + List of values sorted by their indices. + + Examples + -------- + >>> from libtmux._internal.sparse_array import SparseArray + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.add(10, "tenth") + >>> arr.add(0, "zeroth") + >>> arr.add(5, "fifth") + >>> arr.as_list() + ['zeroth', 'fifth', 'tenth'] + """ + return [self[index] for index in sorted(self.keys())] diff --git a/src/libtmux/_internal/types.py b/src/libtmux/_internal/types.py new file mode 100644 index 000000000..bbb2f32e7 --- /dev/null +++ b/src/libtmux/_internal/types.py @@ -0,0 +1,18 @@ +"""Internal type annotations. + +Notes +----- +:class:`StrPath` is based on `typeshed's`_. + +.. _typeshed's: https://github.com/python/typeshed/blob/5ff32f3/stdlib/_typeshed/__init__.pyi#L176-L179 +""" # E501 + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from os import PathLike + from typing import TypeAlias + +StrPath: TypeAlias = "str | PathLike[str]" diff --git a/src/libtmux/_vendor/_structures.py b/src/libtmux/_vendor/_structures.py index 25c1775d8..c2fd421dd 100644 --- a/src/libtmux/_vendor/_structures.py +++ b/src/libtmux/_vendor/_structures.py @@ -2,6 +2,7 @@ # This file is dual licensed under the terms of the Apache License, Version # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. +from __future__ import annotations class InfinityType: @@ -26,7 +27,7 @@ def __gt__(self, other: object) -> bool: def __ge__(self, other: object) -> bool: return True - def __neg__(self: object) -> "NegativeInfinityType": + def __neg__(self: object) -> NegativeInfinityType: return NegativeInfinity diff --git a/src/libtmux/_vendor/version.py b/src/libtmux/_vendor/version.py index 82cfe5c04..b49dab12b 100644 --- a/src/libtmux/_vendor/version.py +++ b/src/libtmux/_vendor/version.py @@ -9,40 +9,42 @@ from packaging.version import parse, Version """ +from __future__ import annotations + import collections import itertools import re -from typing import Callable, Optional, SupportsInt, Tuple, Union +import typing as t +from collections.abc import Callable from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType -__all__ = ["VERSION_PATTERN", "parse", "Version", "InvalidVersion"] - -InfiniteTypes = Union[InfinityType, NegativeInfinityType] -PrePostDevType = Union[InfiniteTypes, Tuple[str, int]] -SubLocalType = Union[InfiniteTypes, int, str] -LocalType = Union[ - NegativeInfinityType, - Tuple[ - Union[ - SubLocalType, - Tuple[SubLocalType, str], - Tuple[NegativeInfinityType, SubLocalType], - ], - ..., - ], -] -CmpKey = Tuple[ - int, Tuple[int, ...], PrePostDevType, PrePostDevType, PrePostDevType, LocalType +__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"] + +InfiniteTypes = InfinityType | NegativeInfinityType +PrePostDevType = InfiniteTypes | tuple[str, int] +SubLocalType = InfiniteTypes | int | str +LocalTuple = ( + SubLocalType | tuple[SubLocalType, str] | tuple[NegativeInfinityType, SubLocalType] +) +LocalType = NegativeInfinityType | tuple[LocalTuple, ...] +CmpKey = tuple[ + int, + tuple[int, ...], + PrePostDevType, + PrePostDevType, + PrePostDevType, + LocalType, ] VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool] _Version = collections.namedtuple( - "_Version", ["epoch", "release", "dev", "pre", "post", "local"] + "_Version", + ["epoch", "release", "dev", "pre", "post", "local"], ) -def parse(version: str) -> "Version": +def parse(version: str) -> Version: """Parse the given version string. Examples @@ -72,7 +74,7 @@ class InvalidVersion(ValueError): libtmux._vendor.version.InvalidVersion: Invalid version: 'invalid' """ - def __init__(self, version: str, *args: object): + def __init__(self, version: str, *args: object) -> None: return super().__init__(f"Invalid version: '{version}'") @@ -85,13 +87,13 @@ def __hash__(self) -> int: # Please keep the duplicated `isinstance` check # in the six comparisons hereunder # unless you find a way to avoid adding overhead function calls. - def __lt__(self, other: "_BaseVersion") -> bool: + def __lt__(self, other: _BaseVersion) -> bool: if not isinstance(other, _BaseVersion): return NotImplemented return self._key < other._key - def __le__(self, other: "_BaseVersion") -> bool: + def __le__(self, other: _BaseVersion) -> bool: if not isinstance(other, _BaseVersion): return NotImplemented @@ -103,13 +105,13 @@ def __eq__(self, other: object) -> bool: return self._key == other._key - def __ge__(self, other: "_BaseVersion") -> bool: + def __ge__(self, other: _BaseVersion) -> bool: if not isinstance(other, _BaseVersion): return NotImplemented return self._key >= other._key - def __gt__(self, other: "_BaseVersion") -> bool: + def __gt__(self, other: _BaseVersion) -> bool: if not isinstance(other, _BaseVersion): return NotImplemented @@ -220,7 +222,8 @@ def __init__(self, version: str) -> None: release=tuple(int(i) for i in match.group("release").split(".")), pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")), post=_parse_letter_version( - match.group("post_l"), match.group("post_n1") or match.group("post_n2") + match.group("post_l"), + match.group("post_n1") or match.group("post_n2"), ), dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")), local=_parse_local_version(match.group("local")), @@ -286,11 +289,11 @@ def epoch(self) -> int: >>> Version("1!2.0.0").epoch 1 """ - _epoch: int = self._version.epoch - return _epoch + epoch: int = self._version.epoch + return epoch @property - def release(self) -> Tuple[int, ...]: + def release(self) -> tuple[int, ...]: """The components of the "release" segment of the version. >>> Version("1.2.3").release @@ -303,11 +306,11 @@ def release(self) -> Tuple[int, ...]: Includes trailing zeroes but not the epoch or any pre-release / development / post-release suffixes. """ - _release: Tuple[int, ...] = self._version.release - return _release + release: tuple[int, ...] = self._version.release + return release @property - def pre(self) -> Optional[Tuple[str, int]]: + def pre(self) -> tuple[str, int] | None: """The pre-release segment of the version. >>> print(Version("1.2.3").pre) @@ -319,11 +322,11 @@ def pre(self) -> Optional[Tuple[str, int]]: >>> Version("1.2.3rc1").pre ('rc', 1) """ - _pre: Optional[Tuple[str, int]] = self._version.pre - return _pre + pre: tuple[str, int] | None = self._version.pre + return pre @property - def post(self) -> Optional[int]: + def post(self) -> int | None: """The post-release number of the version. >>> print(Version("1.2.3").post) @@ -334,7 +337,7 @@ def post(self) -> Optional[int]: return self._version.post[1] if self._version.post else None @property - def dev(self) -> Optional[int]: + def dev(self) -> int | None: """The development number of the version. >>> print(Version("1.2.3").dev) @@ -345,7 +348,7 @@ def dev(self) -> Optional[int]: return self._version.dev[1] if self._version.dev else None @property - def local(self) -> Optional[str]: + def local(self) -> str | None: """The local version segment of the version. >>> print(Version("1.2.3").local) @@ -355,8 +358,7 @@ def local(self) -> Optional[str]: """ if self._version.local: return ".".join(str(x) for x in self._version.local) - else: - return None + return None @property def public(self) -> str: @@ -468,8 +470,9 @@ def micro(self) -> int: def _parse_letter_version( - letter: str, number: Union[str, bytes, SupportsInt] -) -> Optional[Tuple[str, int]]: + letter: str, + number: str | bytes | t.SupportsInt, +) -> tuple[str, int] | None: if letter: # We consider there to be an implicit 0 in a pre-release if there is # not a numeral associated with it. @@ -486,9 +489,9 @@ def _parse_letter_version( letter = "a" elif letter == "beta": letter = "b" - elif letter in ["c", "pre", "preview"]: + elif letter in {"c", "pre", "preview"}: letter = "rc" - elif letter in ["rev", "r"]: + elif letter in {"rev", "r"}: letter = "post" return letter, int(number) @@ -505,7 +508,7 @@ def _parse_letter_version( _local_version_separators = re.compile(r"[\._-]") -def _parse_local_version(local: str) -> Optional[LocalType]: +def _parse_local_version(local: str) -> LocalType | None: """Take a string like abc.1.twelve and turns it into ("abc", 1, "twelve").""" if local is not None: return tuple( @@ -517,19 +520,19 @@ def _parse_local_version(local: str) -> Optional[LocalType]: def _cmpkey( epoch: int, - release: Tuple[int, ...], - pre: Optional[Tuple[str, int]], - post: Optional[Tuple[str, int]], - dev: Optional[Tuple[str, int]], - local: Optional[Tuple[SubLocalType]], + release: tuple[int, ...], + pre: tuple[str, int] | None, + post: tuple[str, int] | None, + dev: tuple[str, int] | None, + local: tuple[SubLocalType] | None, ) -> CmpKey: # When we compare a release version, we want to compare it with all of the # trailing zeros removed. So we'll use a reverse the list, drop all the now # leading zeros until we come to something non zero, then take the rest # re-reverse it back into the correct order and make it a tuple and use # that for our sorting key. - _release = tuple( - reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release)))) + release_ = tuple( + reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release)))), ) # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0. @@ -537,31 +540,31 @@ def _cmpkey( # if there is not a pre or a post segment. If we have one of those then # the normal sorting rules will handle this case correctly. if pre is None and post is None and dev is not None: - _pre: PrePostDevType = NegativeInfinity + pre_: PrePostDevType = NegativeInfinity # Versions without a pre-release (except as noted above) should sort after # those with one. elif pre is None: - _pre = Infinity + pre_ = Infinity else: - _pre = pre + pre_ = pre # Versions without a post segment should sort before those with one. if post is None: - _post: PrePostDevType = NegativeInfinity + post_: PrePostDevType = NegativeInfinity else: - _post = post + post_ = post # Versions without a development segment should sort after those with one. if dev is None: - _dev: PrePostDevType = Infinity + dev_: PrePostDevType = Infinity else: - _dev = dev + dev_ = dev if local is None: # Versions without a local segment should sort before those with one. - _local: LocalType = NegativeInfinity + local_: LocalType = NegativeInfinity else: # Versions with a local segment need that segment parsed to implement # the sorting rules in PEP440. @@ -570,8 +573,8 @@ def _cmpkey( # - Numeric segments sort numerically # - Shorter versions sort before longer versions when the prefixes # match exactly - _local = tuple( + local_ = tuple( (i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local ) - return epoch, _release, _pre, _post, _dev, _local + return epoch, release_, pre_, post_, dev_, local_ diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 81e5cb514..60a3b49c7 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -1,38 +1,52 @@ -# flake8: NOQA: W605 """Helper methods and mixins for libtmux. libtmux.common ~~~~~~~~~~~~~~ """ + +from __future__ import annotations + import logging import re import shutil import subprocess import sys import typing as t -from typing import Dict, Optional, Union from . import exc -from ._compat import LooseVersion, console_to_str, str_from_console +from ._compat import LooseVersion if t.TYPE_CHECKING: - pass - + from collections.abc import Callable logger = logging.getLogger(__name__) #: Minimum version of tmux required to run libtmux -TMUX_MIN_VERSION = "1.8" +TMUX_MIN_VERSION = "3.2a" #: Most recent version of tmux supported -TMUX_MAX_VERSION = "3.3" +TMUX_MAX_VERSION = "3.6" + +SessionDict = dict[str, t.Any] +WindowDict = dict[str, t.Any] +WindowOptionDict = dict[str, t.Any] +PaneDict = dict[str, t.Any] + + +class CmdProtocol(t.Protocol): + """Command protocol for tmux command.""" -SessionDict = t.Dict[str, t.Any] -WindowDict = t.Dict[str, t.Any] -WindowOptionDict = t.Dict[str, t.Any] -PaneDict = t.Dict[str, t.Any] + def __call__(self, cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + """Wrap tmux_cmd.""" + ... + + +class CmdMixin: + """Command mixin for tmux command.""" + + cmd: CmdProtocol class EnvironmentMixin: @@ -40,21 +54,25 @@ class EnvironmentMixin: _add_option = None - cmd: t.Callable[[t.Any, t.Any], "tmux_cmd"] + cmd: Callable[[t.Any, t.Any], tmux_cmd] - def __init__(self, add_option: Optional[str] = None) -> None: + def __init__(self, add_option: str | None = None) -> None: self._add_option = add_option def set_environment(self, name: str, value: str) -> None: - """ - Set environment ``$ tmux set-environment ``. + """Set environment ``$ tmux set-environment ``. Parameters ---------- name : str - the environment variable name. such as 'PATH'. - option : str - environment value. + The environment variable name, e.g. 'PATH'. + value : str + Environment value. + + Raises + ------ + ValueError + If tmux returns an error. """ args = ["set-environment"] if self._add_option: @@ -70,16 +88,21 @@ def set_environment(self, name: str, value: str) -> None: if isinstance(cmd.stderr, list) and len(cmd.stderr) == 1 else cmd.stderr ) - raise ValueError("tmux set-environment stderr: %s" % cmd.stderr) + msg = f"tmux set-environment stderr: {cmd.stderr}" + raise ValueError(msg) def unset_environment(self, name: str) -> None: - """ - Unset environment variable ``$ tmux set-environment -u ``. + """Unset environment variable ``$ tmux set-environment -u ``. Parameters ---------- name : str - the environment variable name. such as 'PATH'. + The environment variable name, e.g. 'PATH'. + + Raises + ------ + ValueError + If tmux returns an error. """ args = ["set-environment"] if self._add_option: @@ -94,7 +117,8 @@ def unset_environment(self, name: str) -> None: if isinstance(cmd.stderr, list) and len(cmd.stderr) == 1 else cmd.stderr ) - raise ValueError("tmux set-environment stderr: %s" % cmd.stderr) + msg = f"tmux set-environment stderr: {cmd.stderr}" + raise ValueError(msg) def remove_environment(self, name: str) -> None: """Remove environment variable ``$ tmux set-environment -r ``. @@ -102,7 +126,12 @@ def remove_environment(self, name: str) -> None: Parameters ---------- name : str - the environment variable name. such as 'PATH'. + The environment variable name, e.g. 'PATH'. + + Raises + ------ + ValueError + If tmux returns an error. """ args = ["set-environment"] if self._add_option: @@ -117,9 +146,10 @@ def remove_environment(self, name: str) -> None: if isinstance(cmd.stderr, list) and len(cmd.stderr) == 1 else cmd.stderr ) - raise ValueError("tmux set-environment stderr: %s" % cmd.stderr) + msg = f"tmux set-environment stderr: {cmd.stderr}" + raise ValueError(msg) - def show_environment(self) -> Dict[str, Union[bool, str]]: + def show_environment(self) -> dict[str, bool | str]: """Show environment ``$ tmux show-environment -t [session]``. Return dict of environment variables for the session. @@ -139,19 +169,19 @@ def show_environment(self) -> Dict[str, Union[bool, str]]: tmux_args += [self._add_option] cmd = self.cmd(*tmux_args) output = cmd.stdout - vars = [tuple(item.split("=", 1)) for item in output] - vars_dict: t.Dict[str, t.Union[str, bool]] = {} - for _t in vars: + opts = [tuple(item.split("=", 1)) for item in output] + opts_dict: dict[str, str | bool] = {} + for _t in opts: if len(_t) == 2: - vars_dict[_t[0]] = _t[1] + opts_dict[_t[0]] = _t[1] elif len(_t) == 1: - vars_dict[_t[0]] = True + opts_dict[_t[0]] = True else: raise exc.VariableUnpackingError(variable=_t) - return vars_dict + return opts_dict - def getenv(self, name: str) -> Optional[t.Union[str, bool]]: + def getenv(self, name: str) -> str | bool | None: """Show environment variable ``$ tmux show-environment -t [session] ``. Return the value of a specific variable if the name is specified. @@ -168,7 +198,7 @@ def getenv(self, name: str) -> Optional[t.Union[str, bool]]: str Value of environment variable """ - tmux_args: t.Tuple[t.Union[str, int], ...] = () + tmux_args: tuple[str | int, ...] = () tmux_args += ("show-environment",) if self._add_option: @@ -176,35 +206,35 @@ def getenv(self, name: str) -> Optional[t.Union[str, bool]]: tmux_args += (name,) cmd = self.cmd(*tmux_args) output = cmd.stdout - vars = [tuple(item.split("=", 1)) for item in output] - vars_dict: t.Dict[str, t.Union[str, bool]] = {} - for _t in vars: + opts = [tuple(item.split("=", 1)) for item in output] + opts_dict: dict[str, str | bool] = {} + for _t in opts: if len(_t) == 2: - vars_dict[_t[0]] = _t[1] + opts_dict[_t[0]] = _t[1] elif len(_t) == 1: - vars_dict[_t[0]] = True + opts_dict[_t[0]] = True else: raise exc.VariableUnpackingError(variable=_t) - return vars_dict.get(name) + return opts_dict.get(name) class tmux_cmd: - """ - :term:`tmux(1)` command via :py:mod:`subprocess`. + """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. Examples -------- - .. code-block:: python + Create a new session, check for error: - proc = tmux_cmd('new-session', '-s%' % 'my session') + >>> proc = tmux_cmd(f'-L{server.socket_name}', 'new-session', '-d', '-P', '-F#S') + >>> if proc.stderr: + ... raise exc.LibTmuxException( + ... 'Command: %s returned error: %s' % (proc.cmd, proc.stderr) + ... ) + ... - if proc.stderr: - raise exc.LibTmuxException( - 'Command: %s returned error: %s' % (proc.cmd, proc.stderr) - ) - - print('tmux command returned %s' % proc.stdout) + >>> print(f'tmux command returned {" ".join(proc.stdout)}') + tmux command returned 2 Equivalent to: @@ -218,20 +248,24 @@ class tmux_cmd: Renamed from ``tmux`` to ``tmux_cmd``. """ - def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + def __init__(self, *args: t.Any) -> None: tmux_bin = shutil.which("tmux") if not tmux_bin: - raise exc.TmuxCommandNotFound() + raise exc.TmuxCommandNotFound cmd = [tmux_bin] cmd += args # add the command arguments to cmd - cmd = [str_from_console(c) for c in cmd] + cmd = [str(c) for c in cmd] self.cmd = cmd try: self.process = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="backslashreplace", ) stdout, stderr = self.process.communicate() returncode = self.process.returncode @@ -241,14 +275,12 @@ def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: self.returncode = returncode - stdout_str = console_to_str(stdout) - stdout_split = stdout_str.split("\n") + stdout_split = stdout.split("\n") # remove trailing newlines from stdout while stdout_split and stdout_split[-1] == "": stdout_split.pop() - stderr_str = console_to_str(stderr) - stderr_split = stderr_str.split("\n") + stderr_split = stderr.split("\n") self.stderr = list(filter(None, stderr_split)) # filter empty values if "has-session" in cmd and len(self.stderr) and not stdout_split: @@ -258,14 +290,14 @@ def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: logger.debug( "self.stdout for {cmd}: {stdout}".format( - cmd=" ".join(cmd), stdout=self.stdout - ) + cmd=" ".join(cmd), + stdout=self.stdout, + ), ) def get_version() -> LooseVersion: - """ - Return tmux version. + """Return tmux version. If tmux is built from git master, the version returned will be the latest version appended with -master, e.g. ``2.4-master``. @@ -282,10 +314,13 @@ def get_version() -> LooseVersion: if proc.stderr: if proc.stderr[0] == "tmux: unknown option -- V": if sys.platform.startswith("openbsd"): # openbsd has no tmux -V - return LooseVersion("%s-openbsd" % TMUX_MAX_VERSION) + return LooseVersion(f"{TMUX_MAX_VERSION}-openbsd") + msg = ( + f"libtmux supports tmux {TMUX_MIN_VERSION} and greater. This system" + " does not meet the minimum tmux version requirement." + ) raise exc.LibTmuxException( - "libtmux supports tmux %s and greater. This system" - " is running tmux 1.3 or earlier." % TMUX_MIN_VERSION + msg, ) raise exc.VersionTooLow(proc.stderr) @@ -293,7 +328,7 @@ def get_version() -> LooseVersion: # Allow latest tmux HEAD if version == "master": - return LooseVersion("%s-master" % TMUX_MAX_VERSION) + return LooseVersion(f"{TMUX_MAX_VERSION}-master") version = re.sub(r"[a-z-]", "", version) @@ -301,13 +336,12 @@ def get_version() -> LooseVersion: def has_version(version: str) -> bool: - """ - Return affirmative if tmux version installed. + """Return True if tmux version installed. Parameters ---------- version : str - version number, e.g. '1.8' + version number, e.g. '3.2a' Returns ------- @@ -318,13 +352,12 @@ def has_version(version: str) -> bool: def has_gt_version(min_version: str) -> bool: - """ - Return affirmative if tmux version greater than minimum. + """Return True if tmux version greater than minimum. Parameters ---------- min_version : str - tmux version, e.g. '1.8' + tmux version, e.g. '3.2a' Returns ------- @@ -335,13 +368,12 @@ def has_gt_version(min_version: str) -> bool: def has_gte_version(min_version: str) -> bool: - """ - Return True if tmux version greater or equal to minimum. + """Return True if tmux version greater or equal to minimum. Parameters ---------- min_version : str - tmux version, e.g. '1.8' + tmux version, e.g. '3.2a' Returns ------- @@ -352,13 +384,12 @@ def has_gte_version(min_version: str) -> bool: def has_lte_version(max_version: str) -> bool: - """ - Return True if tmux version less or equal to minimum. + """Return True if tmux version less or equal to minimum. Parameters ---------- max_version : str - tmux version, e.g. '1.8' + tmux version, e.g. '3.2a' Returns ------- @@ -369,13 +400,12 @@ def has_lte_version(max_version: str) -> bool: def has_lt_version(max_version: str) -> bool: - """ - Return True if tmux version less than minimum. + """Return True if tmux version less than minimum. Parameters ---------- max_version : str - tmux version, e.g. '1.8' + tmux version, e.g. '3.2a' Returns ------- @@ -386,8 +416,7 @@ def has_lt_version(max_version: str) -> bool: def has_minimum_version(raises: bool = True) -> bool: - """ - Return if tmux meets version requirement. Version >1.8 or above. + """Return True if tmux meets version requirement. Version >= 3.2a. Parameters ---------- @@ -406,28 +435,29 @@ def has_minimum_version(raises: bool = True) -> bool: Notes ----- + .. versionchanged:: 0.49.0 + Minimum version bumped to 3.2a. For older tmux, use libtmux v0.48.x. + .. versionchanged:: 0.7.0 No longer returns version, returns True or False .. versionchanged:: 0.1.7 - Versions will now remove trailing letters per `Issue 55`_. - - .. _Issue 55: https://github.com/tmux-python/tmuxp/issues/55. + Versions will now remove trailing letters per + `Issue 55 `_. """ if get_version() < LooseVersion(TMUX_MIN_VERSION): if raises: - raise exc.VersionTooLow( - "libtmux only supports tmux {} and greater. This system" - " has {} installed. Upgrade your tmux to use libtmux.".format( - TMUX_MIN_VERSION, get_version() - ) + msg = ( + f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater. This " + f"system has {get_version()} installed. Upgrade your tmux to use " + "libtmux, or use libtmux v0.48.x for older tmux versions." ) - else: - return False + raise exc.VersionTooLow(msg) + return False return True -def session_check_name(session_name: t.Optional[str]) -> None: +def session_check_name(session_name: str | None) -> None: """Raise exception session name invalid, modeled after tmux function. tmux(1) session names may not be empty, or include periods or colons. @@ -445,49 +475,12 @@ def session_check_name(session_name: t.Optional[str]) -> None: """ if session_name is None or len(session_name) == 0: raise exc.BadSessionName(reason="empty", session_name=session_name) - elif "." in session_name: + if "." in session_name: raise exc.BadSessionName(reason="contains periods", session_name=session_name) - elif ":" in session_name: + if ":" in session_name: raise exc.BadSessionName(reason="contains colons", session_name=session_name) -def handle_option_error(error: str) -> t.Type[exc.OptionError]: - """Raise exception if error in option command found. - - In tmux 3.0, show-option and show-window-option return invalid option instead of - unknown option. See https://github.com/tmux/tmux/blob/3.0/cmd-show-options.c. - - In tmux >2.4, there are 3 different types of option errors: - - - unknown option - - invalid option - - ambiguous option - - In tmux <2.4, unknown option was the only option. - - All errors raised will have the base error of :exc:`exc.OptionError`. So to - catch any option error, use ``except exc.OptionError``. - - Parameters - ---------- - error : str - Error response from subprocess call. - - Raises - ------ - :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, :exc:`exc.InvalidOption`, - :exc:`exc.AmbiguousOption` - """ - if "unknown option" in error: - raise exc.UnknownOption(error) - elif "invalid option" in error: - raise exc.InvalidOption(error) - elif "ambiguous option" in error: - raise exc.AmbiguousOption(error) - else: - raise exc.OptionError(error) # Raise generic option error - - def get_libtmux_version() -> LooseVersion: """Return libtmux version is a PEP386 compliant format. diff --git a/src/libtmux/constants.py b/src/libtmux/constants.py new file mode 100644 index 000000000..43ad3f519 --- /dev/null +++ b/src/libtmux/constants.py @@ -0,0 +1,85 @@ +"""Constant variables for libtmux.""" + +from __future__ import annotations + +import enum + + +class ResizeAdjustmentDirection(enum.Enum): + """Used for *adjustment* in ``resize_window``, ``resize_pane``.""" + + Up = "UP" + Down = "DOWN" + Left = "LEFT" + Right = "RIGHT" + + +RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP: dict[ResizeAdjustmentDirection, str] = { + ResizeAdjustmentDirection.Up: "-U", + ResizeAdjustmentDirection.Down: "-D", + ResizeAdjustmentDirection.Left: "-L", + ResizeAdjustmentDirection.Right: "-R", +} + + +class WindowDirection(enum.Enum): + """Used for *adjustment* in :meth:`Session.new_window()`.""" + + Before = "BEFORE" + After = "AFTER" + + +WINDOW_DIRECTION_FLAG_MAP: dict[WindowDirection, str] = { + WindowDirection.Before: "-b", + WindowDirection.After: "-a", +} + + +class PaneDirection(enum.Enum): + """Used for *adjustment* in :meth:`Pane.split()`.""" + + Above = "ABOVE" + Below = "BELOW" # default with no args + Right = "RIGHT" + Left = "LEFT" + + +PANE_DIRECTION_FLAG_MAP: dict[PaneDirection, list[str]] = { + # -v is assumed, but for explicitness it is passed + PaneDirection.Above: ["-v", "-b"], + PaneDirection.Below: ["-v"], + PaneDirection.Right: ["-h"], + PaneDirection.Left: ["-h", "-b"], +} + + +class _DefaultOptionScope: + # Sentinel value for default scope + ... + + +DEFAULT_OPTION_SCOPE: _DefaultOptionScope = _DefaultOptionScope() + + +class OptionScope(enum.Enum): + """Scope used with ``set-option`` and ``show-option(s)`` commands.""" + + Server = "SERVER" + Session = "SESSION" + Window = "WINDOW" + Pane = "PANE" + + +OPTION_SCOPE_FLAG_MAP: dict[OptionScope, str] = { + OptionScope.Server: "-s", + OptionScope.Session: "", + OptionScope.Window: "-w", + OptionScope.Pane: "-p", +} + +HOOK_SCOPE_FLAG_MAP: dict[OptionScope, str] = { + OptionScope.Server: "-g", + OptionScope.Session: "", + OptionScope.Window: "-w", + OptionScope.Pane: "-p", +} diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index bf35a73f4..6a47b247c 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -4,6 +4,9 @@ ~~~~~~~~~~~ """ + +from __future__ import annotations + import typing as t from libtmux._internal.query_list import ObjectDoesNotExist @@ -16,6 +19,35 @@ class LibTmuxException(Exception): """Base Exception for libtmux Errors.""" +class DeprecatedError(LibTmuxException): + """Raised when a deprecated function, method, or parameter is used. + + This exception provides clear guidance on what to use instead. + + Parameters + ---------- + deprecated : str + The name of the deprecated API (e.g., "Pane.resize_pane()") + replacement : str + The recommended replacement API to use instead + version : str + The version when the API was deprecated (e.g., "0.28.0") + """ + + def __init__( + self, + *, + deprecated: str, + replacement: str, + version: str, + ) -> None: + msg = ( + f"{deprecated} was deprecated in {version} and has been removed. " + f"Use {replacement} instead." + ) + super().__init__(msg) + + class TmuxSessionExists(LibTmuxException): """Session does not exist in the server.""" @@ -29,16 +61,16 @@ class TmuxObjectDoesNotExist(ObjectDoesNotExist): def __init__( self, - obj_key: t.Optional[str] = None, - obj_id: t.Optional[str] = None, - list_cmd: t.Optional[str] = None, - list_extra_args: "t.Optional[ListExtraArgs]" = None, + obj_key: str | None = None, + obj_id: str | None = None, + list_cmd: str | None = None, + list_extra_args: ListExtraArgs | None = None, *args: object, - ): + ) -> None: if all(arg is not None for arg in [obj_key, obj_id, list_cmd, list_extra_args]): return super().__init__( f"Could not find {obj_key}={obj_id} for {list_cmd} " - f'{list_extra_args if list_extra_args is not None else ""}' + f"{list_extra_args if list_extra_args is not None else ''}", ) return super().__init__("Could not find object") @@ -51,8 +83,11 @@ class BadSessionName(LibTmuxException): """Disallowed session name for tmux (empty, contains periods or colons).""" def __init__( - self, reason: str, session_name: t.Optional[str] = None, *args: object - ): + self, + reason: str, + session_name: str | None = None, + *args: object, + ) -> None: msg = f"Bad session name: {reason}" if session_name is not None: msg += f" (session name: {session_name})" @@ -70,12 +105,12 @@ class UnknownOption(OptionError): class UnknownColorOption(UnknownOption): """Unknown color option.""" - def __init__(self, *args: object): + def __init__(self, *args: object) -> None: return super().__init__("Server.colors must equal 88 or 256") class InvalidOption(OptionError): - """Option invalid to tmux, introduced in tmux v2.4.""" + """Option invalid to tmux.""" class AmbiguousOption(OptionError): @@ -89,7 +124,7 @@ class WaitTimeout(LibTmuxException): class VariableUnpackingError(LibTmuxException): """Error unpacking variable.""" - def __init__(self, variable: t.Optional[t.Any] = None, *args: object): + def __init__(self, variable: t.Any | None = None, *args: object) -> None: return super().__init__(f"Unexpected variable: {variable!s}") @@ -100,7 +135,7 @@ class PaneError(LibTmuxException): class PaneNotFound(PaneError): """Pane not found.""" - def __init__(self, pane_id: t.Optional[str] = None, *args: object): + def __init__(self, pane_id: str | None = None, *args: object) -> None: if pane_id is not None: return super().__init__(f"Pane not found: {pane_id}") return super().__init__("Pane not found") @@ -113,19 +148,47 @@ class WindowError(LibTmuxException): class MultipleActiveWindows(WindowError): """Multiple active windows.""" - def __init__(self, count: int, *args: object): + def __init__(self, count: int, *args: object) -> None: return super().__init__(f"Multiple active windows: {count} found") class NoActiveWindow(WindowError): """No active window found.""" - def __init__(self, *args: object): + def __init__(self, *args: object) -> None: return super().__init__("No active windows found") class NoWindowsExist(WindowError): """No windows exist for object.""" - def __init__(self, *args: object): + def __init__(self, *args: object) -> None: return super().__init__("No windows exist for object") + + +class AdjustmentDirectionRequiresAdjustment(LibTmuxException, ValueError): + """If *adjustment_direction* is set, *adjustment* must be set.""" + + def __init__(self) -> None: + super().__init__("adjustment_direction requires adjustment") + + +class WindowAdjustmentDirectionRequiresAdjustment( + WindowError, + AdjustmentDirectionRequiresAdjustment, +): + """ValueError for :meth:`libtmux.Window.resize_window`.""" + + +class PaneAdjustmentDirectionRequiresAdjustment( + WindowError, + AdjustmentDirectionRequiresAdjustment, +): + """ValueError for :meth:`libtmux.Pane.resize_pane`.""" + + +class RequiresDigitOrPercentage(LibTmuxException, ValueError): + """Requires digit (int or str digit) or a percentage.""" + + def __init__(self) -> None: + super().__init__("Requires digit (int or str digit) or a percentage.") diff --git a/src/libtmux/formats.py b/src/libtmux/formats.py index 6f36799f1..a14d10486 100644 --- a/src/libtmux/formats.py +++ b/src/libtmux/formats.py @@ -6,6 +6,9 @@ For reference: https://github.com/tmux/tmux/blob/master/format.c """ + +from __future__ import annotations + import os FORMAT_SEPARATOR = os.environ.get("LIBTMUX_TMUX_FORMAT_SEPARATOR", "␞") diff --git a/src/libtmux/hooks.py b/src/libtmux/hooks.py new file mode 100644 index 000000000..c0c28fd09 --- /dev/null +++ b/src/libtmux/hooks.py @@ -0,0 +1,525 @@ +"""Helpers for tmux hooks. + +tmux Hook Features +------------------ +Hooks are array options (e.g., ``session-renamed[0]``, ``session-renamed[1]``) +with sparse index support (can have gaps: ``[0]``, ``[5]``, ``[10]``). + +All features available in libtmux's minimum supported version (tmux 3.2+): + +- Session, window, and pane-level hooks +- Window hooks via ``-w`` flag, pane hooks via ``-p`` flag +- Hook scope separation (session vs window vs pane) + +**tmux 3.3+**: +- ``client-active`` hook +- ``window-resized`` hook + +**tmux 3.5+**: +- ``pane-title-changed`` hook +- ``client-light-theme`` / ``client-dark-theme`` hooks +- ``command-error`` hook + +Bulk Operations API +------------------- +This module provides bulk operations for managing multiple indexed hooks: + +- :meth:`~HooksMixin.set_hooks` - Set multiple hooks at once +""" + +from __future__ import annotations + +import logging +import re +import typing as t +import warnings + +from libtmux._internal.constants import ( + Hooks, +) +from libtmux._internal.sparse_array import SparseArray +from libtmux.common import CmdMixin, has_lt_version +from libtmux.constants import ( + DEFAULT_OPTION_SCOPE, + HOOK_SCOPE_FLAG_MAP, + OptionScope, + _DefaultOptionScope, +) +from libtmux.options import handle_option_error + +if t.TYPE_CHECKING: + from typing_extensions import Self + +HookDict = dict[str, t.Any] +HookValues = dict[int, str] | SparseArray[str] | list[str] + +logger = logging.getLogger(__name__) + + +class HooksMixin(CmdMixin): + """Mixin for manager scoped hooks in tmux. + + Requires tmux 3.1+. For older versions, use raw commands. + """ + + default_hook_scope: OptionScope | None + hooks: Hooks + + def __init__(self, default_hook_scope: OptionScope | None) -> None: + """When not a user (custom) hook, scope can be implied.""" + self.default_hook_scope = default_hook_scope + self.hooks = Hooks() + + def run_hook( + self, + hook: str, + global_: bool | None = None, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> Self: + """Run a hook immediately. Useful for testing.""" + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + flags: list[str] = ["-R"] + + if global_ is not None and global_: + flags.append("-g") + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + cmd = self.cmd( + "set-hook", + *flags, + hook, + ) + + if isinstance(cmd.stderr, list) and len(cmd.stderr): + handle_option_error(cmd.stderr[0]) + + return self + + def set_hook( + self, + hook: str, + value: int | str, + unset: bool | None = None, + run: bool | None = None, + append: bool | None = None, + g: bool | None = None, + global_: bool | None = None, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> Self: + """Set hook for tmux target. + + Wraps ``$ tmux set-hook ``. + + Parameters + ---------- + hook : str + hook to set, e.g. 'aggressive-resize' + value : int | str + hook command. + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, + :exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption` + """ + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + if g: + warnings.warn( + "g argument is deprecated in favor of global_", + category=DeprecationWarning, + stacklevel=2, + ) + global_ = g + + flags: list[str] = [] + + if unset is not None and unset: + assert isinstance(unset, bool) + flags.append("-u") + + if run is not None and run: + assert isinstance(run, bool) + flags.append("-R") + + if append is not None and append: + assert isinstance(append, bool) + flags.append("-a") + + if global_ is not None and global_: + assert isinstance(global_, bool) + flags.append("-g") + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + cmd = self.cmd( + "set-hook", + *flags, + hook, + value, + ) + + if isinstance(cmd.stderr, list) and len(cmd.stderr): + handle_option_error(cmd.stderr[0]) + + return self + + def unset_hook( + self, + hook: str, + global_: bool | None = None, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> Self: + """Unset hook for tmux target. + + Wraps ``$ tmux set-hook -u `` / ``$ tmux set-hook -U `` + + Parameters + ---------- + hook : str + hook to unset, e.g. 'after-show-environment' + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, + :exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption` + """ + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + flags: list[str] = ["-u"] + + if global_ is not None and global_: + assert isinstance(global_, bool) + flags.append("-g") + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + cmd = self.cmd( + "set-hook", + *flags, + hook, + ) + + if isinstance(cmd.stderr, list) and len(cmd.stderr): + handle_option_error(cmd.stderr[0]) + + return self + + def show_hooks( + self, + global_: bool | None = False, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> HookDict: + """Return a dict of hooks for the target. + + Parameters + ---------- + global_ : bool, optional + Pass ``-g`` flag for global hooks, default False. + scope : OptionScope | _DefaultOptionScope | None, optional + Hook scope (Server/Session/Window/Pane), defaults to object's scope. + + Returns + ------- + HookDict + Dictionary mapping hook names to their values. + + Examples + -------- + >>> session.set_hook('session-renamed[0]', 'display-message "test"') + Session($...) + + >>> hooks = session.show_hooks() + >>> isinstance(hooks, dict) + True + + >>> 'session-renamed[0]' in hooks + True + + >>> session.unset_hook('session-renamed') + Session($...) + """ + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + flags: tuple[str, ...] = () + + if global_: + flags += ("-g",) + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + cmd = self.cmd("show-hooks", *flags) + output = cmd.stdout + hooks: HookDict = {} + for item in output: + # Split on first whitespace only to handle multi-word hook values + parts = item.split(None, 1) + if len(parts) == 2: + key, val = parts + elif len(parts) == 1: + key, val = parts[0], None + else: + logger.warning(f"Error extracting hook: {item}") + continue + + if isinstance(val, str) and val.isdigit(): + hooks[key] = int(val) + elif isinstance(val, str): + hooks[key] = val + + return hooks + + def _show_hook( + self, + hook: str, + global_: bool = False, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> list[str] | None: + """Return value for the hook. + + Parameters + ---------- + hook : str + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, + :exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption` + """ + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + flags: tuple[str | int, ...] = () + + if global_: + flags += ("-g",) + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + flags += (hook,) + + cmd = self.cmd("show-hooks", *flags) + + if len(cmd.stderr): + handle_option_error(cmd.stderr[0]) + + return cmd.stdout + + def show_hook( + self, + hook: str, + global_: bool = False, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> str | int | SparseArray[str] | None: + """Return value for a hook. + + For array hooks (e.g., ``session-renamed``), returns a + :class:`~libtmux._internal.sparse_array.SparseArray` with hook values + at their original indices. Use ``.keys()`` for indices and ``.values()`` + for values. + + Parameters + ---------- + hook : str + Hook name to query + + Returns + ------- + str | int | SparseArray[str] | None + Hook value. For array hooks, returns SparseArray. + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, + :exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption` + + Examples + -------- + >>> session.set_hook('session-renamed[0]', 'display-message "test"') + Session($...) + + >>> hooks = session.show_hook('session-renamed') + >>> isinstance(hooks, SparseArray) + True + + >>> sorted(hooks.keys()) + [0] + + >>> session.unset_hook('session-renamed') + Session($...) + """ + hooks_output = self._show_hook( + hook=hook, + global_=global_, + scope=scope, + ) + if hooks_output is None: + return None + hooks = Hooks.from_stdout(hooks_output) + + # Check if this is an indexed query (e.g., "session-renamed[0]") + # For indexed queries, return the specific value like _show_option does + hook_attr = hook.lstrip("%").replace("-", "_") + index_match = re.search(r"\[(\d+)\]$", hook_attr) + if index_match: + # Strip the index for attribute lookup + base_hook_attr = re.sub(r"\[\d+\]$", "", hook_attr) + hook_val = getattr(hooks, base_hook_attr, None) + if isinstance(hook_val, SparseArray): + return hook_val.get(int(index_match.group(1))) + return hook_val + + return getattr(hooks, hook_attr, None) + + def set_hooks( + self, + hook: str, + values: HookValues, + *, + clear_existing: bool = False, + global_: bool | None = None, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> Self: + """Set multiple indexed hooks at once. + + Parameters + ---------- + hook : str + Hook name, e.g. 'session-renamed' + values : HookValues + Values to set. Can be: + - dict[int, str]: {0: 'cmd1', 1: 'cmd2'} - explicit indices + - SparseArray[str]: preserves indices from another hook + - list[str]: ['cmd1', 'cmd2'] - sequential indices starting at 0 + clear_existing : bool + If True, unset all existing hook values first + global_ : bool | None + Use global hooks + scope : OptionScope | None + Scope for the hook + + Returns + ------- + Self + Returns self for method chaining. + + Examples + -------- + Set hooks with explicit indices: + + >>> session.set_hooks('session-renamed', { + ... 0: 'display-message "hook 0"', + ... 1: 'display-message "hook 1"', + ... }) + Session($...) + + >>> hooks = session.show_hook('session-renamed') + >>> sorted(hooks.keys()) + [0, 1] + + >>> session.unset_hook('session-renamed') + Session($...) + + Set hooks from a list (sequential indices): + + >>> session.set_hooks('after-new-window', [ + ... 'select-pane -t 0', + ... 'send-keys "clear" Enter', + ... ]) + Session($...) + + >>> hooks = session.show_hook('after-new-window') + >>> sorted(hooks.keys()) + [0, 1] + + Replace all existing hooks with ``clear_existing=True``: + + >>> session.set_hooks( + ... 'session-renamed', + ... {0: 'display-message "new"'}, + ... clear_existing=True, + ... ) + Session($...) + + >>> hooks = session.show_hook('session-renamed') + >>> sorted(hooks.keys()) + [0] + + >>> session.unset_hook('session-renamed') + Session($...) + + >>> session.unset_hook('after-new-window') + Session($...) + """ + if clear_existing: + self.unset_hook(hook, global_=global_, scope=scope) + + # Convert list to dict with sequential indices + if isinstance(values, list): + values = dict(enumerate(values)) + + for index, value in values.items(): + self.set_hook( + f"{hook}[{index}]", + value, + global_=global_, + scope=scope, + ) + + return self diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 3ad975e7d..932f969e1 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1,7 +1,11 @@ """Tools for hydrating tmux data into python dataclass objects.""" + +from __future__ import annotations + import dataclasses import logging import typing as t +from collections.abc import Iterable from libtmux import exc from libtmux.common import tmux_cmd @@ -9,168 +13,163 @@ if t.TYPE_CHECKING: ListCmd = t.Literal["list-sessions", "list-windows", "list-panes"] - ListExtraArgs = t.Optional[t.Iterable[str]] + ListExtraArgs = Iterable[str] | None from libtmux.server import Server logger = logging.getLogger(__name__) -OutputRaw = t.Dict[str, t.Any] -OutputsRaw = t.List[OutputRaw] - - -""" -Quirks: - -QUIRK_TMUX_3_1_X_0001: - -- tmux 3.1 and 3.1a: -- server crash with list-panes w/ buffer_created, client_activity, client_created -""" +OutputRaw = dict[str, t.Any] +OutputsRaw = list[OutputRaw] @dataclasses.dataclass() class Obj: """Dataclass of generic tmux object.""" - server: "Server" - - active_window_index: t.Union[str, None] = None - alternate_saved_x: t.Union[str, None] = None - alternate_saved_y: t.Union[str, None] = None - # See QUIRK_TMUX_3_1_X_0001 - buffer_name: t.Union[str, None] = None - buffer_sample: t.Union[str, None] = None - buffer_size: t.Union[str, None] = None - # See QUIRK_TMUX_3_1_X_0001 - client_cell_height: t.Union[str, None] = None - client_cell_width: t.Union[str, None] = None - # See QUIRK_TMUX_3_1_X_0001 - client_discarded: t.Union[str, None] = None - client_flags: t.Union[str, None] = None - client_height: t.Union[str, None] = None - client_key_table: t.Union[str, None] = None - client_name: t.Union[str, None] = None - client_pid: t.Union[str, None] = None - client_termname: t.Union[str, None] = None - client_tty: t.Union[str, None] = None - client_uid: t.Union[str, None] = None - client_user: t.Union[str, None] = None - client_width: t.Union[str, None] = None - client_written: t.Union[str, None] = None - command_list_alias: t.Union[str, None] = None - command_list_name: t.Union[str, None] = None - command_list_usage: t.Union[str, None] = None - config_files: t.Union[str, None] = None - copy_cursor_line: t.Union[str, None] = None - copy_cursor_word: t.Union[str, None] = None - copy_cursor_x: t.Union[str, None] = None - copy_cursor_y: t.Union[str, None] = None - current_file: t.Union[str, None] = None - cursor_character: t.Union[str, None] = None - cursor_flag: t.Union[str, None] = None - cursor_x: t.Union[str, None] = None - cursor_y: t.Union[str, None] = None - history_bytes: t.Union[str, None] = None - history_limit: t.Union[str, None] = None - history_size: t.Union[str, None] = None - insert_flag: t.Union[str, None] = None - keypad_cursor_flag: t.Union[str, None] = None - keypad_flag: t.Union[str, None] = None - last_window_index: t.Union[str, None] = None - line: t.Union[str, None] = None - mouse_all_flag: t.Union[str, None] = None - mouse_any_flag: t.Union[str, None] = None - mouse_button_flag: t.Union[str, None] = None - mouse_sgr_flag: t.Union[str, None] = None - mouse_standard_flag: t.Union[str, None] = None - next_session_id: t.Union[str, None] = None - origin_flag: t.Union[str, None] = None - pane_active: t.Union[str, None] = None # Not detected by script - pane_bg: t.Union[str, None] = None - pane_bottom: t.Union[str, None] = None - pane_current_command: t.Union[str, None] = None - pane_current_path: t.Union[str, None] = None - pane_dead_signal: t.Union[str, None] = None - pane_dead_status: t.Union[str, None] = None - pane_dead_time: t.Union[str, None] = None - pane_fg: t.Union[str, None] = None - pane_height: t.Union[str, None] = None - pane_id: t.Union[str, None] = None - pane_index: t.Union[str, None] = None - pane_left: t.Union[str, None] = None - pane_pid: t.Union[str, None] = None - pane_right: t.Union[str, None] = None - pane_search_string: t.Union[str, None] = None - pane_start_command: t.Union[str, None] = None - pane_start_path: t.Union[str, None] = None - pane_tabs: t.Union[str, None] = None - pane_top: t.Union[str, None] = None - pane_tty: t.Union[str, None] = None - pane_width: t.Union[str, None] = None - - pid: t.Union[str, None] = None - scroll_position: t.Union[str, None] = None - scroll_region_lower: t.Union[str, None] = None - scroll_region_upper: t.Union[str, None] = None - search_match: t.Union[str, None] = None - selection_end_x: t.Union[str, None] = None - selection_end_y: t.Union[str, None] = None - selection_start_x: t.Union[str, None] = None - selection_start_y: t.Union[str, None] = None - session_activity: t.Union[str, None] = None - session_alerts: t.Union[str, None] = None - session_attached: t.Union[str, None] = None - session_attached_list: t.Union[str, None] = None - session_created: t.Union[str, None] = None - session_group: t.Union[str, None] = None - session_group_attached: t.Union[str, None] = None - session_group_list: t.Union[str, None] = None - session_group_size: t.Union[str, None] = None - session_id: t.Union[str, None] = None - session_last_attached: t.Union[str, None] = None - session_name: t.Union[str, None] = None - session_path: t.Union[str, None] = None - session_stack: t.Union[str, None] = None - session_windows: t.Union[str, None] = None - socket_path: t.Union[str, None] = None - start_time: t.Union[str, None] = None - uid: t.Union[str, None] = None - user: t.Union[str, None] = None - version: t.Union[str, None] = None - window_active: t.Union[str, None] = None # Not detected by script - window_active_clients: t.Union[str, None] = None - window_active_sessions: t.Union[str, None] = None - window_activity: t.Union[str, None] = None - window_cell_height: t.Union[str, None] = None - window_cell_width: t.Union[str, None] = None - window_height: t.Union[str, None] = None - window_id: t.Union[str, None] = None - window_index: t.Union[str, None] = None - window_layout: t.Union[str, None] = None - window_linked: t.Union[str, None] = None - window_linked_sessions: t.Union[str, None] = None - window_linked_sessions_list: t.Union[str, None] = None - window_marked_flag: t.Union[str, None] = None - window_name: t.Union[str, None] = None - window_offset_x: t.Union[str, None] = None - window_offset_y: t.Union[str, None] = None - window_panes: t.Union[str, None] = None - window_raw_flags: t.Union[str, None] = None - window_stack_index: t.Union[str, None] = None - window_width: t.Union[str, None] = None - wrap_flag: t.Union[str, None] = None + server: Server + + active_window_index: str | None = None + alternate_saved_x: str | None = None + alternate_saved_y: str | None = None + buffer_name: str | None = None + buffer_sample: str | None = None + buffer_size: str | None = None + client_cell_height: str | None = None + client_cell_width: str | None = None + client_discarded: str | None = None + client_flags: str | None = None + client_height: str | None = None + client_key_table: str | None = None + client_name: str | None = None + client_pid: str | None = None + client_termname: str | None = None + client_tty: str | None = None + client_uid: str | None = None + client_user: str | None = None + client_width: str | None = None + client_written: str | None = None + command_list_alias: str | None = None + command_list_name: str | None = None + command_list_usage: str | None = None + config_files: str | None = None + copy_cursor_line: str | None = None + copy_cursor_word: str | None = None + copy_cursor_x: str | None = None + copy_cursor_y: str | None = None + current_file: str | None = None + cursor_character: str | None = None + cursor_flag: str | None = None + cursor_x: str | None = None + cursor_y: str | None = None + history_bytes: str | None = None + history_limit: str | None = None + history_size: str | None = None + insert_flag: str | None = None + keypad_cursor_flag: str | None = None + keypad_flag: str | None = None + last_window_index: str | None = None + line: str | None = None + mouse_all_flag: str | None = None + mouse_any_flag: str | None = None + mouse_button_flag: str | None = None + mouse_sgr_flag: str | None = None + mouse_standard_flag: str | None = None + next_session_id: str | None = None + origin_flag: str | None = None + pane_active: str | None = None # Not detected by script + pane_at_bottom: str | None = None + pane_at_left: str | None = None + pane_at_right: str | None = None + pane_at_top: str | None = None + pane_bg: str | None = None + pane_bottom: str | None = None + pane_current_command: str | None = None + pane_current_path: str | None = None + pane_dead_signal: str | None = None + pane_dead_status: str | None = None + pane_dead_time: str | None = None + pane_fg: str | None = None + pane_height: str | None = None + pane_id: str | None = None + pane_index: str | None = None + pane_left: str | None = None + pane_pid: str | None = None + pane_right: str | None = None + pane_search_string: str | None = None + pane_start_command: str | None = None + pane_start_path: str | None = None + pane_tabs: str | None = None + pane_top: str | None = None + pane_tty: str | None = None + pane_width: str | None = None + pid: str | None = None + scroll_position: str | None = None + scroll_region_lower: str | None = None + scroll_region_upper: str | None = None + search_match: str | None = None + selection_end_x: str | None = None + selection_end_y: str | None = None + selection_start_x: str | None = None + selection_start_y: str | None = None + session_activity: str | None = None + session_alerts: str | None = None + session_attached: str | None = None + session_attached_list: str | None = None + session_created: str | None = None + session_group: str | None = None + session_group_attached: str | None = None + session_group_list: str | None = None + session_group_size: str | None = None + session_id: str | None = None + session_last_attached: str | None = None + session_name: str | None = None + session_path: str | None = None + session_stack: str | None = None + session_windows: str | None = None + socket_path: str | None = None + start_time: str | None = None + uid: str | None = None + user: str | None = None + version: str | None = None + window_active: str | None = None # Not detected by script + window_active_clients: str | None = None + window_active_sessions: str | None = None + window_activity: str | None = None + window_cell_height: str | None = None + window_cell_width: str | None = None + window_height: str | None = None + window_id: str | None = None + window_index: str | None = None + window_layout: str | None = None + window_linked: str | None = None + window_linked_sessions: str | None = None + window_linked_sessions_list: str | None = None + window_marked_flag: str | None = None + window_name: str | None = None + window_offset_x: str | None = None + window_offset_y: str | None = None + window_panes: str | None = None + window_raw_flags: str | None = None + window_stack_index: str | None = None + window_width: str | None = None + wrap_flag: str | None = None def _refresh( self, obj_key: str, obj_id: str, - list_cmd: "ListCmd" = "list-panes", + list_cmd: ListCmd = "list-panes", + list_extra_args: ListExtraArgs = None, ) -> None: assert isinstance(obj_id, str) obj = fetch_obj( - obj_key=obj_key, obj_id=obj_id, list_cmd=list_cmd, server=self.server + obj_key=obj_key, + obj_id=obj_id, + list_cmd=list_cmd, + list_extra_args=list_extra_args, + server=self.server, ) assert obj is not None if obj is not None: @@ -179,14 +178,14 @@ def _refresh( def fetch_objs( - server: "Server", - list_cmd: "ListCmd", - list_extra_args: "t.Optional[ListExtraArgs]" = None, + server: Server, + list_cmd: ListCmd, + list_extra_args: ListExtraArgs = None, ) -> OutputsRaw: """Fetch a listing of raw data from a tmux command.""" formats = list(Obj.__dataclass_fields__.keys()) - cmd_args: t.List[t.Union[str, int]] = [] + cmd_args: list[str | int] = [] if server.socket_name: cmd_args.insert(0, f"-L{server.socket_name}") @@ -199,10 +198,10 @@ def fetch_objs( list_cmd, ] - if list_extra_args is not None and isinstance(list_extra_args, t.Iterable): + if list_extra_args is not None and isinstance(list_extra_args, Iterable): tmux_cmds.extend(list(list_extra_args)) - tmux_cmds.append("-F%s" % "".join(tmux_formats)) + tmux_cmds.append("-F{}".format("".join(tmux_formats))) proc = tmux_cmd(*tmux_cmds) # output @@ -212,28 +211,26 @@ def fetch_objs( obj_output = proc.stdout obj_formatters = [ - dict(zip(formats, formatter.split(FORMAT_SEPARATOR))) + dict(zip(formats, formatter.split(FORMAT_SEPARATOR), strict=False)) for formatter in obj_output ] # Filter empty values - obj_formatters_filtered = [ - {k: v for k, v in formatter.items() if v} for formatter in obj_formatters - ] - - return obj_formatters_filtered + return [{k: v for k, v in formatter.items() if v} for formatter in obj_formatters] def fetch_obj( - server: "Server", + server: Server, obj_key: str, obj_id: str, - list_cmd: "ListCmd" = "list-panes", - list_extra_args: "t.Optional[ListExtraArgs]" = None, + list_cmd: ListCmd = "list-panes", + list_extra_args: ListExtraArgs = None, ) -> OutputRaw: """Fetch raw data from tmux command.""" obj_formatters_filtered = fetch_objs( - server=server, list_cmd=list_cmd, list_extra_args=list_extra_args + server=server, + list_cmd=list_cmd, + list_extra_args=list_extra_args, ) obj = None diff --git a/src/libtmux/options.py b/src/libtmux/options.py new file mode 100644 index 000000000..681b16575 --- /dev/null +++ b/src/libtmux/options.py @@ -0,0 +1,1262 @@ +# ruff: NOQA: E501 +"""Helpers for tmux options. + +Option parsing function trade testability and clarity for performance. + +Tmux options +------------ + +Options in tmux consist of empty values, strings, integers, arrays, and complex shapes. + +Marshalling types from text: + +Integers: ``buffer-limit 50`` to ``{'buffer-limit': 50}`` +Booleans: ``exit-unattached on`` to ``{'exit-unattached': True}`` + +Exploding arrays: + +``command-alias[1] split-pane=split-window`` to +``{'command-alias[1]': {'split-pane=split-window'}}`` + +However, there is no equivalent to the above type of object in Python (a sparse array), +so a SparseArray is used. + +Exploding complex shapes: + +``"choose-session=choose-tree -s"`` to ``{'choose-session': 'choose-tree -s'}`` + +Finally, we need to convert hyphenated keys to underscored attribute names and assign +values, as python does not allow hyphens in attribute names. + +``command-alias`` is ``command_alias`` in python. + +Options object +-------------- +Dataclasses are used to provide typed access to tmux' option shape. + +Extra data gleaned from the options, such as user options (custom data) and an option +being inherited, + +User options +------------ +There are also custom user options, preceded with @, which exist are stored to +`Options.context.user_options` as a dictionary. + +> tmux set-option -w my-custom-variable my-value +invalid option: my-custom-option + +> tmux set-option -w @my-custom-option my-value +> tmux show-option -w +@my-custom-optione my-value + +Inherited options +----------------- + +``tmux show-options`` -A can include inherited options. The raw output of an inherited +option is detected by the key having a ``*``:: + + visual-activity* on + visual-bell* off + +A list of options that are inherited is kept at ``Options.context._inherited_options`` and +``Options.context.inherited_options``. + +They are mixed with the normal options, +to differentiate them, run ``show_options()`` without ``include_inherited=True``. +""" + +from __future__ import annotations + +import io +import logging +import re +import shlex +import typing as t +import warnings + +from libtmux._internal.sparse_array import SparseArray +from libtmux.common import CmdMixin +from libtmux.constants import ( + DEFAULT_OPTION_SCOPE, + OPTION_SCOPE_FLAG_MAP, + OptionScope, + _DefaultOptionScope, +) + +from . import exc + +if t.TYPE_CHECKING: + from typing import TypeAlias + + from typing_extensions import Self + + from libtmux._internal.constants import TerminalFeatures + from libtmux.common import tmux_cmd + + +TerminalOverride = dict[str, str | None] +TerminalOverrides = dict[str, TerminalOverride] +CommandAliases = dict[str, str] + +OptionDict: TypeAlias = dict[str, t.Any] +UntypedOptionsDict: TypeAlias = dict[str, str | None] +ExplodedUntypedOptionsDict: TypeAlias = dict[ + str, + str | int | list[str] | dict[str, list[str]], +] +ExplodedComplexUntypedOptionsDict: TypeAlias = dict[ + str, + str + | int + | list[str | int] + | dict[str, list[str | int]] + | SparseArray[str | int] + | None, +] + +logger = logging.getLogger(__name__) + + +def handle_option_error(error: str) -> type[exc.OptionError]: + """Raise exception if error in option command found. + + In tmux 3.0, show-option and show-window-option return invalid option instead of + unknown option. See https://github.com/tmux/tmux/blob/3.0/cmd-show-options.c. + + In tmux >2.4, there are 3 different types of option errors: + + - unknown option + - invalid option + - ambiguous option + + In tmux <2.4, unknown option was the only option. + + All errors raised will have the base error of :exc:`exc.OptionError`. So to + catch any option error, use ``except exc.OptionError``. + + Parameters + ---------- + error : str + Error response from subprocess call. + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, :exc:`exc.InvalidOption`, + :exc:`exc.AmbiguousOption` + + Examples + -------- + >>> result = server.cmd( + ... 'set-option', + ... 'unknown-option-name', + ... ) + + >>> bool(isinstance(result.stderr, list) and len(result.stderr)) + True + + >>> import pytest + >>> from libtmux import exc + + >>> with pytest.raises(exc.OptionError): + ... handle_option_error(result.stderr[0]) + """ + if "unknown option" in error: + raise exc.UnknownOption(error) + if "invalid option" in error: + raise exc.InvalidOption(error) + if "ambiguous option" in error: + raise exc.AmbiguousOption(error) + raise exc.OptionError(error) # Raise generic option error + + +_V = t.TypeVar("_V") +ConvertedValue: TypeAlias = str | int | bool | None +ConvertedValues: TypeAlias = ( + ConvertedValue + | list[ConvertedValue] + | dict[str, ConvertedValue] + | SparseArray[ConvertedValue] +) + + +def convert_value( + value: _V | None, +) -> ConvertedValue | _V | None: + """Convert raw option strings to python types. + + Examples + -------- + >>> convert_value("on") + True + >>> convert_value("off") + False + + >>> convert_value("1") + 1 + >>> convert_value("50") + 50 + + >>> convert_value("%50") + '%50' + """ + if not isinstance(value, str): + return value + + if value.isdigit(): + return int(value) + + if value == "on": + return True + + if value == "off": + return False + + return value + + +def convert_values( + value: _V | None, +) -> ConvertedValues | _V | None: + """Recursively convert values to python types via :func:`convert_value`. + + >>> convert_values(None) + + >>> convert_values("on") + True + >>> convert_values("off") + False + + >>> convert_values(["on"]) + [True] + >>> convert_values(["off"]) + [False] + + >>> convert_values({"window_index": "1"}) + {'window_index': 1} + + >>> convert_values({"visual-bell": "on"}) + {'visual-bell': True} + """ + if value is None: + return None + if isinstance(value, dict): + # Note: SparseArray inherits from dict, so this branch handles both + for k, v in value.items(): + value[k] = convert_value(v) + return value + if isinstance(value, list): + for idx, v in enumerate(value): + value[idx] = convert_value(v) + return value + return convert_value(value) + + +def parse_options_to_dict( + stdout: t.IO[str], +) -> UntypedOptionsDict: + r"""Process subprocess.stdout options or hook output to flat, naive, untyped dict. + + Does not explode arrays or deep values. + + Examples + -------- + >>> import io + + >>> raw_options = io.StringIO("status-keys vi") + >>> parse_options_to_dict(raw_options) == {"status-keys": "vi"} + True + + >>> int_options = io.StringIO("message-limit 50") + >>> parse_options_to_dict(int_options) == {"message-limit": "50"} + True + + >>> empty_option = io.StringIO("user-keys") + >>> parse_options_to_dict(empty_option) == {"user-keys": None} + True + + >>> array_option = io.StringIO("command-alias[0] split-pane=split-window") + >>> parse_options_to_dict(array_option) == { + ... "command-alias[0]": "split-pane=split-window"} + True + + >>> array_option = io.StringIO("command-alias[40] split-pane=split-window") + >>> parse_options_to_dict(array_option) == { + ... "command-alias[40]": "split-pane=split-window"} + True + + >>> many_options = io.StringIO(r'''status-keys + ... command-alias[0] split-pane=split-window + ... ''') + >>> parse_options_to_dict(many_options) == { + ... "command-alias[0]": "split-pane=split-window", + ... "status-keys": None,} + True + + >>> many_more_options = io.StringIO(r''' + ... terminal-features[0] xterm*:clipboard:ccolour:cstyle:focus + ... terminal-features[1] screen*:title + ... ''') + >>> parse_options_to_dict(many_more_options) == { + ... "terminal-features[0]": "xterm*:clipboard:ccolour:cstyle:focus", + ... "terminal-features[1]": "screen*:title",} + True + + >>> quoted_option = io.StringIO(r''' + ... command-alias[0] "choose-session=choose-tree -s" + ... ''') + >>> parse_options_to_dict(quoted_option) == { + ... "command-alias[0]": "choose-session=choose-tree -s", + ... } + True + """ + output: UntypedOptionsDict = {} + + val: ConvertedValue | None = None + + for item in stdout.readlines(): + if " " in item: + try: + key, val = shlex.split(item) + except ValueError: + key, val = item.split(" ", maxsplit=1) + else: + key, val = item, None + key = key.strip() + + if key: + if isinstance(val, str) and val.endswith("\n"): + val = val.rstrip("\n") + + output[key] = val + return output + + +def explode_arrays( + _dict: UntypedOptionsDict, + force_array: bool = False, +) -> ExplodedUntypedOptionsDict: + """Explode flat, naive options dict's option arrays. + + Examples + -------- + >>> import io + + >>> many_more_options = io.StringIO(r''' + ... terminal-features[0] xterm*:clipboard:ccolour:cstyle:focus + ... terminal-features[1] screen*:title + ... ''') + >>> many_more_flat_dict = parse_options_to_dict(many_more_options) + >>> many_more_flat_dict == { + ... "terminal-features[0]": "xterm*:clipboard:ccolour:cstyle:focus", + ... "terminal-features[1]": "screen*:title",} + True + >>> explode_arrays(many_more_flat_dict) == { + ... "terminal-features": {0: "xterm*:clipboard:ccolour:cstyle:focus", + ... 1: "screen*:title"}} + True + + tmux arrays allow non-sequential indexes, so we need to support that: + + >>> explode_arrays(parse_options_to_dict(io.StringIO(r''' + ... terminal-features[0] xterm*:clipboard:ccolour:cstyle:focus + ... terminal-features[5] screen*:title + ... '''))) == { + ... "terminal-features": {0: "xterm*:clipboard:ccolour:cstyle:focus", + ... 5: "screen*:title"}} + True + + Use ``force_array=True`` for hooks, which always use array format: + + >>> from libtmux._internal.sparse_array import SparseArray + + >>> hooks_output = io.StringIO(r''' + ... session-renamed[0] display-message 'renamed' + ... session-renamed[5] refresh-client + ... pane-focus-in[0] run-shell 'echo focus' + ... ''') + >>> hooks_exploded = explode_arrays( + ... parse_options_to_dict(hooks_output), + ... force_array=True, + ... ) + + Each hook becomes a SparseArray preserving indices: + + >>> isinstance(hooks_exploded["session-renamed"], SparseArray) + True + >>> hooks_exploded["session-renamed"][0] + "display-message 'renamed'" + >>> hooks_exploded["session-renamed"][5] + 'refresh-client' + >>> sorted(hooks_exploded["session-renamed"].keys()) + [0, 5] + """ + options: dict[str, t.Any] = {} + for key, val in _dict.items(): + Default: type[dict[t.Any, t.Any] | SparseArray[str | int | bool | None]] = ( + dict if isinstance(key, str) and key == "terminal-features" else SparseArray + ) + if "[" not in key: + if force_array: + options[key] = Default() + if val is not None: + options[key][0] = val + else: + options[key] = val + continue + + try: + matchgroup = re.match( + r"(?P