From ff52d0d2e807c6d15e2cc983c5302f115f3c5534 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 08:59:15 -0500 Subject: [PATCH 01/89] deps(libtmux[~=0.55.0]): Bump from ~=0.53.0 for logging, set_title, tmux_bin why: Pick up three libtmux releases (0.53.1, 0.54.0, 0.55.0) bringing structured logging, new Pane API, configurable tmux binary, and several bug fixes that improve error propagation. what: - Bump libtmux dependency specifier ~=0.53.0 -> ~=0.55.0 in pyproject.toml - Update uv.lock (resolved 0.53.1 -> 0.55.0) libtmux 0.55.0 (2026-03-07): - Pane.set_title() wraps select-pane -T; pane_title added to format queries - Server(tmux_bin=) threads custom binary through all commands and version checks - Pre-execution DEBUG logging in tmux_cmd with structured extra - TmuxCommandNotFound raised consistently for invalid tmux_bin paths libtmux 0.54.0 (2026-03-07): - Structured lifecycle logging (INFO) across Server, Session, Window, Pane - NullHandler in __init__.py; lazy %s formatting; isEnabledFor guards - Window.rename_window() now raises on failure instead of swallowing - Server.kill() captures stderr, handles "no server running" gracefully - Server.new_session() checks kill-session stderr - Session.kill_window() target formatting fix (session_name, not window_name) libtmux 0.53.1 (2026-02-18): - Fix race condition in new_session() by avoiding list-sessions query Release: https://github.com/tmux-python/libtmux/releases/tag/v0.55.0 CHANGES: https://github.com/tmux-python/tmuxp/blob/v1.64.1/CHANGES#tmuxp-1641-2026-03-08 Changelog: https://libtmux.git-pull.com/history.html#libtmux-0-55-0-2026-03-07 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0fbcc71110..fd514ee180 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ include = [ { path = "conftest.py", format = "sdist" }, ] dependencies = [ - "libtmux~=0.53.0", + "libtmux~=0.55.0", "colorama>=0.3.9", "PyYAML>=6.0" ] diff --git a/uv.lock b/uv.lock index 39ff327f7d..0e77745341 100644 --- a/uv.lock +++ b/uv.lock @@ -510,11 +510,11 @@ wheels = [ [[package]] name = "libtmux" -version = "0.53.1" +version = "0.55.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/99/0ac0f60d5b93a8a291be02ed1f3fcf70ff50c0526fa9a99eb462d74354b1/libtmux-0.53.1.tar.gz", hash = "sha256:0d9ca4bcf5c0fb7d7a1e4ce0c0cdcbcd7fb354a66819c3d60ccea779d83eac83", size = 413660, upload-time = "2026-02-19T00:44:24.761Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/85/99932ac9ddb90821778f8cabe32b81bbbec280dd1a14a457c512693fb11b/libtmux-0.55.0.tar.gz", hash = "sha256:cdc4aa564b2325618d73d57cb0d7d92475d02026dba2b96a94f87ad328e7e79d", size = 420859, upload-time = "2026-03-08T00:57:55.788Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/58/4a7195e692a4aedf88f3f2701db5a06e730447b504747b19385eb141b718/libtmux-0.53.1-py3-none-any.whl", hash = "sha256:8db49f32a1d5ac0f44ed6b76558c7a3baba701fbbbf6c66a31045f7f779b71a0", size = 78395, upload-time = "2026-02-19T00:44:22.961Z" }, + { url = "https://files.pythonhosted.org/packages/8b/34/b11ab24abb78c73a1b82f6471c2d71bdd1bf2c8f30768ed2f26f1dddc083/libtmux-0.55.0-py3-none-any.whl", hash = "sha256:4b746533856e022c759e5c5cae97f4932e85dae316a2afd4391d6d0e891d6ab8", size = 80094, upload-time = "2026-03-08T00:57:54.141Z" }, ] [[package]] @@ -1463,7 +1463,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "colorama", specifier = ">=0.3.9" }, - { name = "libtmux", specifier = "~=0.53.0" }, + { name = "libtmux", specifier = "~=0.55.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] From 094800f484c67993eb790c2efea3c2731df59869 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 09:03:57 -0500 Subject: [PATCH 02/89] docs(CHANGES) libtmux ~=0.55.0 bump with logging, set_title, tmux_bin why: Document the dependency bump for the upcoming release. what: - Add Development entry for libtmux ~=0.53.0 -> ~=0.55.0 bump - Summarize key upstream changes across three releases --- CHANGES | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGES b/CHANGES index 93b4431235..2f731110b0 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,14 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +### Breaking Changes + +#### **libtmux** minimum bumped from `~=0.53.0` to `~=0.55.0` (#1019) + + Picks up three releases: 0.53.1 (race condition fix in `new_session()`), + 0.54.0 (structured lifecycle logging, error propagation fixes), and + 0.55.0 (`Pane.set_title()`, `Server(tmux_bin=)`, pre-execution DEBUG logging). + ## tmuxp 1.64.1 (2026-03-08) ### Bug fixes From 54aadae523d4250a0ae90851cc640b3253f46baa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 11:08:42 -0500 Subject: [PATCH 03/89] Tag v1.65.0 (libtmux v0.55.0) --- CHANGES | 4 +++- pyproject.toml | 2 +- src/tmuxp/__about__.py | 2 +- uv.lock | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index a82d848c37..95d8c3e57e 100644 --- a/CHANGES +++ b/CHANGES @@ -27,7 +27,7 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force // Usage: tmuxp@next load yoursession ``` -## tmuxp 1.65.0 (Yet to be released) +## tmuxp 1.66.0 (Yet to be released) @@ -35,6 +35,8 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +## tmuxp 1.65.0 (2026-03-08) + ### Breaking Changes #### **libtmux** minimum bumped from `~=0.53.0` to `~=0.55.0` (#1019) diff --git a/pyproject.toml b/pyproject.toml index 0b11c4f57d..4e908892de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.64.2" +version = "1.65.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index aaf0ffb342..adf89c7b0c 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -4,7 +4,7 @@ __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.64.2" +__version__ = "1.65.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/uv.lock b/uv.lock index 334996cdc0..0542d5e6f4 100644 --- a/uv.lock +++ b/uv.lock @@ -1379,7 +1379,7 @@ wheels = [ [[package]] name = "tmuxp" -version = "1.64.2" +version = "1.65.0" source = { editable = "." } dependencies = [ { name = "colorama" }, From 69a886c0864c4f98afcd4af42ef521e30f31ad4f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:42:27 -0500 Subject: [PATCH 04/89] refactor(logging[infra]): add structured logging infrastructure to all modules why: Enable structured logging with `extra` context for filtering, testing, and aggregation. Library modules need NullHandler per Python best practices. what: - Add `logging.getLogger(__name__)` to every module - Add NullHandler in library `__init__.py` - Add TmuxpLoggerAdapter with process() override for Python <3.13 compat - Simplify tmuxp_echo to pure print wrapper (decoupled from logging) - Add setup_log_file() for centralized --log-file handler setup - Fix setup_logger to target "tmuxp" logger, skip NullHandler check - Change default CLI log level from INFO to WARNING - Fix timestamp format bug: %H:%m:%S -> %H:%M:%S - Add future annotations and fix import ordering in _compat.py --- src/tmuxp/__about__.py | 4 ++ src/tmuxp/__init__.py | 5 ++ src/tmuxp/_compat.py | 16 +++-- src/tmuxp/_internal/__init__.py | 6 ++ src/tmuxp/_internal/private_path.py | 3 + src/tmuxp/_internal/types.py | 3 + src/tmuxp/cli/__init__.py | 6 +- src/tmuxp/cli/_colors.py | 4 ++ src/tmuxp/cli/_formatter.py | 3 + src/tmuxp/cli/load.py | 10 ++- src/tmuxp/cli/search.py | 3 + src/tmuxp/exc.py | 4 ++ src/tmuxp/log.py | 104 ++++++++++++++++++--------- src/tmuxp/types.py | 3 + src/tmuxp/workspace/__init__.py | 6 ++ src/tmuxp/workspace/constants.py | 4 ++ tests/test_log.py | 105 ++++++++++++++++++++++++++++ 17 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 tests/test_log.py diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index adf89c7b0c..cb95133689 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -2,6 +2,10 @@ from __future__ import annotations +import logging + +logger = logging.getLogger(__name__) + __title__ = "tmuxp" __package_name__ = "tmuxp" __version__ = "1.65.0" diff --git a/src/tmuxp/__init__.py b/src/tmuxp/__init__.py index 25c46f4d21..78e2118ea5 100644 --- a/src/tmuxp/__init__.py +++ b/src/tmuxp/__init__.py @@ -6,6 +6,8 @@ from __future__ import annotations +import logging + from . import cli, util from .__about__ import ( __author__, @@ -17,3 +19,6 @@ __title__, __version__, ) + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) diff --git a/src/tmuxp/_compat.py b/src/tmuxp/_compat.py index ca83962641..7888a50b46 100644 --- a/src/tmuxp/_compat.py +++ b/src/tmuxp/_compat.py @@ -1,18 +1,26 @@ -# flake8: NOQA +from __future__ import annotations + +import logging import sys +logger = logging.getLogger(__name__) + PY3 = sys.version_info[0] == 3 PYMINOR = sys.version_info[1] PYPATCH = sys.version_info[2] -_identity = lambda x: x + +def _identity(x: object) -> object: + """Return *x* unchanged — used as a no-op decorator.""" + return x + if PY3 and PYMINOR >= 7: - breakpoint = breakpoint + breakpoint = breakpoint # noqa: A001 else: import pdb - breakpoint = pdb.set_trace + breakpoint = pdb.set_trace # noqa: A001 implements_to_string = _identity diff --git a/src/tmuxp/_internal/__init__.py b/src/tmuxp/_internal/__init__.py index 01dccbcfcb..baae40fd2c 100644 --- a/src/tmuxp/_internal/__init__.py +++ b/src/tmuxp/_internal/__init__.py @@ -1 +1,7 @@ """Internal APIs for tmuxp.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/_internal/private_path.py b/src/tmuxp/_internal/private_path.py index 2ab8a998ae..8fa0cec972 100644 --- a/src/tmuxp/_internal/private_path.py +++ b/src/tmuxp/_internal/private_path.py @@ -6,10 +6,13 @@ from __future__ import annotations +import logging import os import pathlib import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: PrivatePathBase = pathlib.Path else: diff --git a/src/tmuxp/_internal/types.py b/src/tmuxp/_internal/types.py index a3521f5832..41498cebd0 100644 --- a/src/tmuxp/_internal/types.py +++ b/src/tmuxp/_internal/types.py @@ -12,9 +12,12 @@ from __future__ import annotations +import logging import typing as t from typing import TypedDict +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: import sys diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index b05708321f..860a9200cb 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -181,9 +181,9 @@ def create_parser() -> argparse.ArgumentParser: "--log-level", action="store", metavar="log-level", - default="info", + default="warning", choices=["debug", "info", "warning", "error", "critical"], - help='log level (debug, info, warning, error, critical) (default "info")', + help='log level (debug, info, warning, error, critical) (default "warning")', ) parser.add_argument( "--color", @@ -297,7 +297,7 @@ def cli(_args: list[str] | None = None) -> None: parser = create_parser() args = parser.parse_args(_args, namespace=ns) - setup_logger(logger=logger, level=args.log_level.upper()) + setup_logger(level=args.log_level.upper()) if args.subparser_name is None: parser.print_help() diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 9932218fb6..2513d118a8 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -9,6 +9,8 @@ from __future__ import annotations +import logging + from tmuxp._internal.colors import ( ColorMode, Colors, @@ -20,6 +22,8 @@ unstyle, ) +logger = logging.getLogger(__name__) + __all__ = [ "ColorMode", "Colors", diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py index 9dfceaf29e..d4efb9ff47 100644 --- a/src/tmuxp/cli/_formatter.py +++ b/src/tmuxp/cli/_formatter.py @@ -13,9 +13,12 @@ from __future__ import annotations import argparse +import logging import re import typing as t +logger = logging.getLogger(__name__) + # Options that expect a value (set externally or via --option=value) OPTIONS_EXPECTING_VALUE = frozenset( { diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 3e6edbd2b7..fd88efcf10 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -23,6 +23,8 @@ from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt_choices, prompt_yes_no, tmuxp_echo +logger = logging.getLogger(__name__) + LOAD_DESCRIPTION = build_description( """ Load tmuxp workspace file(s) and create or attach to a tmux session. @@ -74,6 +76,7 @@ class CLILoadNamespace(argparse.Namespace): colors: CLIColorsLiteral | None color: CLIColorModeLiteral log_file: str | None + log_level: str def load_plugins( @@ -590,12 +593,7 @@ def command_load( cli_colors = Colors(get_color_mode(args.color)) if args.log_file: - logfile_handler = logging.FileHandler(args.log_file) - logfile_handler.setFormatter(log.LogFormatter()) - # Add handler to tmuxp root logger to capture all tmuxp log messages - tmuxp_logger = logging.getLogger("tmuxp") - tmuxp_logger.setLevel(logging.INFO) # Ensure logger level allows INFO - tmuxp_logger.addHandler(logfile_handler) + log.setup_log_file(args.log_file, args.log_level) if args.workspace_files is None or len(args.workspace_files) == 0: tmuxp_echo(cli_colors.error("Enter at least one config")) diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index 93368fd04f..3be4cb1974 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -25,6 +25,7 @@ import argparse import json +import logging import pathlib import re import typing as t @@ -39,6 +40,8 @@ from ._colors import Colors, build_description, get_color_mode from ._output import OutputFormatter, get_output_mode +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py index 525599270f..545038d7ca 100644 --- a/src/tmuxp/exc.py +++ b/src/tmuxp/exc.py @@ -2,10 +2,14 @@ from __future__ import annotations +import logging + from libtmux._internal.query_list import ObjectDoesNotExist from ._compat import implements_to_string +logger = logging.getLogger(__name__) + class TmuxpException(Exception): """Base Exception for Tmuxp Errors.""" diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index e4429eda6a..15367d5310 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -4,12 +4,13 @@ from __future__ import annotations import logging +import sys import time import typing as t from colorama import Fore, Style -from tmuxp._internal.colors import unstyle +logger = logging.getLogger(__name__) LEVEL_COLORS = { "DEBUG": Fore.BLUE, # Blue @@ -19,14 +20,23 @@ "CRITICAL": Fore.RED, } -LOG_LEVELS = { - "CRITICAL": 50, - "ERROR": 40, - "WARNING": 30, - "INFO": 20, - "DEBUG": 10, - "NOTSET": 0, -} + +class TmuxpLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] + """LoggerAdapter that merges extra dictionary on Python < 3.13. + + Follows the portable pattern to avoid repeating the same `extra` on every call + while preserving the ability to add per-call `extra` kwargs. + """ + + def process( + self, msg: t.Any, kwargs: t.MutableMapping[str, t.Any] + ) -> tuple[t.Any, t.MutableMapping[str, t.Any]]: + """Merge extra dictionary on Python < 3.13.""" + extra = dict(self.extra) if self.extra else {} + if "extra" in kwargs: + extra.update(kwargs["extra"]) + kwargs["extra"] = extra + return msg, kwargs def setup_logger( @@ -43,10 +53,17 @@ def setup_logger( logger instance for tmuxp """ if not logger: # if no logger exists, make one - logger = logging.getLogger() + logger = logging.getLogger("tmuxp") + + has_handlers = any(not isinstance(h, logging.NullHandler) for h in logger.handlers) - if not logger.handlers: # setup logger handlers - logger.setLevel(level) + if not has_handlers: # setup logger handlers + channel = logging.StreamHandler() + formatter = DebugLogFormatter() if level == "DEBUG" else LogFormatter() + channel.setFormatter(formatter) + logger.addHandler(channel) + + logger.setLevel(level) def set_style( @@ -126,7 +143,7 @@ def format(self, record: logging.LogRecord) -> str: except Exception as e: record.message = f"Bad message ({e!r}): {record.__dict__!r}" - date_format = "%H:%m:%S" + date_format = "%H:%M:%S" formatting = self.converter(record.created) record.asctime = time.strftime(date_format, formatting) @@ -204,42 +221,61 @@ class DebugLogFormatter(LogFormatter): template = debug_log_template -# Use tmuxp root logger so messages propagate to CLI handlers -_echo_logger = logging.getLogger("tmuxp") +def setup_log_file(log_file: str, level: str = "INFO") -> None: + """Attach a file handler to the tmuxp logger. + + Parameters + ---------- + log_file : str + Path to the log file. + level : str + Log level name (e.g. "DEBUG", "INFO"). Selects formatter and sets + handler filtering level. + + Examples + -------- + >>> import tempfile, os, logging + >>> f = tempfile.NamedTemporaryFile(suffix=".log", delete=False) + >>> f.close() + >>> setup_log_file(f.name, level="INFO") + >>> tmuxp_logger = logging.getLogger("tmuxp") + >>> tmuxp_logger.handlers = [ + ... h for h in tmuxp_logger.handlers if not isinstance(h, logging.FileHandler) + ... ] + >>> os.unlink(f.name) + """ + handler = logging.FileHandler(log_file) + formatter = DebugLogFormatter() if level.upper() == "DEBUG" else LogFormatter() + handler.setFormatter(formatter) + handler_level = getattr(logging, level.upper()) + handler.setLevel(handler_level) + tmuxp_logger = logging.getLogger("tmuxp") + tmuxp_logger.addHandler(handler) + if tmuxp_logger.level == logging.NOTSET or tmuxp_logger.level > handler_level: + tmuxp_logger.setLevel(handler_level) def tmuxp_echo( message: str | None = None, - log_level: str = "INFO", - style_log: bool = False, + file: t.TextIO | None = None, ) -> None: - """Combine logging.log and print for CLI output. + """Print user-facing CLI output. Parameters ---------- message : str | None - Message to log and print. If None, does nothing. - log_level : str - Log level to use (DEBUG, INFO, WARNING, ERROR, CRITICAL). - Default is INFO. - style_log : bool - If True, preserve ANSI styling in log output. - If False, strip ANSI codes from log output. Default is False. + Message to print. If None, does nothing. + file : t.TextIO | None + Output stream. Defaults to sys.stdout. Examples -------- - >>> tmuxp_echo("Session loaded") # doctest: +ELLIPSIS + >>> tmuxp_echo("Session loaded") Session loaded - >>> tmuxp_echo("Warning message", log_level="WARNING") # doctest: +ELLIPSIS + >>> tmuxp_echo("Warning message") Warning message """ if message is None: return - - if style_log: - _echo_logger.log(LOG_LEVELS[log_level], message) - else: - _echo_logger.log(LOG_LEVELS[log_level], unstyle(message)) - - print(message) + print(message, file=file or sys.stdout) diff --git a/src/tmuxp/types.py b/src/tmuxp/types.py index 4d267ae2ee..4aa5031239 100644 --- a/src/tmuxp/types.py +++ b/src/tmuxp/types.py @@ -9,8 +9,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from os import PathLike diff --git a/src/tmuxp/workspace/__init__.py b/src/tmuxp/workspace/__init__.py index ac87e57f62..2b3996b050 100644 --- a/src/tmuxp/workspace/__init__.py +++ b/src/tmuxp/workspace/__init__.py @@ -1 +1,7 @@ """tmuxp workspace functionality.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/workspace/constants.py b/src/tmuxp/workspace/constants.py index 48ecc9c1f6..4a39082b6d 100644 --- a/src/tmuxp/workspace/constants.py +++ b/src/tmuxp/workspace/constants.py @@ -2,4 +2,8 @@ from __future__ import annotations +import logging + +logger = logging.getLogger(__name__) + VALID_WORKSPACE_DIR_FILE_EXTENSIONS = [".yaml", ".yml", ".json"] diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000000..b916b37b94 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,105 @@ +"""Tests for tmuxp.log module.""" + +from __future__ import annotations + +import logging +import sys + +import pytest + +from tmuxp.log import ( + LEVEL_COLORS, + DebugLogFormatter, + LogFormatter, + tmuxp_echo, +) + + +def test_level_colors_no_colorama() -> None: + """LEVEL_COLORS must be raw ANSI escape strings, not colorama objects.""" + for level, code in LEVEL_COLORS.items(): + assert code.startswith("\033["), ( + f"LEVEL_COLORS[{level!r}] should start with ANSI ESC, got {code!r}" + ) + + +def test_log_formatter_format_plain_text() -> None: + """LogFormatter.format() produces plain text without ANSI when unstylized.""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "test message" in output + assert "\033[" not in output + + +def test_debug_log_formatter_format_smoke() -> None: + """DebugLogFormatter.format() runs without error.""" + formatter = DebugLogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.DEBUG, + pathname="", + lineno=42, + msg="debug message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "debug message" in output + + +def test_timestamp_format_has_minutes() -> None: + """Timestamp format must use %M (minutes), not %m (month).""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="ts check", + args=(), + exc_info=None, + ) + formatter.format(record) + # asctime is set during format(); if %m were used, seconds portion would + # show month (01-12) instead of minutes (00-59) — we can't easily + # distinguish that directly, so just verify the format string constant. + # Inspect the source: date_format in LogFormatter.format is "%H:%M:%S" + import inspect + + import tmuxp.log as log_module + + src = inspect.getsource(log_module.LogFormatter.format) + assert '"%H:%M:%S"' in src, "Timestamp format must be %H:%M:%S (M = minutes)" + + +def test_tmuxp_echo_default_stdout(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stdout by default.""" + tmuxp_echo("hello stdout") + captured = capsys.readouterr() + assert captured.out == "hello stdout\n" + assert captured.err == "" + + +def test_tmuxp_echo_to_stderr(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stderr when file=sys.stderr.""" + tmuxp_echo("hello stderr", file=sys.stderr) + captured = capsys.readouterr() + assert captured.err == "hello stderr\n" + assert captured.out == "" + + +def test_tmuxp_echo_none_is_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo(None) produces no output.""" + tmuxp_echo(None) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" From 1c828c48a754968f96b9861774c05d5df56a0adf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:46:15 -0500 Subject: [PATCH 05/89] feat(logging[extra]): add structured log calls with extra context across all modules why: Structured `extra` keys (tmux_session, tmux_window, tmux_pane, tmux_config_path) enable filtering, aggregation, and test assertions on log records rather than string matching. what: - Add structured DEBUG/INFO/WARNING/ERROR log calls to workspace, CLI, and utility modules with appropriate extra keys - Route all raw print() calls through tmuxp_echo() in CLI commands - Fix get_pane() exception catch type to match sibling methods - Change before_script failure log from DEBUG to ERROR - Remove catch-log-reraise in plugin version check --- src/tmuxp/_internal/config_reader.py | 4 ++++ src/tmuxp/cli/convert.py | 7 ++++-- src/tmuxp/cli/edit.py | 6 +++++- src/tmuxp/cli/freeze.py | 16 ++++++++------ src/tmuxp/cli/import_config.py | 7 ++++++ src/tmuxp/cli/load.py | 32 ++++++++++++++++++---------- src/tmuxp/cli/shell.py | 8 +++++-- src/tmuxp/cli/utils.py | 5 ++++- src/tmuxp/plugin.py | 4 ++++ src/tmuxp/shell.py | 19 ++++++++++------- src/tmuxp/util.py | 16 +++++++++++--- src/tmuxp/workspace/builder.py | 30 ++++++++++++++++++++++++++ src/tmuxp/workspace/finders.py | 22 ++++++++++++++++--- src/tmuxp/workspace/freezer.py | 12 +++++++++++ src/tmuxp/workspace/importers.py | 17 +++++++++++++++ src/tmuxp/workspace/loader.py | 10 +++++++++ src/tmuxp/workspace/validation.py | 12 +++++++++++ 17 files changed, 190 insertions(+), 37 deletions(-) diff --git a/src/tmuxp/_internal/config_reader.py b/src/tmuxp/_internal/config_reader.py index 3c667bbceb..6da248dea7 100644 --- a/src/tmuxp/_internal/config_reader.py +++ b/src/tmuxp/_internal/config_reader.py @@ -3,11 +3,14 @@ from __future__ import annotations import json +import logging import pathlib import typing as t import yaml +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias @@ -106,6 +109,7 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: {'session_name': 'my session'} """ assert isinstance(path, pathlib.Path) + logger.debug("loading config", extra={"tmux_config_path": str(path)}) content = path.open(encoding="utf-8").read() if path.suffix in {".yaml", ".yml"}: diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index 97d2d8cd25..a92cfebbfa 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -3,6 +3,7 @@ from __future__ import annotations import locale +import logging import os import pathlib import typing as t @@ -13,7 +14,9 @@ from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir from ._colors import Colors, build_description, get_color_mode -from .utils import prompt_yes_no +from .utils import prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) CONVERT_DESCRIPTION = build_description( """ @@ -130,7 +133,7 @@ def command_convert( new_workspace, encoding=locale.getpreferredencoding(False), ) - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.success("New workspace file saved to ") + colors.info(str(PrivatePath(newfile))) + ".", diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index 006ad6bb12..7308f2eba7 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import subprocess import typing as t @@ -10,6 +11,9 @@ from tmuxp.workspace.finders import find_workspace_file from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) EDIT_DESCRIPTION = build_description( """ @@ -59,7 +63,7 @@ def command_edit( workspace_file = find_workspace_file(workspace_file) sys_editor = os.environ.get("EDITOR", "vim") - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.muted("Opening ") + colors.info(str(PrivatePath(workspace_file))) + colors.muted(" in ") diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 9b48ebf01e..fa26569ca7 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -4,6 +4,7 @@ import argparse import locale +import logging import os import pathlib import sys @@ -19,7 +20,9 @@ from tmuxp.workspace.finders import get_workspace_dir from ._colors import Colors, build_description, get_color_mode -from .utils import prompt, prompt_choices, prompt_yes_no +from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) FREEZE_DESCRIPTION = build_description( """ @@ -141,7 +144,7 @@ def command_freeze( if not session: raise exc.SessionNotFound except TmuxpException as e: - print(colors.error(str(e))) # NOQA: T201 RUF100 + tmuxp_echo(colors.error(str(e))) return frozen_workspace = freezer.freeze(session) @@ -149,7 +152,7 @@ def command_freeze( configparser = ConfigReader(workspace) if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.format_separator(63) + "\n" + colors.muted("Freeze does its best to snapshot live tmux sessions.") @@ -163,7 +166,7 @@ def command_freeze( ) ): if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.muted("tmuxp has examples in JSON and YAML format at ") + colors.info("") + "\n" @@ -190,7 +193,7 @@ def command_freeze( color_mode=color_mode, ) if not args.force and os.path.exists(dest_prompt): - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.warning(f"{PrivatePath(dest_prompt)} exists.") + " " + colors.muted("Pick a new filename."), @@ -252,8 +255,9 @@ def extract_workspace_format( workspace, encoding=locale.getpreferredencoding(False), ) + logger.info("workspace saved", extra={"tmux_config_path": str(dest)}) if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", ) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index 63c2d24a30..df49c221ba 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -3,6 +3,7 @@ from __future__ import annotations import locale +import logging import os import pathlib import sys @@ -16,6 +17,8 @@ from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo +logger = logging.getLogger(__name__) + IMPORT_DESCRIPTION = build_description( """ Import workspaces from teamocil and tmuxinator configuration files. @@ -220,6 +223,10 @@ def import_config( encoding=locale.getpreferredencoding(False), ) + logger.info( + "workspace saved", + extra={"tmux_config_path": str(dest)}, + ) tmuxp_echo( colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", ) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index fd88efcf10..b8184999eb 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -123,6 +123,7 @@ def load_plugins( module_name = ".".join(module_name[:-1]) plugin_name = plugin.split(".")[-1] except AttributeError as error: + logger.exception("plugin load failed") tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -139,12 +140,16 @@ def load_plugins( default=True, color_mode=colors.mode, ): + logger.warning( + "plugin version constraint not met, user declined skip", + ) tmuxp_echo( colors.warning("[Not Skipping]") + " Plugin versions constraint not met. Exiting...", ) sys.exit(1) except (ImportError, AttributeError) as error: + logger.exception("plugin import failed") tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -178,7 +183,8 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: plugin.reattach(builder.session) proc = builder.session.cmd("display-message", "-p", "'#S'") for line in proc.stdout: - print(colors.info(line) if colors else line) # NOQA: T201 RUF100 + tmuxp_echo(colors.info(line) if colors else line) + logger.debug("reattach display-message output: %s", line.strip()) if "TMUX" in os.environ: builder.session.switch_client() @@ -225,7 +231,8 @@ def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> N assert builder.session is not None msg = "Session created in detached state." - print(colors.info(msg) if colors else msg) # NOQA: T201 RUF100 + tmuxp_echo(colors.info(msg) if colors else msg) + logger.info("session created in detached state") def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: @@ -347,10 +354,9 @@ def load_workspace( if isinstance(workspace_file, (str, os.PathLike)): workspace_file = pathlib.Path(workspace_file) - tmuxp_echo( - cli_colors.info("[Loading]") - + " " - + cli_colors.highlight(str(PrivatePath(workspace_file))), + logger.info( + "loading workspace", + extra={"tmux_config_path": str(workspace_file)}, ) # ConfigReader allows us to open a yaml or json file as a dict @@ -378,13 +384,18 @@ def load_workspace( shutil.which("tmux") # raise exception if tmux not found - try: # load WorkspaceBuilder object for tmuxp workspace / tmux server + # WorkspaceBuilder creation — outside spinner so plugin prompts are safe + try: builder = WorkspaceBuilder( session_config=expanded_workspace, plugins=load_plugins(expanded_workspace, colors=cli_colors), server=t, ) except exc.EmptyWorkspaceException: + logger.warning( + "workspace file is empty", + extra={"tmux_config_path": str(workspace_file)}, + ) tmuxp_echo( cli_colors.warning("[Warning]") + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", @@ -393,7 +404,7 @@ def load_workspace( session_name = expanded_workspace["session_name"] - # if the session already exists, prompt the user to attach + # Session-exists check — outside spinner so prompt_yes_no is safe if builder.session_exists(session_name) and not append: if not detached and ( answer_yes @@ -442,9 +453,7 @@ def load_workspace( _load_attached(builder, detached) except exc.TmuxpException as e: - import traceback - - tmuxp_echo(traceback.format_exc()) + logger.exception("workspace build failed") tmuxp_echo(cli_colors.error("[Error]") + f" {e}") choice = prompt_choices( @@ -459,6 +468,7 @@ def load_workspace( if builder.session is not None: builder.session.kill() tmuxp_echo(cli_colors.muted("Session killed.")) + logger.info("session killed by user after build error") elif choice == "a": _reattach(builder, cli_colors) else: diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index e62f0a0758..57a5ae8e4b 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os import pathlib import typing as t @@ -13,6 +14,9 @@ from tmuxp._compat import PY3, PYMINOR from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) SHELL_DESCRIPTION = build_description( """ @@ -222,7 +226,7 @@ def command_shell( ): from tmuxp._compat import breakpoint as tmuxp_breakpoint - print( # NOQA: T201 RUF100 + tmuxp_echo( cli_colors.muted("Launching ") + cli_colors.highlight("pdb", bold=False) + cli_colors.muted(" shell..."), @@ -233,7 +237,7 @@ def command_shell( from tmuxp.shell import launch shell_name = args.shell or "best" - print( # NOQA: T201 RUF100 + tmuxp_echo( cli_colors.muted("Launching ") + cli_colors.highlight(shell_name, bold=False) + cli_colors.muted(" shell for session ") diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 58896deb0f..034c98b2ed 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t from tmuxp._internal.colors import ( @@ -15,6 +16,8 @@ from tmuxp._internal.private_path import PrivatePath from tmuxp.log import tmuxp_echo +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -215,7 +218,7 @@ def prompt_choices( return None if rv in choices_: return rv - print( + tmuxp_echo( colors.warning(f"Invalid choice '{rv}'. ") + f"Please choose from: {', '.join(choices_)}" ) diff --git a/src/tmuxp/plugin.py b/src/tmuxp/plugin.py index 84be58d96d..fa153c7771 100644 --- a/src/tmuxp/plugin.py +++ b/src/tmuxp/plugin.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import libtmux @@ -11,6 +12,8 @@ from .__about__ import __version__ from .exc import TmuxpPluginException +logger = logging.getLogger(__name__) + #: Minimum version of tmux required to run tmuxp TMUX_MIN_VERSION = "3.2" @@ -181,6 +184,7 @@ def __init__(self, **kwargs: Unpack[PluginConfigSchema]) -> None: def _version_check(self) -> None: """Check all dependency versions for compatibility.""" + logger.debug("checking version constraints for %s", self.plugin_name) for dep, constraints in self.version_constraints.items(): assert isinstance(constraints, dict) try: diff --git a/src/tmuxp/shell.py b/src/tmuxp/shell.py index aea0e92020..3d56655ba5 100644 --- a/src/tmuxp/shell.py +++ b/src/tmuxp/shell.py @@ -106,14 +106,17 @@ def has_bpython() -> bool: def detect_best_shell() -> CLIShellLiteral: """Return the best, most feature-rich shell available.""" if has_ptipython(): - return "ptipython" - if has_ptpython(): - return "ptpython" - if has_ipython(): - return "ipython" - if has_bpython(): - return "bpython" - return "code" + shell: CLIShellLiteral = "ptipython" + elif has_ptpython(): + shell = "ptpython" + elif has_ipython(): + shell = "ipython" + elif has_bpython(): + shell = "bpython" + else: + shell = "code" + logger.debug("detected shell: %s", shell) + return shell def get_bpython( diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 490ee7e940..f069a47aec 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -10,6 +10,7 @@ import typing as t from . import exc +from .log import tmuxp_echo if t.TYPE_CHECKING: import pathlib @@ -110,7 +111,9 @@ def oh_my_zsh_auto_title() -> None: or os.environ.get("DISABLE_AUTO_TITLE") == "false" ) ): - print( # NOQA: T201 RUF100 + logger.warning("oh-my-zsh DISABLE_AUTO_TITLE not set") + tmuxp_echo( + "oh-my-zsh DISABLE_AUTO_TITLE not set.\n\n" "Please set:\n\n" "\texport DISABLE_AUTO_TITLE='true'\n\n" "in ~/.zshrc or where your zsh profile is stored.\n" @@ -189,8 +192,15 @@ def get_pane(window: Window, current_pane: Pane | None = None) -> Pane: pane = window.panes.get(pane_id=current_pane.pane_id) else: pane = window.active_pane - except exc.TmuxpException as e: - print(e) # NOQA: T201 RUF100 + except Exception as e: + logger.debug( + "pane lookup failed", + exc_info=True, + extra={"tmux_pane": str(current_pane) if current_pane else ""}, + ) + if current_pane: + raise exc.PaneNotFound(str(current_pane)) from e + raise exc.PaneNotFound from e if pane is None: if current_pane: diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 24c93b3c24..18154b997a 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -15,6 +15,7 @@ from libtmux.window import Window from tmuxp import exc +from tmuxp.log import TmuxpLoggerAdapter from tmuxp.util import get_current_pane, run_before_script if t.TYPE_CHECKING: @@ -332,6 +333,11 @@ def build(self, session: Session | None = None, append: bool = False) -> None: assert session.name is not None self._session = session + _log = TmuxpLoggerAdapter( + logger, + {"tmux_session": self.session_config["session_name"]}, + ) + _log.info("session created") assert session.server is not None @@ -355,8 +361,12 @@ def build(self, session: Session | None = None, append: bool = False) -> None: # session start directory, if it exists. if "start_directory" in self.session_config: cwd = self.session_config["start_directory"] + _log.debug( + "running before script", + ) run_before_script(self.session_config["before_script"], cwd=cwd) except Exception: + _log.error("before script failed") self.session.kill() raise @@ -400,6 +410,8 @@ def build(self, session: Session | None = None, append: bool = False) -> None: if focus: focus.select() + _log.info("workspace built") + def iter_create_windows( self, session: Session, @@ -469,6 +481,14 @@ def iter_create_windows( environment=environment, ) assert isinstance(window, Window) + window_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": session.name or "", + "tmux_window": window_name or "", + }, + ) + window_log.debug("window created") if is_first_window_pass: # if first window, use window 1 session.active_window.kill() @@ -563,6 +583,15 @@ def get_pane_shell( ) assert isinstance(pane, Pane) + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) + pane_log.debug("pane created") # Skip readiness wait when a custom shell/command launcher is set. # The shell/window_shell key runs a command (e.g. "top", "sleep 999") @@ -594,6 +623,7 @@ def get_pane_shell( time.sleep(sleep_before) pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) + pane_log.debug("sent command %s", cmd["cmd"]) if sleep_after is not None: time.sleep(sleep_after) diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index da19bcc887..2bc7704c28 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -142,6 +142,12 @@ def find_local_workspace_files( if start_dir is None: start_dir = os.getcwd() + logger.debug( + "searching for local workspace files from %s", + start_dir, + extra={"tmux_config_path": str(start_dir)}, + ) + current = pathlib.Path(start_dir).resolve() home = pathlib.Path.home().resolve() found: list[pathlib.Path] = [] @@ -361,12 +367,17 @@ def find_workspace_file( ] if len(candidates) > 1: + logger.warning( + "multiple workspace files found, use distinct file names" + " to avoid ambiguity", + extra={"tmux_config_path": workspace_file}, + ) colors = Colors(ColorMode.AUTO) tmuxp_echo( colors.error( - "Multiple .tmuxp.{yml,yaml,json} workspace_files in " - + dirname(workspace_file) - ), + "Multiple .tmuxp.{yaml,yml,json} files found in " + + str(workspace_file) + ) ) tmuxp_echo( "This is undefined behavior, use only one. " @@ -383,6 +394,11 @@ def find_workspace_file( if file_error: raise FileNotFoundError(file_error, workspace_file) + logger.debug( + "resolved workspace file %s", + workspace_file, + extra={"tmux_config_path": workspace_file}, + ) return workspace_file diff --git a/src/tmuxp/workspace/freezer.py b/src/tmuxp/workspace/freezer.py index 8807e9e43e..7ec302494f 100644 --- a/src/tmuxp/workspace/freezer.py +++ b/src/tmuxp/workspace/freezer.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from libtmux.pane import Pane from libtmux.session import Session @@ -64,6 +67,8 @@ def freeze(session: Session) -> dict[str, t.Any]: dict tmuxp compatible workspace """ + logger.debug("freezing session", extra={"tmux_session": session.session_name}) + session_config: dict[str, t.Any] = { "session_name": session.session_name, "windows": [], @@ -119,5 +124,12 @@ def filter_interpreters_and_shells(current_cmd: str | None) -> bool: window_config["panes"].append(pane_config) session_config["windows"].append(window_config) + logger.debug( + "frozen window", + extra={ + "tmux_session": session.session_name, + "tmux_window": window.name, + }, + ) return session_config diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index fda361ca6f..65184d73a4 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. @@ -19,6 +22,14 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + logger.debug( + "importing tmuxinator workspace", + extra={ + "tmux_session": workspace_dict.get("project_name") + or workspace_dict.get("name", ""), + }, + ) + tmuxp_workspace: dict[str, t.Any] = {} if "project_name" in workspace_dict: @@ -122,6 +133,12 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: - clear - cmd_separator """ + _inner = workspace_dict.get("session", workspace_dict) + logger.debug( + "importing teamocil workspace", + extra={"tmux_session": _inner.get("name", "")}, + ) + tmuxp_workspace: dict[str, t.Any] = {} if "session" in workspace_dict: diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 613b2b589d..9efcd05b52 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -101,6 +101,11 @@ def expand( ------- dict """ + logger.debug( + "expanding workspace config", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + # Note: cli.py will expand workspaces relative to project's workspace directory # for the first cwd argument. cwd = pathlib.Path().cwd() if not cwd else pathlib.Path(cwd) @@ -207,6 +212,11 @@ def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + logger.debug( + "trickling down workspace defaults", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + # prepends a pane's ``shell_command`` list with the window and sessions' # ``shell_command_before``. diff --git a/src/tmuxp/workspace/validation.py b/src/tmuxp/workspace/validation.py index 2573209a5d..e7fe4da743 100644 --- a/src/tmuxp/workspace/validation.py +++ b/src/tmuxp/workspace/validation.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging import typing as t from tmuxp import exc +logger = logging.getLogger(__name__) + class SchemaValidationError(exc.WorkspaceError): """Tmuxp configuration validation base error.""" @@ -70,6 +73,15 @@ def validate_schema(workspace_dict: t.Any) -> bool: ------- bool """ + logger.debug( + "validating workspace schema", + extra={ + "tmux_session": workspace_dict.get("session_name", "") + if isinstance(workspace_dict, dict) + else "", + }, + ) + # verify session_name if "session_name" not in workspace_dict: raise SessionNameMissingValidationError From aaaffe5f7e4f8b5c4976ed39d8e9820a17f29521 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:48:32 -0500 Subject: [PATCH 06/89] test(logging[caplog]): add structured log assertions across all modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Verify structured extra keys on log records using caplog.records, per AGENTS.md guidelines — assert on attributes, not string matching. what: - Add caplog tests for builder, freezer, finders, loader, validation, importers, and plugin version_check - Add log-level filtering test for --log-file - Add ANSI-free assertions to JSON/NDJSON output tests (ls, search) - Add non-TTY stderr ANSI-free test for load command --- tests/cli/test_load.py | 74 +++++++++++++++---- tests/cli/test_ls.py | 2 + tests/cli/test_search.py | 2 + tests/test_plugin.py | 14 ++++ tests/test_util.py | 70 +++++++++++++++++- tests/workspace/test_builder.py | 89 ++++++++++++++++++++++- tests/workspace/test_config.py | 47 ++++++++++++ tests/workspace/test_finder.py | 52 +++++++++++++ tests/workspace/test_freezer.py | 28 +++++++ tests/workspace/test_import_teamocil.py | 18 +++++ tests/workspace/test_import_tmuxinator.py | 16 ++++ 11 files changed, 395 insertions(+), 17 deletions(-) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 2191b7320c..9ecffd2471 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -17,7 +17,6 @@ from tmuxp import cli from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli._colors import ColorMode, Colors from tmuxp.cli.load import ( _load_append_windows_to_current_session, _load_attached, @@ -446,7 +445,7 @@ class LogFileTestFixture(t.NamedTuple): LOG_FILE_TEST_FIXTURES: list[LogFileTestFixture] = [ LogFileTestFixture( test_id="load_with_log_file", - cli_args=["load", ".", "--log-file", "log.txt", "-d"], + cli_args=["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"], ), ] @@ -484,10 +483,45 @@ def test_load_log_file( result = capsys.readouterr() log_file_path = tmp_path / "log.txt" - assert "Loading" in log_file_path.open().read() + assert "loading workspace" in log_file_path.open().read() assert result.out is not None +def test_load_log_file_level_filtering( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Log-level filtering: INFO log file should not contain DEBUG messages.""" + tmuxp_config_path = tmp_path / ".tmuxp.yaml" + tmuxp_config_path.write_text( + """ +session_name: hello + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + with contextlib.suppress(Exception): + cli.cli(["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"]) + + log_file_path = tmp_path / "log.txt" + log_contents = log_file_path.read_text() + + # INFO-level messages should appear + assert "loading workspace" in log_contents.lower() or len(log_contents) > 0 + + # No DEBUG-level markers should appear in an INFO-level log file + for line in log_contents.splitlines(): + assert "(DEBUG)" not in line, ( + f"DEBUG message leaked into INFO-level log file: {line}" + ) + + def test_load_plugins( monkeypatch_plugin_test_packages: None, ) -> None: @@ -548,7 +582,7 @@ def test_load_plugins_version_fail_skip( result = capsys.readouterr() - assert "[Loading]" in result.out + assert "Loading" in result.out or "Loaded" in result.out PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES: list[PluginVersionTestFixture] = [ @@ -758,18 +792,28 @@ def test_load_append_windows_to_current_session( # Privacy masking in load command -def test_load_masks_home_in_loading_message(monkeypatch: pytest.MonkeyPatch) -> None: - """Load command should mask home directory in [Loading] message.""" +def test_load_no_ansi_in_nontty_stderr( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """No ANSI escape codes in stderr when running in non-TTY context (CI/pipe).""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + load_workspace(str(session_file), socket_name=server.socket_name, detached=True) + + captured = capsys.readouterr() + assert "\x1b[" not in captured.err, "ANSI codes leaked into non-TTY stderr" + + +def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) workspace_file = pathlib.Path("/home/testuser/work/project/.tmuxp.yaml") - output = ( - colors.info("[Loading]") - + " " - + colors.highlight(str(PrivatePath(workspace_file))) - ) + private_path = str(PrivatePath(workspace_file)) + message = f"Loading workspace: myproject ({private_path})" - assert "~/work/project/.tmuxp.yaml" in output - assert "/home/testuser" not in output + assert "~/work/project/.tmuxp.yaml" in message + assert "/home/testuser" not in message diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 40e1526839..e6f64e28fc 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -161,6 +161,7 @@ def test_ls_json_output( cli.cli(["ls", "--json"]) output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" data = json.loads(output) # JSON output is now an object with workspaces and global_workspace_dirs @@ -200,6 +201,7 @@ def test_ls_ndjson_output( cli.cli(["ls", "--ndjson"]) output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" lines = [line for line in output.strip().split("\n") if line] assert len(lines) == 2 diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py index e4140b2645..9e67266b86 100644 --- a/tests/cli/test_search.py +++ b/tests/cli/test_search.py @@ -830,6 +830,7 @@ def test_output_search_results_json(capsys: pytest.CaptureFixture[str]) -> None: formatter.finalize() captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" data = json.loads(captured.out) assert len(data) == 1 assert data[0]["name"] == "dev" @@ -857,6 +858,7 @@ def test_output_search_results_ndjson(capsys: pytest.CaptureFixture[str]) -> Non formatter.finalize() captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" lines = captured.out.strip().split("\n") # Filter out human-readable lines json_lines = [line for line in lines if line.startswith("{")] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index cf7cfc6371..6d7fda7fd1 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + import pytest from tmuxp.exc import TmuxpPluginException @@ -95,3 +97,15 @@ def test_libtmux_version_fail_incompatible() -> None: with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: LibtmuxVersionFailIncompatiblePlugin() assert "libtmux-incompatible-version-fail" in str(exc_info.value) + + +def test_plugin_version_check_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """_version_check() logs DEBUG with plugin name.""" + with caplog.at_level(logging.DEBUG, logger="tmuxp.plugin"): + AllVersionPassPlugin() + records = [ + r for r in caplog.records if r.msg == "checking version constraints for %s" + ] + assert len(records) >= 1 diff --git a/tests/test_util.py b/tests/test_util.py index baa592e9a8..098c8c212b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -2,6 +2,9 @@ from __future__ import annotations +import logging +import os +import pathlib import sys import typing as t @@ -9,7 +12,7 @@ from tmuxp import exc from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists -from tmuxp.util import get_session, run_before_script +from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script from .constants import FIXTURE_PATH @@ -166,3 +169,68 @@ def test_get_session_should_return_first_session_if_no_active_session( server.new_session(session_name="mysecondsession") assert get_session(server) == first_session + + +def test_get_pane_logs_debug_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """get_pane() logs DEBUG with tmux_pane extra when pane lookup fails.""" + session = server.new_session(session_name="test_pane_log") + window = session.active_window + + # Make active_pane raise Exception to trigger the logging path + monkeypatch.setattr( + type(window), + "active_pane", + property(lambda self: (_ for _ in ()).throw(Exception("mock pane error"))), + ) + + with ( + caplog.at_level(logging.DEBUG, logger="tmuxp.util"), + pytest.raises(exc.PaneNotFound), + ): + get_pane(window, current_pane=None) + + debug_records = [ + r + for r in caplog.records + if hasattr(r, "tmux_pane") and r.levelno == logging.DEBUG + ] + assert len(debug_records) >= 1 + assert debug_records[0].tmux_pane == "" + + +def test_oh_my_zsh_auto_title_logs_warning( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + tmp_path: t.Any, +) -> None: + """oh_my_zsh_auto_title() logs WARNING when DISABLE_AUTO_TITLE not set.""" + monkeypatch.setenv("SHELL", "/bin/zsh") + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + + # Create fake ~/.oh-my-zsh directory + fake_home = tmp_path / "home" + fake_home.mkdir() + oh_my_zsh_dir = fake_home / ".oh-my-zsh" + oh_my_zsh_dir.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + # Patch os.path.exists to return True for ~/.oh-my-zsh + original_exists = os.path.exists + + def patched_exists(path: str) -> bool: + if path == str(pathlib.Path("~/.oh-my-zsh").expanduser()): + return True + return original_exists(path) + + monkeypatch.setattr(os.path, "exists", patched_exists) + + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + oh_my_zsh_auto_title() + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "DISABLE_AUTO_TITLE" in warning_records[0].message diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 6b78dfcbd3..da95168f46 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import logging import os import pathlib import textwrap @@ -697,6 +698,7 @@ def test_window_index( def test_before_script_throw_error_if_retcode_error( server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test tmuxp configuration before_script when command fails.""" config_script_fails = test_utils.read_workspace_file( @@ -716,12 +718,20 @@ def test_before_script_throw_error_if_retcode_error( session_name = sess.name assert session_name is not None - with pytest.raises(exc.BeforeLoadScriptError): + with ( + caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), + pytest.raises(exc.BeforeLoadScriptError), + ): builder.build(session=sess) result = server.has_session(session_name) assert not result, "Kills session if before_script exits with errcode" + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) >= 1 + assert error_records[0].msg == "before script failed" + assert hasattr(error_records[0], "tmux_session") + def test_before_script_throw_error_if_file_not_exists( server: Server, @@ -1681,3 +1691,80 @@ def counting_layout(self: Window, layout: str | None = None) -> Window: builder.build() # 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6 assert call_count == 3 + + +def test_builder_logs_session_created( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder.build() logs INFO with tmux_session extra.""" + workspace = { + "session_name": "test_log_session", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + session_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_session") and r.msg == "session created" + ] + assert len(session_logs) >= 1 + assert session_logs[0].tmux_session == "test_log_session" + + # Verify workspace built log + built_logs = [r for r in caplog.records if r.msg == "workspace built"] + assert len(built_logs) >= 1 + + builder.session.kill() + + +def test_builder_logs_window_and_pane_creation( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder logs DEBUG with tmux_window and tmux_pane extra.""" + workspace = { + "session_name": "test_log_wp", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": [{"cmd": "echo hello"}]}, + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + window_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_window") and r.msg == "window created" + ] + assert len(window_logs) >= 1 + assert window_logs[0].tmux_window == "editor" + + pane_logs = [ + r for r in caplog.records if hasattr(r, "tmux_pane") and r.msg == "pane created" + ] + assert len(pane_logs) >= 1 + + cmd_logs = [r for r in caplog.records if r.msg == "sent command %s"] + assert len(cmd_logs) >= 1 + + builder.session.kill() diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 02ebcf5ffa..fc6d5ccd5b 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import pathlib import typing as t @@ -330,3 +331,49 @@ def test_validate_plugins() -> None: with pytest.raises(exc.WorkspaceError) as excinfo: validation.validate_schema(sconfig) assert excinfo.match("only supports list type") + + +def test_expand_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """expand() logs DEBUG with tmux_session extra.""" + workspace = {"session_name": "test_expand", "windows": [{"window_name": "main"}]} + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.expand(workspace, cwd=str(tmp_path)) + records = [r for r in caplog.records if r.msg == "expanding workspace config"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_expand" + + +def test_trickle_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """trickle() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_trickle", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.trickle(workspace) + records = [ + r for r in caplog.records if r.msg == "trickling down workspace defaults" + ] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_trickle" + + +def test_validate_schema_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """validate_schema() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_validate", + "windows": [{"window_name": "main"}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.validation"): + validation.validate_schema(workspace) + records = [r for r in caplog.records if r.msg == "validating workspace schema"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_validate" diff --git a/tests/workspace/test_finder.py b/tests/workspace/test_finder.py index dd0b270bb8..ab9a69dba4 100644 --- a/tests/workspace/test_finder.py +++ b/tests/workspace/test_finder.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import pathlib import typing as t @@ -11,6 +12,7 @@ from tmuxp import cli from tmuxp.cli.utils import tmuxp_echo from tmuxp.workspace.finders import ( + find_local_workspace_files, find_workspace_file, get_workspace_dir, get_workspace_dir_candidates, @@ -514,3 +516,53 @@ def test_get_workspace_dir_candidates_uses_private_path( path = candidate["path"] assert str(home) not in path, f"Path should be masked: {path}" assert path.startswith("~"), f"Path should start with ~: {path}" + + +def test_find_workspace_file_logs_warning_on_multiple( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """find_workspace_file() logs WARNING when multiple workspace files found.""" + project = tmp_path / "project" + project.mkdir() + + # Create multiple .tmuxp files in the same directory + (project / ".tmuxp.yaml").write_text("session_name: test") + (project / ".tmuxp.json").write_text('{"session_name": "test"}') + + monkeypatch.chdir(project) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.finders"): + find_workspace_file(str(project)) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "multiple workspace files found" in warning_records[0].message + assert hasattr(warning_records[0], "tmux_config_path") + + out = capsys.readouterr().out + assert "Multiple .tmuxp." in out + assert "undefined behavior" in out + + +def test_find_local_workspace_files_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """find_local_workspace_files() logs DEBUG with tmux_config_path extra.""" + project = tmp_path / "project" + project.mkdir() + (project / ".tmuxp.yaml").write_text("session_name: test") + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.finders"): + find_local_workspace_files(project, stop_at_home=False) + + records = [ + r + for r in caplog.records + if r.msg == "searching for local workspace files from %s" + ] + assert len(records) >= 1 + assert hasattr(records[0], "tmux_config_path") diff --git a/tests/workspace/test_freezer.py b/tests/workspace/test_freezer.py index 42fa6cc581..d42386ecef 100644 --- a/tests/workspace/test_freezer.py +++ b/tests/workspace/test_freezer.py @@ -2,9 +2,12 @@ from __future__ import annotations +import logging import time import typing +import pytest + from tests.fixtures import utils as test_utils from tmuxp._internal.config_reader import ConfigReader from tmuxp.workspace import freezer, validation @@ -106,3 +109,28 @@ def test_export_yaml( new_workspace_data = ConfigReader._from_file(yaml_workspace_file) assert config_fixture.sample_workspace.sample_workspace_dict == new_workspace_data + + +def test_freeze_logs_debug( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """freeze() logs DEBUG with tmux_session extra.""" + session_config = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/freezer/sample_workspace.yaml"), + ) + builder = WorkspaceBuilder(session_config=session_config, server=session.server) + builder.build(session=session) + + time.sleep(0.50) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.freezer"): + freezer.freeze(session) + + freeze_records = [r for r in caplog.records if r.msg == "freezing session"] + assert len(freeze_records) >= 1 + assert hasattr(freeze_records[0], "tmux_session") + + window_records = [r for r in caplog.records if r.msg == "frozen window"] + assert len(window_records) >= 1 + assert hasattr(window_records[0], "tmux_window") diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 7de727684b..0ea457e7c6 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import pytest @@ -139,3 +140,20 @@ def test_multisession_config( validation.validate_schema( importers.import_teamocil(multisession_config[session_name]), ) + + +def test_import_teamocil_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_teamocil() logs DEBUG record.""" + workspace = { + "session": { + "name": "test", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + records = [r for r in caplog.records if r.msg == "importing teamocil workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 23f567ae5d..457605f2ab 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import pytest @@ -60,3 +61,18 @@ def test_config_to_dict( assert importers.import_tmuxinator(tmuxinator_dict) == tmuxp_dict validation.validate_schema(importers.import_tmuxinator(tmuxinator_dict)) + + +def test_import_tmuxinator_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_tmuxinator() logs DEBUG record.""" + workspace = { + "name": "test", + "windows": [{"main": ["echo hi"]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + records = [r for r in caplog.records if r.msg == "importing tmuxinator workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" From 3d7b95138b3879d12912a8a56101f15b660c6839 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:48:45 -0500 Subject: [PATCH 07/89] refactor(deps[colorama]): replace colorama with stdlib ANSI constants why: colorama wraps fixed ANSI escape string constants the stdlib can provide directly. Removing it shrinks the dependency tree. what: - Replace all colorama Fore/Style references in log.py with raw ANSI escapes via _ansi_colors from tmuxp._internal.colors - Remove colorama and types-colorama from pyproject.toml --- pyproject.toml | 3 --- src/tmuxp/log.py | 67 +++++++++++++++++++++++++----------------------- uv.lock | 15 ----------- 3 files changed, 35 insertions(+), 50 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4e908892de..aa884dea6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ include = [ ] dependencies = [ "libtmux~=0.55.0", - "colorama>=0.3.9", "PyYAML>=6.0" ] @@ -82,7 +81,6 @@ dev = [ # Lint "ruff", "mypy", - "types-colorama", "types-docutils", "types-Pygments", "types-PyYAML", @@ -118,7 +116,6 @@ coverage =[ lint = [ "ruff", "mypy", - "types-colorama", "types-docutils", "types-Pygments", "types-PyYAML", diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index 15367d5310..cec5da5ac4 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -8,16 +8,20 @@ import time import typing as t -from colorama import Fore, Style +from tmuxp._internal.colors import _ansi_colors, _ansi_reset_all logger = logging.getLogger(__name__) +_ANSI_RESET = _ansi_reset_all # "\033[0m" +_ANSI_BRIGHT = "\033[1m" +_ANSI_FG_RESET = "\033[39m" + LEVEL_COLORS = { - "DEBUG": Fore.BLUE, # Blue - "INFO": Fore.GREEN, # Green - "WARNING": Fore.YELLOW, - "ERROR": Fore.RED, - "CRITICAL": Fore.RED, + "DEBUG": f"\033[{_ansi_colors['blue']}m", + "INFO": f"\033[{_ansi_colors['green']}m", + "WARNING": f"\033[{_ansi_colors['yellow']}m", + "ERROR": f"\033[{_ansi_colors['red']}m", + "CRITICAL": f"\033[{_ansi_colors['red']}m", } @@ -103,27 +107,27 @@ def template( str Template for logger message. """ - reset = Style.RESET_ALL + reset = _ANSI_RESET levelname = set_style( "(%(levelname)s)", stylized, - style_before=(LEVEL_COLORS.get(record.levelname, "") + Style.BRIGHT), - style_after=Style.RESET_ALL, + style_before=(LEVEL_COLORS.get(record.levelname, "") + _ANSI_BRIGHT), + style_after=_ANSI_RESET, suffix=" ", ) asctime = set_style( "%(asctime)s", stylized, - style_before=(Fore.BLACK + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), + style_before=(f"\033[{_ansi_colors['black']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), prefix="[", suffix="]", ) name = set_style( "%(name)s", stylized, - style_before=(Fore.WHITE + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), + style_before=(f"\033[{_ansi_colors['white']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), prefix=" ", suffix=" ", ) @@ -173,42 +177,41 @@ def debug_log_template( str Log template. """ - reset = Style.RESET_ALL + reset = _ANSI_RESET levelname = ( LEVEL_COLORS.get(record.levelname, "") - + Style.BRIGHT + + _ANSI_BRIGHT + "(%(levelname)1.1s)" - + Style.RESET_ALL + + _ANSI_RESET + " " ) asctime = ( "[" - + Fore.BLACK - + Style.DIM - + Style.BRIGHT + + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + "%(asctime)s" - + Fore.RESET - + Style.RESET_ALL + + _ANSI_FG_RESET + + _ANSI_RESET + "]" ) name = ( " " - + Fore.WHITE - + Style.DIM - + Style.BRIGHT + + f"\033[{_ansi_colors['white']}m" + + _ANSI_BRIGHT + "%(name)s" - + Fore.RESET - + Style.RESET_ALL + + _ANSI_FG_RESET + + _ANSI_RESET + " " ) - module_funcName = Fore.GREEN + Style.BRIGHT + "%(module)s.%(funcName)s()" + module_funcName = ( + f"\033[{_ansi_colors['green']}m" + _ANSI_BRIGHT + "%(module)s.%(funcName)s()" + ) lineno = ( - Fore.BLACK - + Style.DIM - + Style.BRIGHT + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + ":" - + Style.RESET_ALL - + Fore.CYAN + + _ANSI_RESET + + f"\033[{_ansi_colors['cyan']}m" + "%(lineno)d" ) diff --git a/uv.lock b/uv.lock index 0542d5e6f4..df6b02b5b6 100644 --- a/uv.lock +++ b/uv.lock @@ -1382,7 +1382,6 @@ name = "tmuxp" version = "1.65.0" source = { editable = "." } dependencies = [ - { name = "colorama" }, { name = "libtmux" }, { name = "pyyaml" }, ] @@ -1420,7 +1419,6 @@ dev = [ { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1447,7 +1445,6 @@ docs = [ lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1462,7 +1459,6 @@ testing = [ [package.metadata] requires-dist = [ - { name = "colorama", specifier = ">=0.3.9" }, { name = "libtmux", specifier = "~=0.55.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] @@ -1496,7 +1492,6 @@ dev = [ { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1519,7 +1514,6 @@ docs = [ lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1586,15 +1580,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] -[[package]] -name = "types-colorama" -version = "0.4.15.20250801" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/37/af713e7d73ca44738c68814cbacf7a655aa40ddd2e8513d431ba78ace7b3/types_colorama-0.4.15.20250801.tar.gz", hash = "sha256:02565d13d68963d12237d3f330f5ecd622a3179f7b5b14ee7f16146270c357f5", size = 10437, upload-time = "2025-08-01T03:48:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/3a/44ccbbfef6235aeea84c74041dc6dfee6c17ff3ddba782a0250e41687ec7/types_colorama-0.4.15.20250801-py3-none-any.whl", hash = "sha256:b6e89bd3b250fdad13a8b6a465c933f4a5afe485ea2e2f104d739be50b13eea9", size = 10743, upload-time = "2025-08-01T03:48:21.774Z" }, -] - [[package]] name = "types-docutils" version = "0.22.3.20260223" From ad99470b2b47b2a48820946bfcb3adb693f8ac5c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:48:56 -0500 Subject: [PATCH 08/89] feat(output[emit_object]): add OutputFormatter.emit_object and Colors.format_rule why: ls and debug-info bypassed OutputFormatter with raw sys.stdout.write, breaking the 2-channel output architecture for machine-readable output. what: - Add OutputFormatter.emit_object() for single top-level JSON objects - Route ls --json/--ndjson and debug-info --json through emit_object() - Add Colors.format_rule() for Unicode box-drawing horizontal rules - Add unit tests for emit_object in JSON, NDJSON, and HUMAN modes --- src/tmuxp/_internal/colors.py | 30 ++++++++++++++++++++++ src/tmuxp/cli/_output.py | 46 +++++++++++++++++++++++++++++++++ src/tmuxp/cli/debug_info.py | 10 ++++---- src/tmuxp/cli/ls.py | 12 ++++----- tests/cli/test_output.py | 48 +++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 12 deletions(-) diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py index a25442d83c..73db6062fa 100644 --- a/src/tmuxp/_internal/colors.py +++ b/src/tmuxp/_internal/colors.py @@ -43,11 +43,14 @@ from __future__ import annotations import enum +import logging import os import re import sys import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias @@ -470,6 +473,33 @@ def format_separator(self, length: int = 25) -> str: """ return self.muted("-" * length) + def format_rule(self, width: int = 40, char: str = "─") -> str: + """Format a horizontal rule using Unicode box-drawing characters. + + A richer alternative to ``format_separator()`` which uses plain hyphens. + + Parameters + ---------- + width : int + Number of characters. Default is 40. + char : str + Character to repeat. Default is ``"─"`` (U+2500). + + Returns + ------- + str + Muted (blue) rule when colors enabled, plain rule otherwise. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_rule(10) + '──────────' + >>> colors.format_rule(5, char="=") + '=====' + """ + return self.muted(char * width) + def format_kv(self, key: str, value: str) -> str: """Format key: value pair with syntax highlighting. diff --git a/src/tmuxp/cli/_output.py b/src/tmuxp/cli/_output.py index 7ac8df92ef..b62f1cc05c 100644 --- a/src/tmuxp/cli/_output.py +++ b/src/tmuxp/cli/_output.py @@ -25,9 +25,12 @@ import enum import json +import logging import sys import typing as t +logger = logging.getLogger(__name__) + class OutputMode(enum.Enum): """Output format modes for CLI commands. @@ -117,6 +120,49 @@ def emit_text(self, text: str) -> None: sys.stdout.write(text + "\n") sys.stdout.flush() + def emit_object(self, data: dict[str, t.Any]) -> None: + """Emit a single top-level JSON object (not a list of records). + + For commands that produce one structured object rather than a stream of + records. Writes immediately without buffering; does not affect + ``_json_buffer``. + + In JSON mode, writes indented JSON followed by a newline. + In NDJSON mode, writes compact single-line JSON followed by a newline. + In HUMAN mode, does nothing (use ``emit_text`` for human output). + + Parameters + ---------- + data : dict + The object to emit. + + Examples + -------- + >>> import io, sys + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit_object({"status": "ok", "count": 3}) + { + "status": "ok", + "count": 3 + } + >>> formatter._json_buffer # buffer is unaffected + [] + + >>> formatter2 = OutputFormatter(OutputMode.NDJSON) + >>> formatter2.emit_object({"status": "ok", "count": 3}) + {"status": "ok", "count": 3} + + >>> formatter3 = OutputFormatter(OutputMode.HUMAN) + >>> formatter3.emit_object({"status": "ok"}) # no output in HUMAN mode + """ + if self.mode == OutputMode.JSON: + sys.stdout.write(json.dumps(data, indent=2) + "\n") + sys.stdout.flush() + elif self.mode == OutputMode.NDJSON: + sys.stdout.write(json.dumps(data) + "\n") + sys.stdout.flush() + # HUMAN: no-op + def finalize(self) -> None: """Finalize output (flush JSON buffer if needed). diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index 284ee462b3..8a69f81bdd 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os import pathlib import platform @@ -17,8 +18,11 @@ from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, OutputMode from .utils import tmuxp_echo +logger = logging.getLogger(__name__) + DEBUG_INFO_DESCRIPTION = build_description( """ Print diagnostic information for debugging and issue reports. @@ -243,9 +247,6 @@ def command_debug_info( parser: argparse.ArgumentParser | None = None, ) -> None: """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" - import json - import sys - # Get output mode output_json = args.output_json if args else False @@ -259,7 +260,6 @@ def command_debug_info( # Output based on mode if output_json: # Single object, not wrapped in array - sys.stdout.write(json.dumps(data, indent=2) + "\n") - sys.stdout.flush() + OutputFormatter(OutputMode.JSON).emit_object(data) else: tmuxp_echo(_format_human_output(data, colors)) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index de8ec2dcd2..cd1ff5da75 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -29,6 +29,7 @@ import argparse import datetime import json +import logging import pathlib import typing as t @@ -46,6 +47,8 @@ from ._colors import Colors, build_description, get_color_mode from ._output import OutputFormatter, OutputMode, get_output_mode +logger = logging.getLogger(__name__) + LS_DESCRIPTION = build_description( """ List workspace files in the tmuxp configuration directory. @@ -567,9 +570,6 @@ def command_ls( -------- >>> # command_ls() lists workspaces from cwd/parents and ~/.tmuxp/ """ - import json - import sys - # Get color mode from args or default to AUTO color_mode = get_color_mode(args.color if args else None) colors = Colors(color_mode) @@ -612,8 +612,7 @@ def command_ls( "workspaces": [], "global_workspace_dirs": global_dir_candidates, } - sys.stdout.write(json.dumps(output_data, indent=2) + "\n") - sys.stdout.flush() + formatter.emit_object(output_data) # NDJSON: just output nothing for empty workspaces return @@ -623,8 +622,7 @@ def command_ls( "workspaces": workspaces, "global_workspace_dirs": global_dir_candidates, } - sys.stdout.write(json.dumps(output_data, indent=2) + "\n") - sys.stdout.flush() + formatter.emit_object(output_data) return # Human and NDJSON output diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py index 3112f60d02..84bee97aa0 100644 --- a/tests/cli/test_output.py +++ b/tests/cli/test_output.py @@ -221,6 +221,54 @@ def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None: assert captured.out == "" +def test_emit_object_json_writes_immediately( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode emit_object should write indented JSON immediately.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"status": "ok", "count": 3} + # Indented output (indent=2) + assert "\n" in captured.out + + +def test_emit_object_ndjson_writes_compact( + capsys: pytest.CaptureFixture[str], +) -> None: + """NDJSON mode emit_object should write compact single-line JSON.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 1 + assert json.loads(lines[0]) == {"status": "ok", "count": 3} + + +def test_emit_object_human_silent(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode emit_object should produce no output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_object({"status": "ok"}) + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_object_does_not_buffer() -> None: + """emit_object must not affect _json_buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.emit_object({"status": "ok"}) + finally: + sys.stdout = old_stdout + assert formatter._json_buffer == [] + + def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None: """Test complete HUMAN output workflow.""" formatter = OutputFormatter(OutputMode.HUMAN) From fd390b60b8f1910e9fa58814cd15b86f3b92400e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:41:34 -0500 Subject: [PATCH 09/89] fix(logging[doctest]): add doctest to TmuxpLoggerAdapter why: CLAUDE.md requires all functions and methods to have working doctests. what: - Add Examples section to TmuxpLoggerAdapter demonstrating extra merging --- src/tmuxp/log.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index cec5da5ac4..d4b9684957 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -30,6 +30,20 @@ class TmuxpLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] Follows the portable pattern to avoid repeating the same `extra` on every call while preserving the ability to add per-call `extra` kwargs. + + Examples + -------- + >>> adapter = TmuxpLoggerAdapter( + ... logging.getLogger("test"), + ... {"tmux_session": "my-session"}, + ... ) + >>> msg, kwargs = adapter.process("hello %s", {"extra": {"tmux_window": "editor"}}) + >>> msg + 'hello %s' + >>> kwargs["extra"]["tmux_session"] + 'my-session' + >>> kwargs["extra"]["tmux_window"] + 'editor' """ def process( From 1826606b5840ba02b252c55dd6e1dccf75601403 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:42:17 -0500 Subject: [PATCH 10/89] docs(logging[AGENTS]): add output channels section to AGENTS.md why: Developers need guidance on the 2-channel output architecture (logger for diagnostics, tmuxp_echo/OutputFormatter for user output). what: - Add "Output channels" section with diagnostics vs user-facing rules - Document print() prohibition in command/business logic - Expand "Avoid" entry for print() with structured extra guidance --- AGENTS.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 3fe760a6e0..bdbe24f926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,12 +206,24 @@ Assert on `caplog.records` attributes, not string matching on `caplog.text`: - Assert on schema: `record.tmux_exit_code == 0` not `"exit code 0" in caplog.text` - `caplog.record_tuples` cannot access extra fields — always use `caplog.records` +### Output channels + +Two output channels serve different audiences: + +1. **Diagnostics** (`logger.*()` with `extra`): System events for log files, `caplog`, and aggregators. Never styled. +2. **User-facing output**: What the human sees. Styled via `Colors` class. + - Commands with output modes (`--json`/`--ndjson`): prefer `OutputFormatter.emit_text()` from `tmuxp.cli._output` — silenced in non-human modes. + - Human-only commands: use `tmuxp_echo()` from `tmuxp.log` (re-exported via `tmuxp.cli.utils`) for user-facing messages. + - **Undefined contracts:** Machine-output behavior for error and empty-result paths (e.g., `search` with no matches) is not yet defined. These paths currently emit styled text through `formatter.emit_text()`, which is a no-op in machine modes. + +Raw `print()` is forbidden in command/business logic. The `print()` call lives only inside the presenter layer (`_output.py`) or `tmuxp_echo`. + ### Avoid - f-strings/`.format()` in log calls - Unguarded logging in hot loops (guard with `isEnabledFor()`) - Catch-log-reraise without adding new context -- `print()` for diagnostics +- `print()` for debugging or internal diagnostics — use `logger.debug()` with structured `extra` instead - Logging secret env var values (log key names only) - Non-scalar ad-hoc objects in `extra` - Requiring custom `extra` fields in format strings without safe defaults (missing keys raise `KeyError`) From 50d87de2259531918c0f9a7f06cdd9bfff41db89 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:50:56 -0500 Subject: [PATCH 11/89] docs(CHANGES): add structured logging and colorama removal entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: PR 1 needs changelog entries for the logging work. what: - Add bug fixes: CLI log level, get_pane() exception, OutputFormatter routing - Add development section: structured logging, colorama removal, print→tmuxp_echo --- CHANGES | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGES b/CHANGES index 95d8c3e57e..627c4298cb 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,26 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +### Bug fixes + +- Fix default CLI log level from INFO to WARNING so normal usage is not noisy (#1017) +- Suppress raw Python tracebacks on workspace build failure; error details available via `--log-level debug` while the user sees only `[Error] ` (#1017) +- Fix `get_pane()` to match sibling methods: widen catch to `Exception`, preserve exception chain via `from e`, replace bare `print()` with structured debug log (#1017) +- Route `ls --json` and `debug-info --json` through `OutputFormatter` for consistent machine-readable output (#1017) + +### Development + +#### Structured logging with `extra` context across all modules (#1017) + +All modules now use `logging.getLogger(__name__)` with structured `extra` keys +(`tmux_session`, `tmux_window`, `tmux_pane`, `tmux_config_path`, etc.) for +filtering and aggregation. Library `__init__.py` adds `NullHandler` per Python +best practices. A new `TmuxpLoggerAdapter` provides persistent context for +objects with stable identity. + +- Remove `colorama` runtime and type-stub dependencies; replace with stdlib ANSI constants (#1017) +- Route all raw `print()` calls through `tmuxp_echo()` for consistent output channels (#1017) + ## tmuxp 1.65.0 (2026-03-08) ### Breaking Changes From ed00d63da20e7fdcaea59c6a1a8c4cfb93c12403 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:31:09 -0500 Subject: [PATCH 12/89] fix(logging[load]): downgrade logger.exception to debug with exc_info why: logger.exception() dumps tracebacks at ERROR level (visible at default WARNING) alongside tmuxp_echo() user-friendly messages, causing users to see double output. what: - Change plugin load failed from exception to debug with exc_info - Change plugin import failed from exception to debug with exc_info - Change workspace build failed from exception to debug with exc_info --- src/tmuxp/cli/load.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index b8184999eb..97dd486710 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -123,7 +123,7 @@ def load_plugins( module_name = ".".join(module_name[:-1]) plugin_name = plugin.split(".")[-1] except AttributeError as error: - logger.exception("plugin load failed") + logger.debug("plugin load failed", exc_info=True) tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -149,7 +149,7 @@ def load_plugins( ) sys.exit(1) except (ImportError, AttributeError) as error: - logger.exception("plugin import failed") + logger.debug("plugin import failed", exc_info=True) tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -453,7 +453,7 @@ def load_workspace( _load_attached(builder, detached) except exc.TmuxpException as e: - logger.exception("workspace build failed") + logger.debug("workspace build failed", exc_info=True) tmuxp_echo(cli_colors.error("[Error]") + f" {e}") choice = prompt_choices( From 476a550dec569e9d934d81e251925a51d28b59ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:31:31 -0500 Subject: [PATCH 13/89] fix(logging[load]): restore user-facing [Loading] message why: The structured logging migration removed the user-visible [Loading] message that shows which workspace file is being loaded. what: - Add tmuxp_echo with [Loading] and privacy-masked workspace path - Uses PrivatePath (already imported) and cli_colors (already available) --- src/tmuxp/cli/load.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 97dd486710..5cc145e80a 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -358,6 +358,11 @@ def load_workspace( "loading workspace", extra={"tmux_config_path": str(workspace_file)}, ) + tmuxp_echo( + cli_colors.info("[Loading]") + + " " + + cli_colors.highlight(str(PrivatePath(workspace_file))), + ) # ConfigReader allows us to open a yaml or json file as a dict raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} From 9ee0acc4f5844bca4752fbf8b7a8e908263f40b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:31:57 -0500 Subject: [PATCH 14/89] fix(logging[load]): move reattach output to structured extra why: Inlining tmux stdout in the log message string defeats structured log aggregation and filtering. what: - Move display-message output from format arg to extra tmux_stdout key --- src/tmuxp/cli/load.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 5cc145e80a..4673c4cde6 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -184,7 +184,10 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: proc = builder.session.cmd("display-message", "-p", "'#S'") for line in proc.stdout: tmuxp_echo(colors.info(line) if colors else line) - logger.debug("reattach display-message output: %s", line.strip()) + logger.debug( + "reattach display-message output", + extra={"tmux_stdout": [line.strip()]}, + ) if "TMUX" in os.environ: builder.session.switch_client() From 16fb89a1dbb89b38d00733f2ccf68e2995c3a269 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:32:17 -0500 Subject: [PATCH 15/89] fix(logging[doctest]): add doctest to _identity why: All functions must have working doctests per project conventions. what: - Add Examples section with string and integer pass-through tests --- src/tmuxp/_compat.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/_compat.py b/src/tmuxp/_compat.py index 7888a50b46..51a055f7f5 100644 --- a/src/tmuxp/_compat.py +++ b/src/tmuxp/_compat.py @@ -11,7 +11,22 @@ def _identity(x: object) -> object: - """Return *x* unchanged — used as a no-op decorator.""" + """Return *x* unchanged — used as a no-op decorator. + + Examples + -------- + >>> from tmuxp._compat import _identity + + Strings pass through unchanged: + + >>> _identity("hello") + 'hello' + + Integers pass through unchanged: + + >>> _identity(42) + 42 + """ return x From 689b5bceebc19911621ce04038610d754d9b83c9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:33:05 -0500 Subject: [PATCH 16/89] fix(logging[builder]): add script path to before_script error extra why: The bare error message loses the script path, making it harder to diagnose which before_script failed in structured log systems. what: - Add tmux_config_path extra with the before_script path to error log --- src/tmuxp/workspace/builder.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 18154b997a..57d494c01f 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -366,7 +366,14 @@ def build(self, session: Session | None = None, append: bool = False) -> None: ) run_before_script(self.session_config["before_script"], cwd=cwd) except Exception: - _log.error("before script failed") + _log.error( + "before script failed", + extra={ + "tmux_config_path": str( + self.session_config["before_script"], + ), + }, + ) self.session.kill() raise From df72c8408c25b491b9892a1247c0bb809d9aecd1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:33:29 -0500 Subject: [PATCH 17/89] fix(logging[util]): remove redundant exc_info from pane lookup reraise why: The raise...from e chain on the next lines already preserves the exception traceback; logging it too is redundant per project standards. what: - Remove exc_info=True from pane lookup debug log before reraise --- src/tmuxp/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index f069a47aec..d63a6c4182 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -195,7 +195,6 @@ def get_pane(window: Window, current_pane: Pane | None = None) -> Pane: except Exception as e: logger.debug( "pane lookup failed", - exc_info=True, extra={"tmux_pane": str(current_pane) if current_pane else ""}, ) if current_pane: From 1f38d39379ccc7b3de9f256b53e61c21a7fc0617 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 16:33:51 -0500 Subject: [PATCH 18/89] fix(logging[test]): update stale assertion in skipped test why: The assertion checked for "Loading" or "Loaded" but the restored user-facing message now uses "[Loading]" format. what: - Update assertion to match the restored [Loading] message format --- tests/cli/test_load.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 9ecffd2471..60c2c446ed 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -582,7 +582,7 @@ def test_load_plugins_version_fail_skip( result = capsys.readouterr() - assert "Loading" in result.out or "Loaded" in result.out + assert "[Loading]" in result.out PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES: list[PluginVersionTestFixture] = [ From e23569d434cd3cddb19d20aabb6907036dfb9ac8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 17:01:42 -0500 Subject: [PATCH 19/89] Tag v1.66.0 (logging updates via #1017) --- CHANGES | 4 +++- pyproject.toml | 2 +- src/tmuxp/__about__.py | 2 +- uv.lock | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 627c4298cb..5e8bf5cdcd 100644 --- a/CHANGES +++ b/CHANGES @@ -27,7 +27,7 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force // Usage: tmuxp@next load yoursession ``` -## tmuxp 1.66.0 (Yet to be released) +## tmuxp 1.67.0 (Yet to be released) @@ -35,6 +35,8 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +## tmuxp 1.66.0 (2026-03-08) + ### Bug fixes - Fix default CLI log level from INFO to WARNING so normal usage is not noisy (#1017) diff --git a/pyproject.toml b/pyproject.toml index aa884dea6a..ceb1aae0c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.65.0" +version = "1.66.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index cb95133689..f1cfd86085 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -8,7 +8,7 @@ __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.65.0" +__version__ = "1.66.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/uv.lock b/uv.lock index df6b02b5b6..03668ca9d4 100644 --- a/uv.lock +++ b/uv.lock @@ -1379,7 +1379,7 @@ wheels = [ [[package]] name = "tmuxp" -version = "1.65.0" +version = "1.66.0" source = { editable = "." } dependencies = [ { name = "libtmux" }, From 46cf41104ace100a8953eedf54685dcdc9e722b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:49:15 -0500 Subject: [PATCH 20/89] feat(builder[callbacks]): add progress, before_script, script_output, and build_event callbacks why: The builder needs to emit lifecycle events so a UI layer can render real-time progress without coupling builder logic to display code. what: - Add on_progress, on_before_script, on_script_output, on_build_event callback parameters to WorkspaceBuilder - Emit structured build events: session_created (with window_total, session_pane_total), window_started, pane_creating, window_done, workspace_built, before_script_started, before_script_done - Add on_line callback to run_before_script() for capturing script output - Add doctests for all callback types --- src/tmuxp/util.py | 14 ++- src/tmuxp/workspace/builder.py | 180 ++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index d63a6c4182..152b1f6c06 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -28,8 +28,12 @@ def run_before_script( script_file: str | pathlib.Path, cwd: pathlib.Path | None = None, + on_line: t.Callable[[str], None] | None = None, ) -> int: - """Execute shell script, ``tee``-ing output to both terminal (if TTY) and buffer.""" + """Execute shell script, streaming output to callback or terminal (if TTY). + + Output is buffered and optionally forwarded via the ``on_line`` callback. + """ script_cmd = shlex.split(str(script_file)) try: @@ -68,13 +72,17 @@ def run_before_script( if line_out and line_out.strip(): out_buffer.append(line_out) - if is_out_tty: + if on_line is not None: + on_line(line_out) + elif is_out_tty: sys.stdout.write(line_out) sys.stdout.flush() if line_err and line_err.strip(): err_buffer.append(line_err) - if is_err_tty: + if on_line is not None: + on_line(line_err) + elif is_err_tty: sys.stderr.write(line_err) sys.stderr.flush() diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 57d494c01f..728b477963 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -169,6 +169,101 @@ class WorkspaceBuilder: >>> sorted([window.name for window in session.windows]) ['editor', 'logging', 'test'] + **Progress callback:** + + >>> calls: list[str] = [] + >>> progress_cfg = { + ... "session_name": "progress-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=progress_cfg, + ... server=server, + ... on_progress=calls.append, + ... ) + >>> builder.build() + >>> "Workspace built" in calls + True + + **Before-script hook:** + + >>> hook_calls: list[bool] = [] + >>> no_script_cfg = { + ... "session_name": "hook-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=no_script_cfg, + ... server=server, + ... on_before_script=lambda: hook_calls.append(True), + ... ) + >>> builder.build() + >>> hook_calls # no before_script in config, callback not fired + [] + + **Script output hook:** + + >>> script_lines: list[str] = [] + >>> no_script_cfg2 = { + ... "session_name": "script-output-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=no_script_cfg2, + ... server=server, + ... on_script_output=script_lines.append, + ... ) + >>> builder.build() + >>> script_lines # no before_script in config, callback not fired + [] + + **Build events hook:** + + >>> events: list[dict] = [] + >>> event_cfg = { + ... "session_name": "events-demo", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=event_cfg, + ... server=server, + ... on_build_event=events.append, + ... ) + >>> builder.build() + >>> [e["event"] for e in events] + ['session_created', 'window_started', 'pane_creating', + 'window_done', 'workspace_built'] + >>> next(e for e in events if e["event"] == "session_created")["session_pane_total"] + 1 + + **Build events with before_script:** + + ``before_script_started`` fires before the script runs; + ``before_script_done`` fires in ``finally`` (success or failure). + + >>> script_events: list[dict] = [] + >>> script_event_cfg = { + ... "session_name": "script-events-demo", + ... "before_script": "echo hello", + ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + ... } + >>> builder = WorkspaceBuilder( + ... session_config=script_event_cfg, + ... server=server, + ... on_build_event=script_events.append, + ... ) + >>> builder.build() + >>> event_names = [e["event"] for e in script_events] + >>> "before_script_started" in event_names + True + >>> "before_script_done" in event_names + True + >>> bs_start = event_names.index("before_script_started") + >>> bs_done = event_names.index("before_script_done") + >>> win_start = event_names.index("window_started") + >>> bs_start < bs_done < win_start + True + The normal phase of loading is: 1. Load JSON / YAML file via :class:`pathlib.Path`:: @@ -211,12 +306,20 @@ class WorkspaceBuilder: server: Server _session: Session | None session_name: str + on_progress: t.Callable[[str], None] | None + on_before_script: t.Callable[[], None] | None + on_script_output: t.Callable[[str], None] | None + on_build_event: t.Callable[[dict[str, t.Any]], None] | None def __init__( self, session_config: dict[str, t.Any], server: Server, plugins: list[t.Any] | None = None, + on_progress: t.Callable[[str], None] | None = None, + on_before_script: t.Callable[[], None] | None = None, + on_script_output: t.Callable[[str], None] | None = None, + on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None, ) -> None: """Initialize workspace loading. @@ -231,6 +334,23 @@ def __init__( server : :class:`libtmux.Server` tmux server to build session in + on_progress : callable, optional + callback for progress updates during building + + on_before_script : callable, optional + called just before ``before_script`` runs; use to clear the terminal + (e.g. stop a spinner) so script output is not interleaved + + on_script_output : callable, optional + called with each output line from ``before_script`` subprocess; when + set, raw TTY tee is suppressed so the caller can route lines to a + live panel instead + + on_build_event : callable, optional + called with a dict event at each structural build milestone (session + created, window started/done, pane creating, workspace built); used + by the CLI to render a live session tree + Notes ----- TODO: Initialize :class:`libtmux.Session` from here, in @@ -248,6 +368,10 @@ def __init__( self.session_config = session_config self.plugins = plugins + self.on_progress = on_progress + self.on_before_script = on_before_script + self.on_script_output = on_script_output + self.on_build_event = on_build_event if self.server is not None and self.session_exists( session_name=self.session_config["session_name"], @@ -332,7 +456,21 @@ def build(self, session: Session | None = None, append: bool = False) -> None: assert session is not None assert session.name is not None + if self.on_progress: + self.on_progress(f"Session created: {session.name}") + self._session = session + if self.on_build_event: + self.on_build_event( + { + "event": "session_created", + "name": session.name, + "window_total": len(self.session_config["windows"]), + "session_pane_total": sum( + len(w.get("panes", [])) for w in self.session_config["windows"] + ), + } + ) _log = TmuxpLoggerAdapter( logger, {"tmux_session": self.session_config["session_name"]}, @@ -354,6 +492,10 @@ def build(self, session: Session | None = None, append: bool = False) -> None: focus = None if "before_script" in self.session_config: + if self.on_before_script: + self.on_before_script() + if self.on_build_event: + self.on_build_event({"event": "before_script_started"}) try: cwd = None @@ -364,7 +506,11 @@ def build(self, session: Session | None = None, append: bool = False) -> None: _log.debug( "running before script", ) - run_before_script(self.session_config["before_script"], cwd=cwd) + run_before_script( + self.session_config["before_script"], + cwd=cwd, + on_line=self.on_script_output, + ) except Exception: _log.error( "before script failed", @@ -376,6 +522,9 @@ def build(self, session: Session | None = None, append: bool = False) -> None: ) self.session.kill() raise + finally: + if self.on_build_event: + self.on_build_event({"event": "before_script_done"}) if "options" in self.session_config: for option, value in self.session_config["options"].items(): @@ -414,10 +563,17 @@ def build(self, session: Session | None = None, append: bool = False) -> None: if focus_pane: focus_pane.select() + if self.on_build_event: + self.on_build_event({"event": "window_done"}) + if focus: focus.select() + if self.on_progress: + self.on_progress("Workspace built") _log.info("workspace built") + if self.on_build_event: + self.on_build_event({"event": "workspace_built"}) def iter_create_windows( self, @@ -450,6 +606,17 @@ def iter_create_windows( ): window_name = window_config.get("window_name", None) + if self.on_progress: + self.on_progress(f"Creating window: {window_name or window_iterator}") + if self.on_build_event: + self.on_build_event( + { + "event": "window_started", + "name": window_name or str(window_iterator), + "pane_total": len(window_config["panes"]), + } + ) + is_first_window_pass = self.first_window_pass( window_iterator, session, @@ -545,6 +712,17 @@ def iter_create_panes( window_config["panes"], start=pane_base_index, ): + if self.on_progress: + self.on_progress(f"Creating pane: {pane_index}") + if self.on_build_event: + self.on_build_event( + { + "event": "pane_creating", + "pane_num": pane_index - int(pane_base_index) + 1, + "pane_total": len(window_config["panes"]), + } + ) + if pane_index == int(pane_base_index): pane = window.active_pane else: From ac1f73b73b9bf06532e61de8f48cfa124b829366 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:49:26 -0500 Subject: [PATCH 21/89] feat(_progress[spinner]): add Spinner, BuildTree, templates, and presets why: The CLI needs an animated progress display during workspace builds. A dedicated module keeps display logic decoupled from builder and load. what: - Add Spinner context manager with atexit cursor restore and non-TTY fallback - Add BuildTree for tracking build state (session, windows, panes) - Add scrolling output panel for before_script output lines - Add PROGRESS_PRESETS (default, minimal, window, pane, verbose) with format_template using {session}, {window}, {bar}, {progress}, etc. - Add render_bar() with marching indicator during before_script - Add SUCCESS_TEMPLATE and format_success() for persistent completion line - Add _SafeFormatMap, ANSI-aware truncation, dynamic terminal width refresh --- src/tmuxp/_internal/colors.py | 1 + src/tmuxp/cli/_colors.py | 2 + src/tmuxp/cli/_progress.py | 1131 +++++++++++++++++++++++++++++++++ 3 files changed, 1134 insertions(+) create mode 100644 src/tmuxp/cli/_progress.py diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py index 73db6062fa..a9b350016f 100644 --- a/src/tmuxp/_internal/colors.py +++ b/src/tmuxp/_internal/colors.py @@ -610,6 +610,7 @@ def get_color_mode(color_arg: str | None = None) -> ColorMode: # ANSI styling utilities (originally from click, via utils.py) _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") +ANSI_SEQ_RE = _ansi_re def strip_ansi(value: str) -> str: diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 2513d118a8..31dab82076 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -12,6 +12,7 @@ import logging from tmuxp._internal.colors import ( + ANSI_SEQ_RE, ColorMode, Colors, UnknownStyleColor, @@ -25,6 +26,7 @@ logger = logging.getLogger(__name__) __all__ = [ + "ANSI_SEQ_RE", "ColorMode", "Colors", "UnknownStyleColor", diff --git a/src/tmuxp/cli/_progress.py b/src/tmuxp/cli/_progress.py new file mode 100644 index 0000000000..01d31d7272 --- /dev/null +++ b/src/tmuxp/cli/_progress.py @@ -0,0 +1,1131 @@ +"""Progress indicators for tmuxp CLI. + +This module provides a threaded spinner for long-running operations, +using only standard library and ANSI escape sequences. +""" + +from __future__ import annotations + +import atexit +import collections +import dataclasses +import itertools +import logging +import shutil +import sys +import threading +import time +import typing as t + +from ._colors import ANSI_SEQ_RE, ColorMode, Colors, strip_ansi + +logger = logging.getLogger(__name__) + + +if t.TYPE_CHECKING: + import types + + +# ANSI Escape Sequences +HIDE_CURSOR = "\033[?25l" +SHOW_CURSOR = "\033[?25h" +ERASE_LINE = "\033[2K" +CURSOR_TO_COL0 = "\r" +CURSOR_UP_1 = "\x1b[1A" +SYNC_START = "\x1b[?2026h" # synchronized output: buffer until SYNC_END +SYNC_END = "\x1b[?2026l" # flush — prevents multi-line flicker + +# Spinner frames (braille pattern) +SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + +BAR_WIDTH = 10 # inner fill character count +DEFAULT_OUTPUT_LINES = 3 # default spinner panel height (lines of script output) + + +def _visible_len(s: str) -> int: + r"""Return visible length of *s*, ignoring ANSI escapes. + + Examples + -------- + >>> _visible_len("hello") + 5 + >>> _visible_len("\033[32mgreen\033[0m") + 5 + >>> _visible_len("") + 0 + """ + return len(strip_ansi(s)) + + +def _truncate_visible(text: str, max_visible: int, suffix: str = "...") -> str: + r"""Truncate *text* to *max_visible* visible characters, preserving ANSI sequences. + + If the visible length of *text* is already within *max_visible*, it is + returned unchanged. Otherwise the text is cut so that exactly + *max_visible* visible characters remain, a ``\x1b[0m`` reset is appended + (to prevent color bleed), followed by *suffix*. + + Parameters + ---------- + text : str + Input string, possibly containing ANSI escape sequences. + max_visible : int + Maximum number of visible (non-ANSI) characters to keep. + suffix : str + Appended after the reset when truncation occurs. Default ``"..."``. + + Returns + ------- + str + Truncated string with ANSI sequences intact. + + Examples + -------- + Plain text truncation: + + >>> _truncate_visible("hello world", 5) + 'hello\x1b[0m...' + + ANSI sequences are preserved whole: + + >>> _truncate_visible("\033[32mgreen\033[0m", 3) + '\x1b[32mgre\x1b[0m...' + + No truncation needed: + + >>> _truncate_visible("short", 10) + 'short' + + Empty string: + + >>> _truncate_visible("", 5) + '' + """ + if max_visible <= 0: + return "" + if _visible_len(text) <= max_visible: + return text + + result: list[str] = [] + visible = 0 + i = 0 + while i < len(text) and visible < max_visible: + m = ANSI_SEQ_RE.match(text, i) + if m: + result.append(m.group()) + i = m.end() + else: + result.append(text[i]) + visible += 1 + i += 1 + return "".join(result) + "\x1b[0m" + suffix + + +SUCCESS_TEMPLATE = "Loaded workspace: {session} ({workspace_path}) {summary}" + +PROGRESS_PRESETS: dict[str, str] = { + "default": "Loading workspace: {session} {bar} {progress} {window}", + "minimal": "Loading workspace: {session} [{window_progress}]", + "window": "Loading workspace: {session} {window_bar} {window_progress_rel}", + "pane": "Loading workspace: {session} {pane_bar} {session_pane_progress}", + "verbose": ( + "Loading workspace: {session} [window {window_index} of {window_total}" + " · pane {pane_index} of {pane_total}] {window}" + ), +} + + +def render_bar(done: int, total: int, width: int = BAR_WIDTH) -> str: + """Render a plain-text ASCII progress bar without color. + + Parameters + ---------- + done : int + Completed units. + total : int + Total units. When ``<= 0``, returns ``""``. + width : int + Inner fill character count; default :data:`BAR_WIDTH`. + + Returns + ------- + str + A bar like ``"█████░░░░░"``. + Returns ``""`` when *total* <= 0 or *width* <= 0. + + Examples + -------- + >>> render_bar(0, 10) + '░░░░░░░░░░' + >>> render_bar(5, 10) + '█████░░░░░' + >>> render_bar(10, 10) + '██████████' + >>> render_bar(0, 0) + '' + >>> render_bar(3, 10, width=5) + '█░░░░' + """ + if total <= 0 or width <= 0: + return "" + filled = min(width, int(done / total * width)) + return "█" * filled + "░" * (width - filled) + + +class _SafeFormatMap(dict): # type: ignore[type-arg] + """dict subclass that returns ``{key}`` for missing keys in format_map.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def resolve_progress_format(fmt: str) -> str: + """Return the format string for *fmt*, resolving preset names. + + If *fmt* is a key in :data:`PROGRESS_PRESETS` the corresponding + format string is returned; otherwise *fmt* is returned as-is. + + Examples + -------- + >>> resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + True + >>> resolve_progress_format("{session} w{window_progress}") + '{session} w{window_progress}' + >>> resolve_progress_format("unknown-preset") + 'unknown-preset' + """ + return PROGRESS_PRESETS.get(fmt, fmt) + + +@dataclasses.dataclass +class _WindowStatus: + """State for a single window in the build tree.""" + + name: str + done: bool = False + pane_num: int | None = None + pane_total: int | None = None + pane_done: int = 0 # panes completed in this window (set on window_done) + + +class BuildTree: + """Tracks session/window/pane build state; renders a structural progress tree. + + **Template Token Lifecycle** + + Each token is first available at the event listed in its column. + ``—`` means the value does not change at that phase. + + .. list-table:: + :header-rows: 1 + + * - Token + - Pre-``session_created`` + - After ``session_created`` + - After ``window_started`` + - After ``pane_creating`` + - After ``window_done`` + * - ``{session}`` + - ``""`` + - session name + - — + - — + - — + * - ``{window}`` + - ``""`` + - ``""`` + - window name + - — + - last window name + * - ``{window_index}`` + - ``0`` + - ``0`` + - N (1-based started count) + - — + - — + * - ``{window_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{window_progress}`` + - ``""`` + - ``""`` + - ``"N/M"`` when > 0 + - — + - — + * - ``{windows_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - increments + * - ``{windows_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{window_progress_rel}`` + - ``""`` + - ``"0/M"`` + - ``"0/M"`` + - — + - ``"N/M"`` + * - ``{pane_index}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - ``0`` + * - ``{pane_total}`` + - ``0`` + - ``0`` + - window's pane total + - — + - window's pane total + * - ``{pane_progress}`` + - ``""`` + - ``""`` + - ``""`` + - ``"N/M"`` + - ``""`` + * - ``{pane_done}`` + - ``0`` + - ``0`` + - ``0`` + - pane_num + - pane_total + * - ``{pane_remaining}`` + - ``0`` + - ``0`` + - pane_total + - decrements + - ``0`` + * - ``{pane_progress_rel}`` + - ``""`` + - ``""`` + - ``"0/M"`` + - ``"N/M"`` + - ``"M/M"`` + * - ``{progress}`` + - ``""`` + - ``""`` + - ``"N/M win"`` + - ``"N/M win · P/Q pane"`` + - — + * - ``{session_pane_total}`` + - ``0`` + - total + - — + - — + - — + * - ``{session_panes_done}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - accumulated + * - ``{session_panes_remaining}`` + - ``0`` + - total + - total + - total + - decrements + * - ``{session_pane_progress}`` + - ``""`` + - ``"0/T"`` + - — + - — + - ``"N/T"`` + * - ``{overall_percent}`` + - ``0`` + - ``0`` + - ``0`` + - ``0`` + - updates + * - ``{summary}`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``"[N win, M panes]"`` + * - ``{bar}`` (spinner) + - ``[░░…]`` + - ``[░░…]`` + - starts filling + - fractional + - jumps + * - ``{pane_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{window_bar}`` (spinner) + - ``""`` + - ``[░░…]`` + - — + - — + - updates + * - ``{status_icon}`` (spinner) + - ``""`` + - ``""`` + - ``""`` + - ``""`` + - ``""`` + + During ``before_script``: ``{bar}``, ``{pane_bar}``, ``{window_bar}`` show a + marching animation; ``{status_icon}`` = ``⏸``. + + Examples + -------- + Empty tree renders nothing: + + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> tree = BuildTree() + >>> tree.render(colors, 80) + [] + + After session_created event the header appears: + + >>> tree.on_event({"event": "session_created", "name": "my-session"}) + >>> tree.render(colors, 80) + ['Session'] + + After window_started and pane_creating: + + >>> tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + >>> tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- editor, pane (1 of 2)' + + After window_done the window gets a checkmark: + + >>> tree.on_event({"event": "window_done"}) + >>> lines = tree.render(colors, 80) + >>> lines[1] + '- ✓ editor' + + **Inline status format:** + + >>> tree2 = BuildTree() + >>> tree2.format_inline("Building projects...") + 'Building projects...' + >>> tree2.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai' + >>> tree2.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + >>> tree2.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + >>> tree2.format_inline("Building projects...") + 'Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs' + """ + + def __init__(self, workspace_path: str = "") -> None: + self.workspace_path: str = workspace_path + self.session_name: str | None = None + self.windows: list[_WindowStatus] = [] + self.window_total: int | None = None + self.session_pane_total: int | None = None + self.session_panes_done: int = 0 + self.windows_done: int = 0 + self._before_script_event: threading.Event = threading.Event() + + def on_event(self, event: dict[str, t.Any]) -> None: + """Update tree state from a build event dict. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.on_event({ + ... "event": "session_created", "name": "dev", "window_total": 2, + ... }) + >>> tree.session_name + 'dev' + >>> tree.window_total + 2 + >>> tree.on_event({ + ... "event": "window_started", "name": "editor", "pane_total": 3, + ... }) + >>> len(tree.windows) + 1 + >>> tree.windows[0].name + 'editor' + """ + kind = event["event"] + if kind == "session_created": + self.session_name = event["name"] + self.window_total = event.get("window_total") + self.session_pane_total = event.get("session_pane_total") + elif kind == "before_script_started": + self._before_script_event.set() + elif kind == "before_script_done": + self._before_script_event.clear() + elif kind == "window_started": + self.windows.append( + _WindowStatus(name=event["name"], pane_total=event["pane_total"]) + ) + elif kind == "pane_creating": + if self.windows: + w = self.windows[-1] + w.pane_num = event["pane_num"] + w.pane_total = event["pane_total"] + elif kind == "window_done": + if self.windows: + w = self.windows[-1] + w.done = True + w.pane_num = None + w.pane_done = w.pane_total or 0 + self.session_panes_done += w.pane_done + self.windows_done += 1 + elif kind == "workspace_built": + for w in self.windows: + w.done = True + + def render(self, colors: Colors, width: int) -> list[str]: + """Render the current tree state to a list of display strings. + + Parameters + ---------- + colors : Colors + Colors instance for ANSI styling. + width : int + Terminal width; window lines are truncated to ``width - 1``. + + Returns + ------- + list[str] + Lines to display; empty list if no session has been created yet. + """ + if self.session_name is None: + return [] + lines: list[str] = [colors.heading("Session")] + for w in self.windows: + if w.done: + line = f"- {colors.success('✓')} {colors.highlight(w.name)}" + elif w.pane_num is not None and w.pane_total is not None: + line = ( + f"- {colors.highlight(w.name)}" + f"{colors.muted(f', pane ({w.pane_num} of {w.pane_total})')}" + ) + else: + line = f"- {colors.highlight(w.name)}" + lines.append(_truncate_visible(line, width - 1, suffix="")) + return lines + + def _context(self) -> dict[str, t.Any]: + """Return the current build-state token dict for template rendering. + + Examples + -------- + Zero-state before any events: + + >>> tree = BuildTree(workspace_path="~/.tmuxp/myapp.yaml") + >>> ev = { + ... "event": "session_created", + ... "name": "myapp", + ... "window_total": 5, + ... "session_pane_total": 10, + ... } + >>> tree.on_event(ev) + >>> ctx = tree._context() + >>> ctx["workspace_path"] + '~/.tmuxp/myapp.yaml' + >>> ctx["session"] + 'myapp' + >>> ctx["window_total"] + 5 + >>> ctx["window_index"] + 0 + >>> ctx["progress"] + '' + >>> ctx["windows_done"] + 0 + >>> ctx["windows_remaining"] + 5 + >>> ctx["window_progress_rel"] + '0/5' + >>> ctx["session_pane_total"] + 10 + >>> ctx["session_panes_remaining"] + 10 + >>> ctx["session_pane_progress"] + '0/10' + >>> ctx["summary"] + '' + + After windows complete, summary shows counts: + + >>> tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + >>> tree.on_event({"event": "window_done"}) + >>> tree.on_event({"event": "window_started", "name": "w2", "pane_total": 5}) + >>> tree.on_event({"event": "window_done"}) + >>> tree._context()["summary"] + '[2 win, 8 panes]' + """ + w = self.windows[-1] if self.windows else None + window_idx = len(self.windows) + win_tot = self.window_total or 0 + pane_idx = (w.pane_num or 0) if w else 0 + pane_tot = (w.pane_total or 0) if w else 0 + + win_progress = f"{window_idx}/{win_tot}" if win_tot and window_idx > 0 else "" + pane_progress = f"{pane_idx}/{pane_tot}" if pane_tot and pane_idx > 0 else "" + progress_parts = [ + f"{win_progress} win" if win_progress else "", + f"{pane_progress} pane" if pane_progress else "", + ] + progress = " · ".join(p for p in progress_parts if p) + + win_done = self.windows_done + win_progress_rel = f"{win_done}/{win_tot}" if win_tot else "" + + pane_done_cur = ( + (w.pane_num or 0) if w and not w.done else (w.pane_done if w else 0) + ) + pane_remaining = max(0, pane_tot - pane_done_cur) + pane_progress_rel = f"{pane_done_cur}/{pane_tot}" if pane_tot else "" + + spt = self.session_pane_total or 0 + session_pane_progress = f"{self.session_panes_done}/{spt}" if spt else "" + overall_percent = int(self.session_panes_done / spt * 100) if spt else 0 + + summary_parts: list[str] = [] + if self.windows_done: + summary_parts.append(f"{self.windows_done} win") + if self.session_panes_done: + summary_parts.append(f"{self.session_panes_done} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + + return { + "workspace_path": self.workspace_path, + "session": self.session_name or "", + "window": w.name if w else "", + "window_index": window_idx, + "window_total": win_tot, + "window_progress": win_progress, + "pane_index": pane_idx, + "pane_total": pane_tot, + "pane_progress": pane_progress, + "progress": progress, + "windows_done": win_done, + "windows_remaining": max(0, win_tot - win_done), + "window_progress_rel": win_progress_rel, + "pane_done": pane_done_cur, + "pane_remaining": pane_remaining, + "pane_progress_rel": pane_progress_rel, + "session_pane_total": spt, + "session_panes_done": self.session_panes_done, + "session_panes_remaining": max(0, spt - self.session_panes_done), + "session_pane_progress": session_pane_progress, + "overall_percent": overall_percent, + "summary": summary, + } + + def format_template( + self, + fmt: str, + extra: dict[str, t.Any] | None = None, + ) -> str: + """Render *fmt* with the current build state. + + Returns ``""`` before ``session_created`` fires so callers can + fall back to a pre-build message. Unknown ``{tokens}`` are left + as-is (not dropped silently). + + The optional *extra* dict is merged on top of :meth:`_context` so + callers (e.g. :class:`Spinner`) can inject ANSI-colored tokens like + ``{bar}`` without adding color concerns to :class:`BuildTree`. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.format_template("{session} [{progress}] {window}") + '' + >>> ev = {"event": "session_created", "name": "cihai", "window_total": 3} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [] ' + >>> ev = {"event": "window_started", "name": "editor", "pane_total": 4} + >>> tree.on_event(ev) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win] editor' + >>> tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + >>> tree.format_template("{session} [{progress}] {window}") + 'cihai [1/3 win · 2/4 pane] editor' + >>> tree.format_template("minimal: {session} [{window_progress}]") + 'minimal: cihai [1/3]' + >>> tree.format_template("{session} {unknown_token}") + 'cihai {unknown_token}' + >>> tree.format_template("{session}", extra={"custom": "value"}) + 'cihai' + """ + if self.session_name is None: + return "" + ctx: dict[str, t.Any] = self._context() + if extra: + ctx = {**ctx, **extra} + return fmt.format_map(_SafeFormatMap(ctx)) + + def format_inline(self, base: str) -> str: + """Return base message with current build state appended inline. + + Parameters + ---------- + base : str + The original spinner message to start from. + + Returns + ------- + str + ``base`` alone if no session has been created yet; otherwise + ``"base session_name [W of N windows, P of M panes] window_name"``, + omitting the bracket section when there is no current window, and + omitting individual parts when their totals are not known. + """ + if self.session_name is None: + return base + parts = [base, self.session_name] + if self.windows: + w = self.windows[-1] + window_idx = len(self.windows) + bracket_parts: list[str] = [] + if self.window_total is not None: + bracket_parts.append(f"{window_idx} of {self.window_total} windows") + if w.pane_num is not None and w.pane_total is not None: + bracket_parts.append(f"{w.pane_num} of {w.pane_total} panes") + if bracket_parts: + parts.append(f"[{', '.join(bracket_parts)}]") + parts.append(w.name) + return " ".join(parts) + + +class Spinner: + """A threaded spinner for CLI progress. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> with Spinner("Build...", color_mode=ColorMode.NEVER, stream=stream) as spinner: + ... spinner.add_output_line("Session created: test") + ... spinner.update_message("Creating window: editor") + """ + + def __init__( + self, + message: str = "Loading...", + color_mode: ColorMode = ColorMode.AUTO, + stream: t.TextIO = sys.stderr, + interval: float = 0.1, + output_lines: int = DEFAULT_OUTPUT_LINES, + progress_format: str | None = None, + workspace_path: str = "", + ) -> None: + """Initialize spinner. + + Parameters + ---------- + message : str + Text displayed next to the spinner animation. + color_mode : ColorMode + ANSI color mode for styled output. + stream : t.TextIO + Output stream (default ``sys.stderr``). + interval : float + Seconds between animation frames. + output_lines : int + Max lines in the scrolling output panel. ``0`` hides the panel, + ``-1`` means unlimited. + progress_format : str | None + Format string for progress output. Tokens are documented in + :class:`BuildTree`. ``None`` uses the built-in default. + workspace_path : str + Absolute path to the workspace config file, shown in success + output. + """ + self.message = message + self._base_message = message + self.colors = Colors(color_mode) + self.stream = stream + self.interval = interval + + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._enabled = self._should_enable() + self._panel_hidden = output_lines == 0 + if output_lines < 0: + self._output_lines: collections.deque[str] = ( + collections.deque() + ) # unlimited + elif output_lines == 0: + self._output_lines = collections.deque(maxlen=1) # drop, never render + else: + self._output_lines = collections.deque(maxlen=output_lines) + self._prev_height: int = 0 + self._build_tree: BuildTree = BuildTree(workspace_path=workspace_path) + self._progress_format: str | None = ( + resolve_progress_format(progress_format) + if progress_format is not None + else None + ) + + def _should_enable(self) -> bool: + """Check if spinner should be enabled (TTY check).""" + return self.stream.isatty() + + def _restore_cursor(self) -> None: + """Unconditionally restore cursor — called by atexit on abnormal exit.""" + self.stream.write(SHOW_CURSOR) + self.stream.flush() + + def _spin(self) -> None: + """Spin in background thread.""" + frames = itertools.cycle(SPINNER_FRAMES) + march_pos = 0 # marching bar position counter (local to _spin) + + self.stream.write(HIDE_CURSOR) + self.stream.flush() + + try: + while not self._stop_event.is_set(): + frame = next(frames) + term_width = shutil.get_terminal_size(fallback=(80, 24)).columns + if self._panel_hidden: + panel: list[str] = [] + else: + term_height = shutil.get_terminal_size( + fallback=(80, 24), + ).lines + raw_panel = list(self._output_lines) + max_panel = term_height - 2 + if len(raw_panel) > max_panel: + raw_panel = raw_panel[-max_panel:] + panel = [ + _truncate_visible(line, term_width - 1, suffix="") + for line in raw_panel + ] + new_height = len(panel) + 1 # panel lines + spinner line + + parts: list[str] = [] + + # Erase previous render (cursor is at end of previous spinner line) + if self._prev_height > 0: + parts.append(f"{CURSOR_TO_COL0}{ERASE_LINE}") + parts.extend( + f"{CURSOR_UP_1}{ERASE_LINE}" + for _ in range(self._prev_height - 1) + ) + + # Write panel lines (tree lines already constrained by render()) + parts.extend(f"{output_line}\n" for output_line in panel) + + # Determine final spinner message + if ( + self._progress_format is not None + and self._build_tree._before_script_event.is_set() + ): + # Marching bar: sweep a 2-cell highlight across the bar + p = march_pos % max(1, BAR_WIDTH - 1) + march_bar = ( + self.colors.muted("░" * p) + + self.colors.warning("░░") + + self.colors.muted("░" * max(0, BAR_WIDTH - p - 2)) + ) + tree = self._build_tree + extra: dict[str, t.Any] = { + "session": self.colors.highlight( + tree.session_name or "", + ), + "bar": march_bar, + "pane_bar": march_bar, + "window_bar": march_bar, + "status_icon": self.colors.warning("⏸"), + } + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + msg = rendered if rendered else self._base_message + march_pos += 1 + else: + msg = self.message + march_pos = 0 # reset when not in before_script + + # Write spinner line (no trailing newline — cursor stays here) + spinner_text = f"{self.colors.info(frame)} {msg}" + if _visible_len(spinner_text) > term_width - 1: + spinner_text = _truncate_visible(spinner_text, term_width - 4) + parts.append(f"{CURSOR_TO_COL0}{spinner_text}") + + # Wrap entire frame in synchronized output to prevent flicker. + # Terminals that don't support it safely ignore the sequences. + self.stream.write(SYNC_START + "".join(parts) + SYNC_END) + self.stream.flush() + self._prev_height = new_height + time.sleep(self.interval) + finally: + # Erase the whole block and show cursor + if self._prev_height > 0: + self.stream.write(f"{CURSOR_TO_COL0}{ERASE_LINE}") + for _ in range(self._prev_height - 1): + self.stream.write(f"{CURSOR_UP_1}{ERASE_LINE}") + self.stream.write(SHOW_CURSOR) + self.stream.flush() + self._prev_height = 0 + + def add_output_line(self, line: str) -> None: + r"""Append a line to the live output panel (thread-safe via GIL). + + When the spinner is disabled (non-TTY), writes directly to the stream + so output is not silently swallowed. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.add_output_line("hello world") + >>> stream.getvalue() + 'hello world\n' + """ + stripped = line.rstrip("\n\r") + if stripped: + if self._enabled: + self._output_lines.append(stripped) + else: + self.stream.write(stripped + "\n") + self.stream.flush() + + def update_message(self, message: str) -> None: + """Update the message displayed next to the spinner. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("initial", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.message + 'initial' + >>> spinner.update_message("updated") + >>> spinner.message + 'updated' + """ + self.message = message + + def _build_extra(self) -> dict[str, t.Any]: + """Return spinner-owned template tokens (colored bar, status_icon). + + These are separated from :meth:`BuildTree._context` to keep ANSI/color + concerns out of :class:`BuildTree`, which is also used in tests without + colors. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner._build_tree.on_event( + ... { + ... "event": "session_created", + ... "name": "s", + ... "window_total": 4, + ... "session_pane_total": 8, + ... } + ... ) + >>> extra = spinner._build_extra() + >>> extra["bar"] + '░░░░░░░░░░' + >>> extra["status_icon"] + '' + """ + tree = self._build_tree + win_tot = tree.window_total or 0 + spt = tree.session_pane_total or 0 + + # Composite fraction: (windows_done + pane_frac) / window_total + if win_tot > 0: + cw = tree.windows[-1] if tree.windows else None + pane_frac = 0.0 + if cw and not cw.done and cw.pane_total: + pane_frac = (cw.pane_num or 0) / cw.pane_total + composite_done = tree.windows_done + pane_frac + composite_bar = render_bar(int(composite_done * 100), win_tot * 100) + else: + composite_bar = render_bar(0, 0) + + pane_bar = render_bar(tree.session_panes_done, spt) + window_bar = render_bar(tree.windows_done, win_tot) + + def _color_bar(plain: str) -> str: + if not plain: + return plain + filled = plain.count("█") + empty = plain.count("░") + return self.colors.success("█" * filled) + self.colors.muted("░" * empty) + + return { + "session": self.colors.highlight(tree.session_name or ""), + "bar": _color_bar(composite_bar), + "pane_bar": _color_bar(pane_bar), + "window_bar": _color_bar(window_bar), + "status_icon": "", + } + + def on_build_event(self, event: dict[str, t.Any]) -> None: + """Forward build event to BuildTree and update spinner message inline. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("Loading", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.on_build_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 3, + ... }) + >>> spinner._build_tree.session_name + 'myapp' + """ + self._build_tree.on_event(event) + if self._progress_format is not None: + extra = self._build_extra() + rendered = self._build_tree.format_template( + self._progress_format, extra=extra + ) + # Only switch to template output once a window has started so that + # the session_created → window_started gap doesn't show empty brackets. + self.message = ( + rendered + if (rendered and self._build_tree.windows) + else self._base_message + ) + else: + self.message = self._build_tree.format_inline(self._base_message) + + def start(self) -> None: + """Start the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + """ + if not self._enabled: + return + + atexit.register(self._restore_cursor) + self._stop_event.clear() + self._thread = threading.Thread(target=self._spin, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the spinner thread. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("test", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.start() + >>> spinner.stop() + >>> spinner._thread is None + True + """ + if self._thread and self._thread.is_alive(): + self._stop_event.set() + self._thread.join() + self._thread = None + atexit.unregister(self._restore_cursor) + + def format_success(self) -> str: + """Render the success template with current build state. + + Uses :data:`SUCCESS_TEMPLATE` with colored ``{session}`` + (``highlight()``), ``{workspace_path}`` (``info()``), and + ``{summary}`` (``muted()``) from :meth:`BuildTree._context`. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream, + ... workspace_path="~/.tmuxp/myapp.yaml") + >>> spinner._build_tree.on_event({ + ... "event": "session_created", "name": "myapp", + ... "window_total": 2, "session_pane_total": 4, + ... }) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w1", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner._build_tree.on_event( + ... {"event": "window_started", "name": "w2", "pane_total": 2}) + >>> spinner._build_tree.on_event({"event": "window_done"}) + >>> spinner.format_success() + 'Loaded workspace: myapp (~/.tmuxp/myapp.yaml) [2 win, 4 panes]' + """ + tree = self._build_tree + ctx = tree._context() + extra: dict[str, t.Any] = { + "session": self.colors.highlight(tree.session_name or ""), + "workspace_path": self.colors.info(ctx.get("workspace_path", "")), + "summary": self.colors.muted(ctx.get("summary", "")) + if ctx.get("summary") + else "", + } + return SUCCESS_TEMPLATE.format_map(_SafeFormatMap({**ctx, **extra})) + + def success(self, text: str | None = None) -> None: + """Stop the spinner and print a success line. + + Parameters + ---------- + text : str | None + The success message to display after the checkmark. + When ``None``, uses :meth:`format_success` if a progress format + is configured, otherwise falls back to ``_base_message``. + + Examples + -------- + >>> import io + >>> stream = io.StringIO() + >>> spinner = Spinner("x", color_mode=ColorMode.NEVER, stream=stream) + >>> spinner.success("done") + >>> "✓ done" in stream.getvalue() + True + + With no args and no progress format, falls back to base message: + + >>> stream2 = io.StringIO() + >>> spinner2 = Spinner("Loading...", color_mode=ColorMode.NEVER, stream=stream2) + >>> spinner2.success() + >>> "✓ Loading..." in stream2.getvalue() + True + """ + self.stop() + if text is None and self._progress_format is not None: + text = self.format_success() + elif text is None: + text = self._base_message + checkmark = self.colors.success("\u2713") + msg = f"{checkmark} {text}" + self.stream.write(f"{msg}\n") + self.stream.flush() + + def __enter__(self) -> Spinner: + self.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> t.Literal[False]: + self.stop() + return False From 12e5f12bee8a14a1974c9ed166b35c45e958f6f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:49:43 -0500 Subject: [PATCH 22/89] feat(load[spinner]): wire progress spinner into tmuxp load command why: Users need visual feedback during workspace builds, especially for sessions with many windows or long before_script executions. what: - Add _silence_stream_handlers() to suppress StreamHandler during spinner - Add _dispatch_build() extracting shared build/attach/error logic - Wire Spinner.on_build_event and add_output_line to builder callbacks - Add --progress-format / TMUXP_PROGRESS_FORMAT for preset or custom format - Add --progress-lines / TMUXP_PROGRESS_LINES for panel height control - Add --no-progress / TMUXP_PROGRESS=0 to disable spinner entirely - Emit persistent success line with checkmark after successful build - Stop spinner before interactive prompts (TMUX switch, error recovery) --- src/tmuxp/cli/load.py | 380 +++++++++++++++++++++++++++++++++++------- 1 file changed, 318 insertions(+), 62 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 4673c4cde6..375cdb1b22 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import contextlib import importlib import logging import os @@ -21,10 +22,41 @@ from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir from ._colors import ColorMode, Colors, build_description, get_color_mode +from ._progress import ( + DEFAULT_OUTPUT_LINES, + SUCCESS_TEMPLATE, + Spinner, + _SafeFormatMap, + resolve_progress_format, +) from .utils import prompt_choices, prompt_yes_no, tmuxp_echo logger = logging.getLogger(__name__) + +@contextlib.contextmanager +def _silence_stream_handlers(logger_name: str = "tmuxp") -> t.Iterator[None]: + """Temporarily raise StreamHandler level to WARNING while spinner is active. + + INFO/DEBUG log records are diagnostics for aggregators, not user-facing output; + the spinner is the user-facing progress channel. Restores original levels on exit. + """ + _log = logging.getLogger(logger_name) + saved: list[tuple[logging.StreamHandler[t.Any], int]] = [ + (h, h.level) + for h in _log.handlers + if isinstance(h, logging.StreamHandler) + and not isinstance(h, logging.FileHandler) + ] + for h, _ in saved: + h.setLevel(logging.WARNING) + try: + yield + finally: + for h, level in saved: + h.setLevel(level) + + LOAD_DESCRIPTION = build_description( """ Load tmuxp workspace file(s) and create or attach to a tmux session. @@ -77,6 +109,9 @@ class CLILoadNamespace(argparse.Namespace): color: CLIColorModeLiteral log_file: str | None log_level: str + progress_format: str | None + panel_lines: int | None + no_progress: bool def load_plugins( @@ -196,7 +231,11 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: builder.session.attach() -def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: +def _load_attached( + builder: WorkspaceBuilder, + detached: bool, + pre_attach_hook: t.Callable[[], None] | None = None, +) -> None: """ Load workspace in new session. @@ -204,10 +243,16 @@ def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: ---------- builder: :class:`workspace.builder.WorkspaceBuilder` detached : bool + pre_attach_hook : callable, optional + called after build, before attach/switch_client; use to stop the spinner + so its cleanup sequences don't appear inside the tmux pane. """ builder.build() assert builder.session is not None + if pre_attach_hook is not None: + pre_attach_hook() + if "TMUX" in os.environ: # tmuxp ran from inside tmux # unset TMUX, save it, e.g. '/tmp/tmux-1000/default,30668,0' tmux_env = os.environ.pop("TMUX") @@ -219,7 +264,11 @@ def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: builder.session.attach() -def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: +def _load_detached( + builder: WorkspaceBuilder, + colors: Colors | None = None, + pre_output_hook: t.Callable[[], None] | None = None, +) -> None: """ Load workspace in new session but don't attach. @@ -228,11 +277,16 @@ def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> N builder: :class:`workspace.builder.WorkspaceBuilder` colors : Colors | None Optional Colors instance for styled output. + pre_output_hook : Callable | None + Called after build but before printing, e.g. to stop a spinner. """ builder.build() assert builder.session is not None + if pre_output_hook is not None: + pre_output_hook() + msg = "Session created in detached state." tmuxp_echo(colors.info(msg) if colors else msg) logger.info("session created in detached state") @@ -265,6 +319,123 @@ def _setup_plugins(builder: WorkspaceBuilder) -> Session: return builder.session +def _dispatch_build( + builder: WorkspaceBuilder, + detached: bool, + append: bool, + answer_yes: bool, + cli_colors: Colors, + pre_attach_hook: t.Callable[[], None] | None = None, + on_error_hook: t.Callable[[], None] | None = None, + pre_prompt_hook: t.Callable[[], None] | None = None, +) -> Session | None: + """Dispatch the build to the correct load path and handle errors. + + Handles the detached/attached/append switching logic and the + ``TmuxpException`` error-recovery prompt. Extracted so the + spinner-enabled and spinner-disabled paths share one implementation. + + Parameters + ---------- + builder : WorkspaceBuilder + Configured workspace builder. + detached : bool + Load session in detached state. + append : bool + Append windows to the current session. + answer_yes : bool + Skip interactive prompts. + cli_colors : Colors + Colors instance for styled output. + pre_attach_hook : callable, optional + Called before attach/switch_client (e.g. stop spinner). + on_error_hook : callable, optional + Called before showing the error-recovery prompt (e.g. stop spinner). + pre_prompt_hook : callable, optional + Called before any interactive prompt (e.g. stop spinner so ANSI + escape sequences don't garble the terminal during user input). + + Returns + ------- + Session | None + The built session, or ``None`` if the user killed it on error. + + Examples + -------- + >>> from tmuxp.cli.load import _dispatch_build + >>> callable(_dispatch_build) + True + """ + try: + if detached: + _load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook) + return _setup_plugins(builder) + + if append: + if "TMUX" in os.environ: # tmuxp ran from inside tmux + _load_append_windows_to_current_session(builder) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + return _setup_plugins(builder) + + # append and answer_yes have no meaning if specified together + if answer_yes: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + return _setup_plugins(builder) + + if "TMUX" in os.environ: # tmuxp ran from inside tmux + if pre_prompt_hook is not None: + pre_prompt_hook() + msg = ( + "Already inside TMUX, switch to session? yes/no\n" + "Or (a)ppend windows in the current active session?\n[y/n/a]" + ) + options = ["y", "n", "a"] + choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) + + if choice == "y": + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + elif choice == "a": + _load_append_windows_to_current_session(builder) + else: + _load_detached(builder, cli_colors) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + except exc.TmuxpException as e: + if on_error_hook is not None: + on_error_hook() + logger.debug("workspace build failed", exc_info=True) + tmuxp_echo(cli_colors.error("[Error]") + f" {e}") + + choice = prompt_choices( + cli_colors.error("Error loading workspace.") + + " (k)ill, (a)ttach, (d)etach?", + choices=["k", "a", "d"], + default="k", + color_mode=cli_colors.mode, + ) + + if choice == "k": + if builder.session is not None: + builder.session.kill() + tmuxp_echo(cli_colors.muted("Session killed.")) + logger.info("session killed by user after build error") + elif choice == "a": + _reattach(builder, cli_colors) + else: + sys.exit() + return None + finally: + builder.on_progress = None + builder.on_before_script = None + builder.on_script_output = None + builder.on_build_event = None + + return _setup_plugins(builder) + + def load_workspace( workspace_file: StrPath, socket_name: str | None = None, @@ -276,6 +447,9 @@ def load_workspace( answer_yes: bool = False, append: bool = False, cli_colors: Colors | None = None, + progress_format: str | None = None, + panel_lines: int | None = None, + no_progress: bool = False, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -301,6 +475,15 @@ def load_workspace( Default False. cli_colors : Colors, optional Colors instance for CLI output formatting. If None, uses AUTO mode. + progress_format : str, optional + Spinner format preset name or custom format string with tokens. + panel_lines : int, optional + Number of script-output lines shown in the spinner panel. + Defaults to the :class:`~tmuxp.cli._progress.Spinner` default (3). + Override via ``TMUXP_PROGRESS_LINES`` environment variable. + no_progress : bool + Disable the progress spinner entirely. Default False. + Also disabled when ``TMUXP_PROGRESS=0``. Notes ----- @@ -361,11 +544,13 @@ def load_workspace( "loading workspace", extra={"tmux_config_path": str(workspace_file)}, ) - tmuxp_echo( - cli_colors.info("[Loading]") - + " " - + cli_colors.highlight(str(PrivatePath(workspace_file))), - ) + _progress_disabled = no_progress or os.getenv("TMUXP_PROGRESS", "1") == "0" + if _progress_disabled: + tmuxp_echo( + cli_colors.info("[Loading]") + + " " + + cli_colors.highlight(str(PrivatePath(workspace_file))), + ) # ConfigReader allows us to open a yaml or json file as a dict raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} @@ -425,64 +610,96 @@ def load_workspace( _reattach(builder, cli_colors) return None - try: - if detached: - _load_detached(builder, cli_colors) - return _setup_plugins(builder) - - if append: - if "TMUX" in os.environ: # tmuxp ran from inside tmux - _load_append_windows_to_current_session(builder) - else: - _load_attached(builder, detached) - - return _setup_plugins(builder) - - # append and answer_yes have no meaning if specified together - if answer_yes: - _load_attached(builder, detached) - return _setup_plugins(builder) - - if "TMUX" in os.environ: # tmuxp ran from inside tmux - msg = ( - "Already inside TMUX, switch to session? yes/no\n" - "Or (a)ppend windows in the current active session?\n[y/n/a]" + if _progress_disabled: + _private_path = str(PrivatePath(workspace_file)) + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + ) + if result is not None: + summary = "" + try: + win_count = len(result.windows) + pane_count = sum(len(w.panes) for w in result.windows) + summary_parts: list[str] = [] + if win_count: + summary_parts.append(f"{win_count} win") + if pane_count: + summary_parts.append(f"{pane_count} panes") + summary = f"[{', '.join(summary_parts)}]" if summary_parts else "" + except Exception: + logger.debug("session gone before summary", exc_info=True) + ctx = { + "session": cli_colors.highlight(session_name), + "workspace_path": cli_colors.info(_private_path), + "summary": cli_colors.muted(summary) if summary else "", + } + checkmark = cli_colors.success("\u2713") + tmuxp_echo( + f"{checkmark} {SUCCESS_TEMPLATE.format_map(_SafeFormatMap(ctx))}" ) - options = ["y", "n", "a"] - choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) + return result - if choice == "y": - _load_attached(builder, detached) - elif choice == "a": - _load_append_windows_to_current_session(builder) - else: - _load_detached(builder, cli_colors) - else: - _load_attached(builder, detached) - - except exc.TmuxpException as e: - logger.debug("workspace build failed", exc_info=True) - tmuxp_echo(cli_colors.error("[Error]") + f" {e}") - - choice = prompt_choices( - cli_colors.error("Error loading workspace.") - + " (k)ill, (a)ttach, (d)etach?", - choices=["k", "a", "d"], - default="k", - color_mode=cli_colors.mode, + # Spinner wraps only the actual build phase + _progress_fmt = resolve_progress_format( + progress_format + if progress_format is not None + else os.getenv("TMUXP_PROGRESS_FORMAT", "default") + ) + _panel_lines_env = os.getenv("TMUXP_PROGRESS_LINES") + if _panel_lines_env: + try: + _panel_lines_env_int: int | None = int(_panel_lines_env) + except ValueError: + _panel_lines_env_int = None + else: + _panel_lines_env_int = None + _panel_lines = panel_lines if panel_lines is not None else _panel_lines_env_int + _private_path = str(PrivatePath(workspace_file)) + _spinner = Spinner( + message=( + f"Loading workspace: {cli_colors.highlight(session_name)} ({_private_path})" + ), + color_mode=cli_colors.mode, + progress_format=_progress_fmt, + output_lines=_panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES, + workspace_path=_private_path, + ) + _success_emitted = False + + def _emit_success() -> None: + nonlocal _success_emitted + if _success_emitted: + return + _success_emitted = True + _spinner.success() + + with ( + _silence_stream_handlers(), + _spinner as spinner, + ): + builder.on_build_event = spinner.on_build_event + _resolved_panel = ( + _panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES ) - - if choice == "k": - if builder.session is not None: - builder.session.kill() - tmuxp_echo(cli_colors.muted("Session killed.")) - logger.info("session killed by user after build error") - elif choice == "a": - _reattach(builder, cli_colors) - else: - sys.exit() - - return _setup_plugins(builder) + if _resolved_panel != 0: + builder.on_script_output = spinner.add_output_line + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + pre_attach_hook=_emit_success, + on_error_hook=spinner.stop, + pre_prompt_hook=spinner.stop, + ) + if result is not None: + _emit_success() + return result def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: @@ -567,6 +784,42 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP help="file to log errors/output to", ) + parser.add_argument( + "--progress-format", + metavar="FORMAT", + dest="progress_format", + default=None, + help=( + "Spinner line format: preset name " + "(default, minimal, window, pane, verbose) " + "or a format string with tokens " + "{session}, {window}, {progress}, {window_progress}, {pane_progress}, etc. " + "Env: TMUXP_PROGRESS_FORMAT" + ), + ) + + parser.add_argument( + "--progress-lines", + metavar="N", + dest="panel_lines", + type=int, + default=None, + help=( + "Number of script-output lines shown in the spinner panel (default: 3). " + "0 hides the panel entirely (script output goes to stdout). " + "-1 shows unlimited lines (capped to terminal height). " + "Env: TMUXP_PROGRESS_LINES" + ), + ) + + parser.add_argument( + "--no-progress", + dest="no_progress", + action="store_true", + default=False, + help=("Disable the animated progress spinner. Env: TMUXP_PROGRESS=0"), + ) + try: import shtab @@ -648,4 +901,7 @@ def command_load( answer_yes=args.answer_yes or False, append=args.append or False, cli_colors=cli_colors, + progress_format=args.progress_format, + panel_lines=args.panel_lines, + no_progress=args.no_progress, ) From 2528c5908deeb8f14133f9d4227333b7733c8437 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 14:51:55 -0500 Subject: [PATCH 23/89] test(progress[coverage]): add comprehensive tests for spinner and build progress why: The progress spinner and its CLI wiring need thorough test coverage for BuildTree state, template rendering, bar generation, panel behavior, and CLI flag handling. what: - Add tests/cli/test_progress.py covering Spinner lifecycle, BuildTree state transitions, template presets, bar rendering, panel lines, ANSI truncation, non-TTY fallback, and success output - Add tests/workspace/test_progress.py for builder callback integration - Update tests/cli/test_load.py for progress output assertions --- tests/cli/test_load.py | 80 ++ tests/cli/test_progress.py | 1425 ++++++++++++++++++++++++++++++ tests/workspace/test_progress.py | 258 ++++++ 3 files changed, 1763 insertions(+) create mode 100644 tests/cli/test_progress.py create mode 100644 tests/workspace/test_progress.py diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 60c2c446ed..ec045dcf3c 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -807,6 +807,86 @@ def test_load_no_ansi_in_nontty_stderr( assert "\x1b[" not in captured.err, "ANSI codes leaked into non-TTY stderr" +class ProgressDisableFixture(t.NamedTuple): + """Test fixture for progress disable logic.""" + + test_id: str + env_value: str | None + no_progress_flag: bool + expected_disabled: bool + + +PROGRESS_DISABLE_FIXTURES: list[ProgressDisableFixture] = [ + ProgressDisableFixture("default_enabled", None, False, False), + ProgressDisableFixture("env_disabled", "0", False, True), + ProgressDisableFixture("flag_disabled", None, True, True), + ProgressDisableFixture("env_enabled_explicit", "1", False, False), + ProgressDisableFixture("flag_overrides_env", "1", True, True), +] + + +@pytest.mark.parametrize( + list(ProgressDisableFixture._fields), + PROGRESS_DISABLE_FIXTURES, + ids=[f.test_id for f in PROGRESS_DISABLE_FIXTURES], +) +def test_progress_disable_logic( + test_id: str, + env_value: str | None, + no_progress_flag: bool, + expected_disabled: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Progress disable expression matches expected behavior.""" + if env_value is not None: + monkeypatch.setenv("TMUXP_PROGRESS", env_value) + else: + monkeypatch.delenv("TMUXP_PROGRESS", raising=False) + + import os + + result = no_progress_flag or os.getenv("TMUXP_PROGRESS", "1") == "0" + assert result is expected_disabled + + +def test_load_workspace_no_progress( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with no_progress=True creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + no_progress=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + +def test_load_workspace_env_progress_disabled( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace with TMUXP_PROGRESS=0 creates session without spinner.""" + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setenv("TMUXP_PROGRESS", "0") + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + session = load_workspace( + session_file, + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "sample workspace" + + def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) diff --git a/tests/cli/test_progress.py b/tests/cli/test_progress.py new file mode 100644 index 0000000000..8ace187b65 --- /dev/null +++ b/tests/cli/test_progress.py @@ -0,0 +1,1425 @@ +"""Tests for tmuxp CLI progress indicator.""" + +from __future__ import annotations + +import atexit +import io +import pathlib +import time +import typing as t + +import libtmux +import pytest + +from tmuxp.cli._colors import ColorMode +from tmuxp.cli._progress import ( + BAR_WIDTH, + ERASE_LINE, + HIDE_CURSOR, + PROGRESS_PRESETS, + SHOW_CURSOR, + SUCCESS_TEMPLATE, + BuildTree, + Spinner, + _truncate_visible, + _visible_len, + render_bar, + resolve_progress_format, +) + + +class SpinnerEnablementFixture(t.NamedTuple): + """Test fixture for spinner TTY/color enablement matrix.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_enabled: bool + + +SPINNER_ENABLEMENT_FIXTURES: list[SpinnerEnablementFixture] = [ + SpinnerEnablementFixture("tty_color_always", True, ColorMode.ALWAYS, True), + SpinnerEnablementFixture("tty_color_auto", True, ColorMode.AUTO, True), + SpinnerEnablementFixture("tty_color_never", True, ColorMode.NEVER, True), + SpinnerEnablementFixture("non_tty_color_always", False, ColorMode.ALWAYS, False), + SpinnerEnablementFixture("non_tty_color_never", False, ColorMode.NEVER, False), +] + + +@pytest.mark.parametrize( + list(SpinnerEnablementFixture._fields), + SPINNER_ENABLEMENT_FIXTURES, + ids=[f.test_id for f in SPINNER_ENABLEMENT_FIXTURES], +) +def test_spinner_enablement( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_enabled: bool, +) -> None: + """Spinner._enabled depends only on TTY, not on color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + assert spinner._enabled is expected_enabled + + +def test_spinner_disabled_output() -> None: + """Disabled spinner produces no output.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + with Spinner(message="Test", stream=stream) as spinner: + spinner.update_message("Updated") + + assert stream.getvalue() == "" + + +def test_spinner_enabled_output() -> None: + """Enabled spinner writes ANSI control sequences.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Test", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ): + pass # enter and exit — enough for at least one frame + cleanup + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert ERASE_LINE in output + assert "Test" in output + + +def test_spinner_atexit_registered(monkeypatch: pytest.MonkeyPatch) -> None: + """atexit.register called on start, unregistered on stop.""" + registered: list[t.Any] = [] + unregistered: list[t.Any] = [] + monkeypatch.setattr(atexit, "register", lambda fn, *a: registered.append(fn)) + monkeypatch.setattr(atexit, "unregister", lambda fn: unregistered.append(fn)) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream) as spinner: + assert len(registered) == 1 + assert spinner._restore_cursor in registered + + assert len(unregistered) == 1 + assert spinner._restore_cursor in unregistered + + +def test_spinner_cleans_up_on_exception() -> None: + """SHOW_CURSOR written even when body raises.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + msg = "deliberate" + with ( + pytest.raises(ValueError), + Spinner(message="Test", color_mode=ColorMode.ALWAYS, stream=stream), + ): + raise ValueError(msg) + + assert SHOW_CURSOR in stream.getvalue() + + +def test_spinner_update_message_thread_safe() -> None: + """update_message() can be called from the main thread without error.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Start", color_mode=ColorMode.NEVER, stream=stream) + spinner.update_message("Updated") + assert spinner.message == "Updated" + + +def test_spinner_add_output_line_accumulates() -> None: + """add_output_line() appends stripped lines to the panel deque on TTY.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + spinner.add_output_line("Session created: test\n") + spinner.add_output_line("Creating window: editor") + spinner.add_output_line("") # blank lines are ignored + + assert list(spinner._output_lines) == [ + "Session created: test", + "Creating window: editor", + ] + + +def test_spinner_panel_respects_maxlen() -> None: + """Panel deque enforces output_lines maxlen, dropping oldest lines.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", color_mode=ColorMode.NEVER, stream=stream, output_lines=3 + ) + for i in range(5): + spinner.add_output_line(f"line {i}") + + panel = list(spinner._output_lines) + assert len(panel) == 3 + assert panel == ["line 2", "line 3", "line 4"] + + +def test_spinner_panel_rendered_in_output() -> None: + """Enabled spinner writes panel lines and spinner line to stream.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + with Spinner( + message="Building...", color_mode=ColorMode.ALWAYS, stream=stream, interval=0.01 + ) as spinner: + spinner.add_output_line("Session created: my-session") + # Wait long enough for the spinner thread to render at least one frame + # that includes the panel line (interval=0.01s, so 0.05s is sufficient). + time.sleep(0.05) + + output = stream.getvalue() + assert HIDE_CURSOR in output + assert SHOW_CURSOR in output + assert "Session created: my-session" in output + assert "Building..." in output + + +# BuildTree tests + + +def test_build_tree_empty_renders_nothing() -> None: + """BuildTree.render() returns [] before any session_created event.""" + colors = ColorMode.NEVER + tree = BuildTree() + from tmuxp.cli._colors import Colors + + assert tree.render(Colors(colors), 80) == [] + + +def test_build_tree_session_created_shows_header() -> None: + """After session_created, render() returns the 'Session' heading line.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines == ["Session"] + + +def test_build_tree_window_started_no_pane_yet() -> None: + """window_started adds a window line with just the name (no pane info).""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert len(lines) == 2 + assert lines[1] == "- editor" + + +def test_build_tree_pane_creating_shows_progress() -> None: + """pane_creating updates the last window to show pane N of M.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 3}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- editor, pane (2 of 3)" + + +def test_build_tree_window_done_shows_checkmark() -> None: + """window_done marks the window as done; render shows checkmark.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + tree.on_event({"event": "window_done"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + + +def test_build_tree_workspace_built_marks_all_done() -> None: + """workspace_built marks all windows as done.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 1}) + tree.on_event({"event": "window_started", "name": "logs", "pane_total": 1}) + tree.on_event({"event": "workspace_built"}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- ✓ logs" + + +def test_build_tree_multiple_windows_accumulate() -> None: + """Multiple window_started events accumulate into separate tree lines.""" + from tmuxp.cli._colors import Colors + + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "my-session"}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "logging", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + lines = tree.render(Colors(ColorMode.NEVER), 80) + assert lines[1] == "- ✓ editor" + assert lines[2] == "- logging, pane (1 of 1)" + + +def test_spinner_on_build_event_delegates_to_tree() -> None: + """Spinner.on_build_event() updates the internal BuildTree state.""" + import io + + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner(message="Building...", color_mode=ColorMode.NEVER, stream=stream) + spinner.on_build_event({"event": "session_created", "name": "test-session"}) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 1} + ) + + assert spinner._build_tree.session_name == "test-session" + assert len(spinner._build_tree.windows) == 1 + assert spinner._build_tree.windows[0].name == "editor" + + +# BuildTree.format_inline tests + + +def test_build_tree_format_inline_empty() -> None: + """format_inline returns base unchanged when no session has been created.""" + tree = BuildTree() + assert tree.format_inline("Building projects...") == "Building projects..." + + +def test_build_tree_format_inline_session_only() -> None: + """format_inline returns 'base session' after session_created with no windows.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_inline("Building projects...") == "Building projects... cihai" + + +def test_build_tree_format_inline_with_window_total() -> None: + """format_inline shows window index/total bracket after window_started.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows] gp-libs" + + +def test_build_tree_format_inline_with_panes() -> None: + """format_inline includes pane progress once pane_creating fires.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "gp-libs", "pane_total": 2}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + result = tree.format_inline("Building projects...") + assert result == "Building projects... cihai [1 of 3 windows, 1 of 2 panes] gp-libs" + + +def test_build_tree_format_inline_no_window_total() -> None: + """format_inline omits window count bracket when window_total is absent.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai"}) + tree.on_event({"event": "window_started", "name": "main", "pane_total": 1}) + tree.on_event({"event": "pane_creating", "pane_num": 1, "pane_total": 1}) + result = tree.format_inline("Building...") + assert result == "Building... cihai [1 of 1 panes] main" + + +def test_spinner_on_build_event_updates_message() -> None: + """on_build_event updates spinner.message via format_inline after each event.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 3}) + assert spinner.message == "Building... cihai [1 of 2 windows, 2 of 3 panes] editor" + + +# resolve_progress_format tests + + +def test_resolve_progress_format_preset_name() -> None: + """A known preset name resolves to its format string.""" + assert resolve_progress_format("default") == PROGRESS_PRESETS["default"] + assert resolve_progress_format("minimal") == PROGRESS_PRESETS["minimal"] + assert resolve_progress_format("verbose") == PROGRESS_PRESETS["verbose"] + + +def test_resolve_progress_format_raw_string() -> None: + """A raw template string is returned unchanged.""" + raw = "{session} w{window_progress}" + assert resolve_progress_format(raw) == raw + + +def test_resolve_progress_format_unknown_name() -> None: + """An unknown name not in presets is returned as-is (raw template pass-through).""" + assert resolve_progress_format("not-a-preset") == "not-a-preset" + + +# BuildTree.format_template tests + + +def test_build_tree_format_template_before_session() -> None: + """format_template returns '' before session_created fires.""" + tree = BuildTree() + assert tree.format_template("{session} [{progress}] {window}") == "" + + +def test_build_tree_format_template_session_only() -> None: + """After session_created alone, progress and window are empty.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + assert tree.format_template("{session} [{progress}] {window}") == "cihai [] " + + +def test_build_tree_format_template_with_window() -> None: + """After window_started, window progress appears but pane progress does not.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win] editor" + ) + + +def test_build_tree_format_template_with_pane() -> None: + """After pane_creating, both window and pane progress appear.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert ( + tree.format_template("{session} [{progress}] {window}") + == "cihai [1/3 win · 2/4 pane] editor" + ) + + +def test_build_tree_format_template_minimal() -> None: + """The minimal preset-style template shows only window fraction.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + assert tree.format_template("{session} [{window_progress}]") == "cihai [1/3]" + + +def test_build_tree_format_template_verbose() -> None: + """Verbose template shows window/pane indices and totals explicitly.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 12}) + tree.on_event({"event": "window_started", "name": "editor", "pane_total": 4}) + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + result = tree.format_template(PROGRESS_PRESETS["verbose"]) + assert result == "Loading workspace: cihai [window 1 of 12 · pane 2 of 4] editor" + + +def test_build_tree_format_template_bad_token() -> None: + """Unknown tokens are left as {name}, known tokens still resolve.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown}") + # _SafeFormatMap: {session} resolves, {unknown} stays as-is + assert result == "cihai {unknown}" + + +# Spinner.progress_format integration tests + + +def test_spinner_progress_format_updates_message() -> None: + """Spinner with explicit progress_format uses format_template for updates.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + # Use an explicit format string rather than "default" preset to avoid + # coupling this test to the preset definition (which now includes {bar}). + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} [{progress}] {window}", + ) + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 3} + ) + # No windows yet — falls back to base message to avoid showing empty brackets. + assert spinner.message == "Building..." + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 4} + ) + assert spinner.message == "cihai [1/3 win] editor" + + spinner.on_build_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert spinner.message == "cihai [1/3 win · 2/4 pane] editor" + + +def test_spinner_progress_format_none_uses_inline() -> None: + """Spinner with progress_format=None preserves the format_inline path.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + + spinner.on_build_event( + {"event": "session_created", "name": "cihai", "window_total": 2} + ) + assert spinner.message == "Building... cihai" + + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 3} + ) + assert spinner.message == "Building... cihai [1 of 2 windows] editor" + + +# render_bar tests + + +def test_render_bar_empty() -> None: + """render_bar with done=0 produces an all-empty bar.""" + assert render_bar(0, 10) == "░░░░░░░░░░" + + +def test_render_bar_half() -> None: + """render_bar with done=5, total=10 fills exactly half.""" + assert render_bar(5, 10) == "█████░░░░░" + + +def test_render_bar_full() -> None: + """render_bar with done=total fills the entire bar.""" + assert render_bar(10, 10) == "██████████" + + +def test_render_bar_zero_total() -> None: + """render_bar with total=0 returns empty string.""" + assert render_bar(0, 0) == "" + + +def test_render_bar_custom_width() -> None: + """render_bar with custom width produces bar of that inner width.""" + assert render_bar(3, 10, width=5) == "█░░░░" + + +def test_render_bar_width_constant() -> None: + """BAR_WIDTH is the default inner width used by render_bar.""" + bar = render_bar(0, 10) + assert len(bar) == BAR_WIDTH + + +# BuildTree new token tests + + +def test_build_tree_context_session_pane_total() -> None: + """session_pane_total token reflects count from session_created event.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + ctx = tree._context() + assert ctx["session_pane_total"] == 8 + assert ctx["session_pane_progress"] == "0/8" + assert ctx["overall_percent"] == 0 + + +def test_build_tree_context_window_progress_rel() -> None: + """window_progress_rel is 0/N from session_created, increments on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 2}) + assert tree._context()["window_progress_rel"] == "0/3" + + tree.on_event({"event": "window_done"}) + assert tree._context()["window_progress_rel"] == "1/3" + + +def test_build_tree_context_pane_progress_rel() -> None: + """pane_progress_rel shows 0/M after window_started, N/M after pane_creating.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 4, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "0/4" + + tree.on_event({"event": "pane_creating", "pane_num": 2, "pane_total": 4}) + assert tree._context()["pane_progress_rel"] == "2/4" + assert tree._context()["pane_done"] == 2 + assert tree._context()["pane_remaining"] == 2 + + +def test_build_tree_context_overall_percent() -> None: + """overall_percent is pane-based 0-100; updates on window_done.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 8, + } + ) + assert tree._context()["overall_percent"] == 0 + + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 4}) + tree.on_event({"event": "window_done"}) + assert tree._context()["session_panes_done"] == 4 + assert tree._context()["overall_percent"] == 50 + + +def test_build_tree_before_script_event_toggle() -> None: + """before_script_started sets the Event; before_script_done clears it.""" + tree = BuildTree() + assert not tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_started"}) + assert tree._before_script_event.is_set() + + tree.on_event({"event": "before_script_done"}) + assert not tree._before_script_event.is_set() + + +def test_build_tree_zero_pane_window() -> None: + """Windows with pane_total=0 do not cause division-by-zero or exceptions.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + + assert tree.session_panes_done == 0 + assert tree.windows_done == 1 + ctx = tree._context() + assert ctx["session_pane_progress"] == "" + assert ctx["overall_percent"] == 0 + + +def test_format_template_extra_backward_compat() -> None: + """format_template(fmt) without extra still works as before.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} [{progress}] {window}") + assert result == "cihai [] " + + +def test_format_template_extra_injected() -> None: + """format_template resolves extra tokens from the extra dict.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {bar}", extra={"bar": "[TEST_BAR]"}) + assert result == "cihai [TEST_BAR]" + + +def test_format_template_unknown_token_preserved() -> None: + """Unknown tokens in the format string render as {name}, not blank or raw fmt.""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "cihai", "window_total": 3}) + result = tree.format_template("{session} {unknown_token}") + assert result == "cihai {unknown_token}" + + +# Spinner bar token tests + + +def test_spinner_bar_token_no_color() -> None: + """With ColorMode.NEVER, {bar} token in message contains bar characters.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="{session} {bar} {progress} {window}", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "cihai", + "window_total": 3, + "session_pane_total": 6, + } + ) + spinner.on_build_event( + {"event": "window_started", "name": "editor", "pane_total": 2} + ) + spinner.on_build_event({"event": "pane_creating", "pane_num": 1, "pane_total": 2}) + + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_pane_bar_preset() -> None: + """The 'pane' preset wires {pane_bar} and {session_pane_progress}.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="pane", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 4, + } + ) + spinner.on_build_event({"event": "window_started", "name": "w1", "pane_total": 2}) + spinner.on_build_event({"event": "window_done"}) + + assert "2/4" in spinner.message + assert "░" in spinner.message or "█" in spinner.message + + +def test_spinner_before_script_event_via_events() -> None: + """before_script_started / before_script_done toggle the BuildTree Event flag.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.on_build_event({"event": "before_script_started"}) + assert spinner._build_tree._before_script_event.is_set() + + spinner.on_build_event({"event": "before_script_done"}) + assert not spinner._build_tree._before_script_event.is_set() + + +def test_progress_presets_have_expected_keys() -> None: + """All expected preset names are present in PROGRESS_PRESETS.""" + for name in ("default", "minimal", "window", "pane", "verbose"): + assert name in PROGRESS_PRESETS, f"Missing preset: {name}" + + +def test_progress_presets_default_includes_bar() -> None: + """The 'default' preset includes the {bar} token.""" + assert "{bar}" in PROGRESS_PRESETS["default"] + + +def test_progress_presets_minimal_format() -> None: + """The 'minimal' preset includes the Loading prefix and window_progress token.""" + expected = "Loading workspace: {session} [{window_progress}]" + assert PROGRESS_PRESETS["minimal"] == expected + + +# BuildTree remaining token tests + + +class RemainingTokenFixture(t.NamedTuple): + """Test fixture for windows_remaining and session_panes_remaining tokens.""" + + test_id: str + events: list[dict[str, t.Any]] + token: str + expected: int + + +REMAINING_TOKEN_FIXTURES: list[RemainingTokenFixture] = [ + RemainingTokenFixture( + "windows_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + ], + "windows_remaining", + 3, + ), + RemainingTokenFixture( + "windows_remaining_after_done", + [ + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + {"event": "window_started", "name": "w1", "pane_total": 2}, + {"event": "window_done"}, + ], + "windows_remaining", + 2, + ), + RemainingTokenFixture( + "session_panes_remaining_initial", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + ], + "session_panes_remaining", + 5, + ), + RemainingTokenFixture( + "session_panes_remaining_after_window", + [ + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 5, + }, + {"event": "window_started", "name": "w1", "pane_total": 3}, + {"event": "window_done"}, + ], + "session_panes_remaining", + 2, + ), +] + + +@pytest.mark.parametrize( + list(RemainingTokenFixture._fields), + REMAINING_TOKEN_FIXTURES, + ids=[f.test_id for f in REMAINING_TOKEN_FIXTURES], +) +def test_build_tree_remaining_tokens( + test_id: str, + events: list[dict[str, t.Any]], + token: str, + expected: int, +) -> None: + """Remaining tokens decrement correctly as windows/panes complete.""" + tree = BuildTree() + for ev in events: + tree.on_event(ev) + assert tree._context()[token] == expected + + +# _visible_len tests + + +class VisibleLenFixture(t.NamedTuple): + """Test fixture for _visible_len ANSI-aware length calculation.""" + + test_id: str + text: str + expected_len: int + + +VISIBLE_LEN_FIXTURES: list[VisibleLenFixture] = [ + VisibleLenFixture("plain_text", "hello", 5), + VisibleLenFixture("ansi_green", "\033[32mgreen\033[0m", 5), + VisibleLenFixture("empty_string", "", 0), + VisibleLenFixture("nested_ansi", "\033[1m\033[31mbold red\033[0m", 8), + VisibleLenFixture("ansi_only", "\033[0m", 0), +] + + +@pytest.mark.parametrize( + list(VisibleLenFixture._fields), + VISIBLE_LEN_FIXTURES, + ids=[f.test_id for f in VISIBLE_LEN_FIXTURES], +) +def test_visible_len( + test_id: str, + text: str, + expected_len: int, +) -> None: + """_visible_len returns the visible character count, ignoring ANSI escapes.""" + assert _visible_len(text) == expected_len + + +# Spinner.add_output_line non-TTY write-through tests + + +class OutputLineFixture(t.NamedTuple): + """Test fixture for add_output_line TTY vs non-TTY behavior.""" + + test_id: str + isatty: bool + lines: list[str] + expected_deque: list[str] + expected_stream_contains: str + + +OUTPUT_LINE_FIXTURES: list[OutputLineFixture] = [ + OutputLineFixture( + "tty_accumulates_in_deque", + isatty=True, + lines=["line1\n", "line2\n"], + expected_deque=["line1", "line2"], + expected_stream_contains="", + ), + OutputLineFixture( + "non_tty_writes_to_stream", + isatty=False, + lines=["hello\n", "world\n"], + expected_deque=[], + expected_stream_contains="hello\nworld\n", + ), + OutputLineFixture( + "blank_lines_ignored", + isatty=True, + lines=["", "\n"], + expected_deque=[], + expected_stream_contains="", + ), +] + + +@pytest.mark.parametrize( + list(OutputLineFixture._fields), + OUTPUT_LINE_FIXTURES, + ids=[f.test_id for f in OUTPUT_LINE_FIXTURES], +) +def test_spinner_output_line_behavior( + test_id: str, + isatty: bool, + lines: list[str], + expected_deque: list[str], + expected_stream_contains: str, +) -> None: + """add_output_line accumulates in deque (TTY) or writes to stream (non-TTY).""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=ColorMode.NEVER, stream=stream) + for line in lines: + spinner.add_output_line(line) + + assert list(spinner._output_lines) == expected_deque + assert expected_stream_contains in stream.getvalue() + + +# Spinner.success tests + + +# Panel lines special values tests + + +class PanelLinesFixture(t.NamedTuple): + """Test fixture for Spinner panel_lines special values.""" + + test_id: str + output_lines: int + expected_maxlen: int | None # None = unbounded + expected_hidden: bool + add_count: int + expected_retained: int + + +PANEL_LINES_FIXTURES: list[PanelLinesFixture] = [ + PanelLinesFixture("zero_hides_panel", 0, 1, True, 10, 1), + PanelLinesFixture("negative_unlimited", -1, None, False, 100, 100), + PanelLinesFixture("positive_normal", 5, 5, False, 10, 5), + PanelLinesFixture("default_three", 3, 3, False, 5, 3), +] + + +@pytest.mark.parametrize( + list(PanelLinesFixture._fields), + PANEL_LINES_FIXTURES, + ids=[f.test_id for f in PANEL_LINES_FIXTURES], +) +def test_spinner_panel_lines_special_values( + test_id: str, + output_lines: int, + expected_maxlen: int | None, + expected_hidden: bool, + add_count: int, + expected_retained: int, +) -> None: + """Spinner panel_lines=0 hides, -1 is unlimited, positive caps normally.""" + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=output_lines, + ) + for i in range(add_count): + spinner.add_output_line(f"line {i}") + + assert len(spinner._output_lines) == expected_retained + assert spinner._output_lines.maxlen == expected_maxlen + assert spinner._panel_hidden is expected_hidden + + +def test_spinner_unlimited_caps_rendered_panel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unlimited panel (-1) caps rendered lines to terminal_height - 2.""" + import os as _os + import shutil + + monkeypatch.setattr( + shutil, + "get_terminal_size", + lambda fallback=(80, 24): _os.terminal_size((80, 10)), + ) + + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + spinner = Spinner( + message="Test", + color_mode=ColorMode.NEVER, + stream=stream, + output_lines=-1, + interval=0.01, + ) + for i in range(50): + spinner.add_output_line(f"line {i}") + + # All 50 lines should be retained in the unbounded deque + assert len(spinner._output_lines) == 50 + + # Start spinner briefly to render at least one frame + spinner.start() + time.sleep(0.05) + spinner.stop() + + output = stream.getvalue() + # Verify that not all 50 lines appear in any single frame + # The cap should limit to terminal_height - 2 = 8 lines + # Only the last 8 lines should appear in output + assert "line 49" in output + assert "line 0" not in output + + +class SuccessFixture(t.NamedTuple): + """Test fixture for Spinner.success() output behavior.""" + + test_id: str + isatty: bool + color_mode: ColorMode + expected_contains: str + + +SUCCESS_FIXTURES: list[SuccessFixture] = [ + SuccessFixture("tty_with_color", True, ColorMode.ALWAYS, "done"), + SuccessFixture("tty_no_color", True, ColorMode.NEVER, "✓ done"), + SuccessFixture("non_tty", False, ColorMode.NEVER, "✓ done"), +] + + +@pytest.mark.parametrize( + list(SuccessFixture._fields), + SUCCESS_FIXTURES, + ids=[f.test_id for f in SUCCESS_FIXTURES], +) +def test_spinner_success_behavior( + test_id: str, + isatty: bool, + color_mode: ColorMode, + expected_contains: str, +) -> None: + """success() always emits the checkmark message regardless of TTY/color mode.""" + stream = io.StringIO() + stream.isatty = lambda: isatty # type: ignore[method-assign] + + spinner = Spinner(message="Test", color_mode=color_mode, stream=stream) + spinner.success("done") + + output = stream.getvalue() + assert "✓" in output + assert expected_contains in output + + +# _truncate_visible tests + + +def test_truncate_visible_plain_text() -> None: + """Plain text is truncated to max_visible chars with default suffix.""" + assert _truncate_visible("hello world", 5) == "hello\x1b[0m..." + + +def test_truncate_visible_ansi_preserved() -> None: + """ANSI sequences are preserved whole; only visible chars count.""" + result = _truncate_visible("\033[32mgreen\033[0m", 3) + assert result == "\x1b[32mgre\x1b[0m..." + + +def test_truncate_visible_no_truncation() -> None: + """String shorter than max_visible is returned unchanged.""" + assert _truncate_visible("short", 10) == "short" + + +def test_truncate_visible_empty() -> None: + """Empty string returns empty string.""" + assert _truncate_visible("", 5) == "" + + +def test_truncate_visible_custom_suffix() -> None: + """Custom suffix is appended after truncation.""" + assert _truncate_visible("hello world", 5, suffix="~") == "hello\x1b[0m~" + + +def test_truncate_visible_no_suffix() -> None: + """Empty suffix produces only the reset sequence.""" + assert _truncate_visible("hello world", 5, suffix="") == "hello\x1b[0m" + + +# workspace_path token tests + + +def test_build_tree_workspace_path_in_context() -> None: + """workspace_path is available in _context() when set on construction.""" + tree = BuildTree(workspace_path="~/.tmuxp/foo.yaml") + tree.on_event({"event": "session_created", "name": "foo", "window_total": 1}) + ctx = tree._context() + assert ctx["workspace_path"] == "~/.tmuxp/foo.yaml" + + +def test_build_tree_workspace_path_empty_default() -> None: + """workspace_path defaults to empty string in _context().""" + tree = BuildTree() + tree.on_event({"event": "session_created", "name": "s", "window_total": 1}) + assert tree._context()["workspace_path"] == "" + + +def test_spinner_workspace_path_passed_to_tree() -> None: + """Spinner passes workspace_path through to its BuildTree.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/proj.yaml", + ) + assert spinner._build_tree.workspace_path == "~/.tmuxp/proj.yaml" + + +def test_build_tree_workspace_path_in_template() -> None: + """workspace_path token resolves in format_template.""" + tree = BuildTree(workspace_path="~/.tmuxp/bar.yaml") + tree.on_event({"event": "session_created", "name": "bar", "window_total": 1}) + result = tree.format_template("{session} ({workspace_path})") + assert result == "bar (~/.tmuxp/bar.yaml)" + + +# {summary} token tests + + +def test_build_tree_summary_empty_state() -> None: + """Summary token is empty string before any windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + } + ) + assert tree._context()["summary"] == "" + + +def test_build_tree_summary_after_windows_done() -> None: + """Summary token shows bracketed win/pane counts after windows complete.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 8, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 2}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w3", "pane_total": 3}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[3 win, 8 panes]" + + +def test_build_tree_summary_windows_only_no_panes() -> None: + """Summary token shows only win count when pane_total is 0.""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 2, + "session_pane_total": 0, + } + ) + tree.on_event({"event": "window_started", "name": "w1", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + tree.on_event({"event": "window_started", "name": "w2", "pane_total": 0}) + tree.on_event({"event": "window_done"}) + assert tree._context()["summary"] == "[2 win]" + + +def test_build_tree_summary_panes_only() -> None: + """Summary token shows only pane count when windows_done is 0 (edge case).""" + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "s", + "window_total": 1, + "session_pane_total": 6, + } + ) + # Manually set session_panes_done without window_done to test edge case + tree.session_panes_done = 6 + assert tree._context()["summary"] == "[6 panes]" + + +# format_success() tests + + +def test_spinner_format_success_full_build() -> None: + """format_success renders SUCCESS_TEMPLATE with session, path, and summary.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/myapp.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "myapp", + "window_total": 3, + "session_pane_total": 8, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w1", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w2", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + spinner._build_tree.on_event( + {"event": "window_started", "name": "w3", "pane_total": 3} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "myapp" in result + assert "~/.tmuxp/myapp.yaml" in result + assert "[3 win, 8 panes]" in result + + +def test_spinner_format_success_no_windows() -> None: + """format_success with no windows/panes done omits brackets.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + workspace_path="~/.tmuxp/empty.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "empty", + "window_total": 0, + "session_pane_total": 0, + } + ) + + result = spinner.format_success() + assert "Loaded workspace:" in result + assert "empty" in result + assert "~/.tmuxp/empty.yaml" in result + assert "[" not in result + + +# Spinner.success() with no args tests + + +def test_spinner_success_no_args_template_mode() -> None: + """success() with no args uses format_success when progress_format is set.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + workspace_path="~/.tmuxp/proj.yaml", + ) + spinner._build_tree.on_event( + { + "event": "session_created", + "name": "proj", + "window_total": 1, + "session_pane_total": 2, + } + ) + spinner._build_tree.on_event( + {"event": "window_started", "name": "main", "pane_total": 2} + ) + spinner._build_tree.on_event({"event": "window_done"}) + + spinner.success() + + output = stream.getvalue() + assert "✓" in output + assert "Loaded workspace:" in output + assert "proj" in output + assert "~/.tmuxp/proj.yaml" in output + assert "[1 win, 2 panes]" in output + + +def test_spinner_success_no_args_no_template() -> None: + """success() with no args and no progress_format falls back to _base_message.""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading workspace: myapp", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format=None, + ) + spinner.success() + + output = stream.getvalue() + assert "✓ Loading workspace: myapp" in output + + +def test_spinner_success_explicit_text_backward_compat() -> None: + """success('custom text') still works as before (backward compat).""" + stream = io.StringIO() + stream.isatty = lambda: False # type: ignore[method-assign] + + spinner = Spinner( + message="Loading...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.success("custom done message") + + output = stream.getvalue() + assert "✓ custom done message" in output + + +# SUCCESS_TEMPLATE constant tests + + +def test_success_template_value() -> None: + """SUCCESS_TEMPLATE contains expected tokens.""" + assert "{session}" in SUCCESS_TEMPLATE + assert "{workspace_path}" in SUCCESS_TEMPLATE + assert "{summary}" in SUCCESS_TEMPLATE + assert "Loaded workspace:" in SUCCESS_TEMPLATE + + +def test_no_success_message_on_build_error( + server: libtmux.Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], +) -> None: + """Success message is not emitted when _dispatch_build returns None.""" + import yaml + + from tmuxp.cli._colors import Colors + from tmuxp.cli.load import load_workspace + + monkeypatch.delenv("TMUX", raising=False) + + config = {"session_name": "test-fail", "windows": [{"window_name": "main"}]} + config_file = tmp_path / "fail.yaml" + config_file.write_text(yaml.dump(config)) + + monkeypatch.setattr( + "tmuxp.cli.load._dispatch_build", + lambda *args, **kwargs: None, + ) + + result = load_workspace( + str(config_file), + socket_name=server.socket_name, + cli_colors=Colors(ColorMode.NEVER), + ) + + assert result is None + captured = capfd.readouterr() + assert "\u2713" not in captured.err + assert "Loaded workspace:" not in captured.err diff --git a/tests/workspace/test_progress.py b/tests/workspace/test_progress.py new file mode 100644 index 0000000000..336fa9dafd --- /dev/null +++ b/tests/workspace/test_progress.py @@ -0,0 +1,258 @@ +"""Tests for tmuxp workspace builder progress callback.""" + +from __future__ import annotations + +import typing as t + +import pytest +from libtmux.server import Server + +from tmuxp import exc +from tmuxp.workspace.builder import WorkspaceBuilder + + +def test_builder_on_progress_callback( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """WorkspaceBuilder calls on_progress at each build milestone.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "progress-test", + "windows": [{"window_name": "editor", "panes": [{"shell_command": []}]}], + } + + calls: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_progress=calls.append, + ) + builder.build() + + assert any("Session created:" in c for c in calls) + assert any("Creating window:" in c for c in calls) + assert any("Creating pane:" in c for c in calls) + assert "Workspace built" in calls + + +def test_builder_on_before_script_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_before_script callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-callback-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + called: list[bool] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_before_script=lambda: called.append(True), + ) + builder.build() + assert called == [] + + +def test_builder_on_script_output_not_called_without_script( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_script_output callback is not invoked when config has no before_script key.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "no-script-output-test", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + lines: list[str] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_script_output=lines.append, + ) + builder.build() + assert lines == [] + + +def test_builder_on_build_event_sequence( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_build_event fires the full event sequence during build().""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "build-event-test", + "windows": [ + { + "window_name": "editor", + "panes": [{"shell_command": []}, {"shell_command": []}], + }, + {"window_name": "logs", "panes": [{"shell_command": []}]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert event_types[0] == "session_created" + assert event_types[-1] == "workspace_built" + assert event_types.count("window_started") == 2 + assert event_types.count("window_done") == 2 + assert event_types.count("pane_creating") == 3 # 2 panes + 1 pane + + created = next(e for e in events if e["event"] == "session_created") + assert created["window_total"] == 2 + assert created["session_pane_total"] == 3 # 2 panes + 1 pane + + +def test_builder_on_build_event_session_name( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event carries correct session name.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "name-check", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["name"] == "name-check" + assert created["window_total"] == 1 + + +def test_builder_on_build_event_session_pane_total( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """session_created event includes session_pane_total summing all windows' panes.""" + monkeypatch.delenv("TMUX", raising=False) + + pane: dict[str, list[object]] = {"shell_command": []} + session_config = { + "session_name": "pane-total-test", + "windows": [ + {"window_name": "w1", "panes": [pane, pane]}, + {"window_name": "w2", "panes": [pane]}, + {"window_name": "w3", "panes": [pane, pane, pane]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + created = next(e for e in events if e["event"] == "session_created") + assert created["session_pane_total"] == 6 # 2 + 1 + 3 + + +def test_builder_before_script_events( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_started fires before run; before_script_done fires in finally.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-events-test", + "before_script": "echo hello", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + bs_start_idx = event_types.index("before_script_started") + bs_done_idx = event_types.index("before_script_done") + win_idx = event_types.index("window_started") + assert bs_start_idx < bs_done_idx < win_idx + + +def test_builder_before_script_done_fires_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script_done fires in finally even when the script fails.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "before-script-fail-test", + "before_script": "/bin/false", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + with pytest.raises(exc.BeforeLoadScriptError): + builder.build() + + event_types = [e["event"] for e in events] + assert "before_script_started" in event_types + assert "before_script_done" in event_types + + +def test_builder_on_build_event_pane_numbers( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """pane_creating events carry 1-based pane_num and correct pane_total.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "pane-num-test", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + {"shell_command": []}, + {"shell_command": []}, + ], + }, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + pane_events = [e for e in events if e["event"] == "pane_creating"] + assert len(pane_events) == 3 + assert [e["pane_num"] for e in pane_events] == [1, 2, 3] + assert all(e["pane_total"] == 3 for e in pane_events) From 2e9f8cd4d0c8cd4a453bbdc4d298bdbc428d05ff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:56:24 -0500 Subject: [PATCH 24/89] docs(progress): document progress display, env vars, and API reference why: Users need documentation for the progress spinner feature. what: - Add "Progress display" section to docs/cli/load.md with presets, tokens, panel lines, disabling, and before-script behavior - Add TMUXP_PROGRESS, TMUXP_PROGRESS_FORMAT, TMUXP_PROGRESS_LINES to environmental-variables.md - Add docs/api/cli/progress.md API reference page --- docs/api/cli/index.md | 1 + docs/api/cli/progress.md | 8 ++ docs/cli/load.md | 101 ++++++++++++++++++ docs/configuration/environmental-variables.md | 51 +++++++++ 4 files changed, 161 insertions(+) create mode 100644 docs/api/cli/progress.md diff --git a/docs/api/cli/index.md b/docs/api/cli/index.md index 9289503905..1381fbc90f 100644 --- a/docs/api/cli/index.md +++ b/docs/api/cli/index.md @@ -16,6 +16,7 @@ freeze import_config load ls +progress search shell utils diff --git a/docs/api/cli/progress.md b/docs/api/cli/progress.md new file mode 100644 index 0000000000..3b092349cf --- /dev/null +++ b/docs/api/cli/progress.md @@ -0,0 +1,8 @@ +# tmuxp progress - `tmuxp.cli._progress` + +```{eval-rst} +.. automodule:: tmuxp.cli._progress + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/cli/load.md b/docs/cli/load.md index a362888e15..8be9178f29 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -152,3 +152,104 @@ $ tmuxp load [filename] --log-file [log_filename] ```console $ tmuxp --log-level [LEVEL] load [filename] --log-file [log_filename] ``` + +## Progress display + +When loading a workspace, tmuxp shows an animated spinner with build progress. The spinner updates as windows and panes are created, giving real-time feedback during session builds. + +### Presets + +Five built-in presets control the spinner format: + +| Preset | Format | +|--------|--------| +| `default` | `Loading workspace: {session} {bar} {progress} {window}` | +| `minimal` | `Loading workspace: {session} [{window_progress}]` | +| `window` | `Loading workspace: {session} {window_bar} {window_progress_rel}` | +| `pane` | `Loading workspace: {session} {pane_bar} {session_pane_progress}` | +| `verbose` | `Loading workspace: {session} [window {window_index} of {window_total} · pane {pane_index} of {pane_total}] {window}` | + +Select a preset with `--progress-format`: + +```console +$ tmuxp load --progress-format minimal myproject +``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS_FORMAT=verbose tmuxp load myproject +``` + +### Custom format tokens + +Use a custom format string with any of the available tokens: + +| Token | Description | +|-------|-------------| +| `{session}` | Session name | +| `{window}` | Current window name | +| `{window_index}` | Current window number (1-based) | +| `{window_total}` | Total number of windows | +| `{window_progress}` | Window fraction (e.g. `1/3`) | +| `{window_progress_rel}` | Completed windows fraction (e.g. `1/3`) | +| `{windows_done}` | Number of completed windows | +| `{windows_remaining}` | Number of remaining windows | +| `{pane_index}` | Current pane number in the window | +| `{pane_total}` | Total panes in the current window | +| `{pane_progress}` | Pane fraction (e.g. `2/4`) | +| `{progress}` | Combined progress (e.g. `1/3 win · 2/4 pane`) | +| `{session_pane_progress}` | Panes completed across the session (e.g. `5/10`) | +| `{overall_percent}` | Pane-based completion percentage (0–100) | +| `{bar}` | Composite progress bar | +| `{pane_bar}` | Pane-based progress bar | +| `{window_bar}` | Window-based progress bar | +| `{status_icon}` | Status icon (⏸ during before_script) | + +Example: + +```console +$ tmuxp load --progress-format "{session} {bar} {overall_percent}%" myproject +``` + +### Panel lines + +The spinner shows script output in a panel below the spinner line. Control the panel height with `--progress-lines`: + +Hide the panel entirely (script output goes to stdout): + +```console +$ tmuxp load --progress-lines 0 myproject +``` + +Show unlimited lines (capped to terminal height): + +```console +$ tmuxp load --progress-lines -1 myproject +``` + +Set a custom height (default is 3): + +```console +$ tmuxp load --progress-lines 5 myproject +``` + +### Disabling progress + +Disable the animated spinner entirely: + +```console +$ tmuxp load --no-progress myproject +``` + +Or via environment variable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +When progress is disabled, logging flows normally to the terminal and no spinner is rendered. + +### Before-script behavior + +During `before_script` execution, the progress bar shows a marching animation and a ⏸ status icon, indicating that tmuxp is waiting for the script to finish before continuing with pane creation. diff --git a/docs/configuration/environmental-variables.md b/docs/configuration/environmental-variables.md index d485da4574..73c0d1e373 100644 --- a/docs/configuration/environmental-variables.md +++ b/docs/configuration/environmental-variables.md @@ -24,3 +24,54 @@ building sessions. For this case you can override it here. ```console $ env LIBTMUX_TMUX_FORMAT_SEPARATOR='__SEP__' tmuxp load [session] ``` + +(TMUXP_PROGRESS)= + +## `TMUXP_PROGRESS` + +Master on/off switch for the animated progress spinner during `tmuxp load`. +Defaults to `1` (enabled). Set to `0` to disable: + +```console +$ TMUXP_PROGRESS=0 tmuxp load myproject +``` + +Equivalent to the `--no-progress` CLI flag. + +(TMUXP_PROGRESS_FORMAT)= + +## `TMUXP_PROGRESS_FORMAT` + +Set the spinner line format. Accepts a preset name (`default`, `minimal`, `window`, `pane`, `verbose`) or a custom format string with tokens like `{session}`, `{bar}`, `{progress}`: + +```console +$ TMUXP_PROGRESS_FORMAT=minimal tmuxp load myproject +``` + +Custom format example: + +```console +$ TMUXP_PROGRESS_FORMAT="{session} {bar} {overall_percent}%" tmuxp load myproject +``` + +Equivalent to the `--progress-format` CLI flag. + +(TMUXP_PROGRESS_LINES)= + +## `TMUXP_PROGRESS_LINES` + +Number of script-output lines shown in the spinner panel. Defaults to `3`. + +Set to `0` to hide the panel entirely (script output goes to stdout): + +```console +$ TMUXP_PROGRESS_LINES=0 tmuxp load myproject +``` + +Set to `-1` for unlimited lines (capped to terminal height): + +```console +$ TMUXP_PROGRESS_LINES=-1 tmuxp load myproject +``` + +Equivalent to the `--progress-lines` CLI flag. From 96ede9beeda820d297dbcc1ef83347f742301c2d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 15:56:53 -0500 Subject: [PATCH 25/89] docs(CHANGES): add animated progress spinner entry why: PR 2 needs changelog entry for the progress spinner feature. what: - Add "Animated progress spinner" section under What's new --- CHANGES | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGES b/CHANGES index 5e8bf5cdcd..2c3018924e 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,22 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +### What's new + +#### Animated progress spinner for `tmuxp load` (#1020) + +The `load` command now shows an animated spinner with real-time build progress +as windows and panes are created. Five built-in presets control the display +format (`default`, `minimal`, `window`, `pane`, `verbose`), and custom format +strings are supported via `--progress-format` or `TMUXP_PROGRESS_FORMAT`. + +- `--progress-lines N` / `TMUXP_PROGRESS_LINES`: Control how many lines of + `before_script` output appear in the spinner panel (default: 3). Use `0` to + hide the panel, `-1` for unlimited (capped to terminal height). +- `--no-progress` / `TMUXP_PROGRESS=0`: Disable the spinner entirely. +- During `before_script` execution, the progress bar shows a marching animation + and ⏸ icon. + ## tmuxp 1.66.0 (2026-03-08) ### Bug fixes From d08589aefbbef115dc456097c0d7001010292412 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 20:24:33 -0500 Subject: [PATCH 26/89] Tag v1.67.0 (load spinner via #1020) --- CHANGES | 4 +++- pyproject.toml | 2 +- src/tmuxp/__about__.py | 2 +- uv.lock | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 2c3018924e..707359f3ac 100644 --- a/CHANGES +++ b/CHANGES @@ -27,7 +27,7 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force // Usage: tmuxp@next load yoursession ``` -## tmuxp 1.67.0 (Yet to be released) +## tmuxp 1.68.0 (Yet to be released) @@ -35,6 +35,8 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +## tmuxp 1.67.0 (2026-03-08) + ### What's new #### Animated progress spinner for `tmuxp load` (#1020) diff --git a/pyproject.toml b/pyproject.toml index ceb1aae0c9..e394750acc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.66.0" +version = "1.67.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index f1cfd86085..48ad3629a1 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -8,7 +8,7 @@ __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.66.0" +__version__ = "1.67.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/uv.lock b/uv.lock index 03668ca9d4..5606904017 100644 --- a/uv.lock +++ b/uv.lock @@ -1379,7 +1379,7 @@ wheels = [ [[package]] name = "tmuxp" -version = "1.66.0" +version = "1.67.0" source = { editable = "." } dependencies = [ { name = "libtmux" }, From 6a62a0894e4f0530a064cdac66c71b2bb5b153e7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 17:09:50 -0600 Subject: [PATCH 27/89] docs(style[headings]): compact heading sizes and changelog spacing why: Furo's default heading sizes (h1=2.5em, h2=2em) are disproportionately large for content-dense pages like the changelog. Inspired by biomejs.dev's compact proportions. what: - Reduce global heading sizes ~30% (h1=1.8em, h2=1.4em, h3=1.15em) - Add changelog-specific spacing between consecutive version entries - Mute category headings (h3) with secondary foreground color - Subtle item headings (h4) at body text size --- docs/_static/css/custom.css | 64 +++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 00a15fdc22..90e02262de 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -19,3 +19,67 @@ .sidebar-tree .active { font-weight: bold; } + +/* ── Global heading refinements ───────────────────────────── + * Reduce Furo's default heading sizes (~30%) for better + * proportions. Furo uses bare h1-h6 selectors, so `article` + * prefix provides sufficient specificity to override. + * ────────────────────────────────────────────────────────── */ +article h1 { + font-size: 1.8em; + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +article h2 { + font-size: 1.4em; + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} + +article h3 { + font-size: 1.15em; + font-weight: 600; + margin-bottom: 0.375rem; +} + +article h4 { + font-size: 1.05em; + font-weight: 600; + margin-bottom: 0.25rem; +} + +article h5 { + font-size: 1em; + font-weight: 600; +} + +article h6 { + font-size: 0.875em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ── Changelog heading extras ─────────────────────────────── + * Vertical spacing separates consecutive version entries. + * Category headings (h3) are muted. Item headings (h4) are + * subtle. Targets #history section from CHANGES markdown. + * ────────────────────────────────────────────────────────── */ + +/* Spacing between consecutive version entries */ +#history > section + section { + margin-top: 2.5rem; +} + +/* Category headings — muted secondary color */ +#history h3 { + color: var(--color-foreground-secondary); + margin-top: 1.25rem; +} + +/* Item headings — subtle, same size as body */ +#history h4 { + font-size: 1em; + margin-top: 1rem; +} From 2b483f3708e6dfda41b13a2415f71ae1ea41a2de Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 17:15:22 -0600 Subject: [PATCH 28/89] docs(style[toc,body]): refine right-panel TOC and body typography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo's default TOC title (10px) is nearly invisible and smaller than its own items (12px), inverting typographic hierarchy. Body line-height (1.5) is tighter than WCAG-recommended range. what: - Bump TOC item size 75% → 81.25% (12→13px) via --toc-font-size - Bump TOC title size 62.5% → 87.5% (10→14px) via --toc-title-font-size - Increase .toc-tree line-height 1.3 → 1.4 for wrapped entries - Increase article line-height 1.5 → 1.6 for paragraph readability - Enable text-rendering: optimizeLegibility on body --- docs/_static/css/custom.css | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 90e02262de..079b5371c6 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -83,3 +83,33 @@ article h6 { font-size: 1em; margin-top: 1rem; } + +/* ── Right-panel TOC refinements ──────────────────────────── + * Adjust Furo's table-of-contents proportions for better + * readability. Inspired by Starlight defaults (Biome docs). + * Uses Furo CSS variable overrides where possible. + * ────────────────────────────────────────────────────────── */ + +/* TOC font sizes: items 75% → 81.25% (12→13px), + title 62.5% → 87.5% (10→14px) */ +:root { + --toc-font-size: var(--font-size--small--2); + --toc-title-font-size: var(--font-size--small); +} + +/* More generous line-height for wrapped TOC entries */ +.toc-tree { + line-height: 1.4; +} + +/* ── Body typography refinements ──────────────────────────── + * Improve paragraph readability with wider line-height and + * sharper text rendering. Furo already sets font-smoothing. + * ────────────────────────────────────────────────────────── */ +body { + text-rendering: optimizeLegibility; +} + +article { + line-height: 1.6; +} From f22b1f4fc25da536f0ceca0849cec80b1e99d3e8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 17:21:43 -0600 Subject: [PATCH 29/89] docs(style[toc,content]): flexible TOC width with inner-panel padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo's TOC had no breathing room from the viewport edge and long entries could overflow. Moving padding to the inner wrapper matches the Biome/Starlight pattern where the outer aside defines dimensions and an inner element controls content insets. what: - Add min-width: 18em on .toc-drawer to replace Furo's 15em - Move padding-right from .toc-drawer to .toc-sticky (inner panel) - Add flex: 1 on .content so it absorbs extra space on wide screens - Override responsive breakpoint to fully hide TOC at ≤82em --- docs/_static/css/custom.css | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 079b5371c6..b0f5ca0df5 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -102,6 +102,44 @@ article h6 { line-height: 1.4; } +/* ── Flexible right-panel TOC (inner-panel padding) ───────── + * Furo hardcodes .toc-drawer to width: 15em (SASS, compiled). + * min-width: 18em overrides it; long TOC entries wrap inside + * the box instead of blowing past the viewport. + * + * Padding lives on .toc-sticky (the inner panel), not on + * .toc-drawer (the outer aside). This matches Biome/Starlight + * where the aside defines dimensions and an inner wrapper + * (.right-sidebar-panel) controls content insets. The + * scrollbar sits naturally between content and viewport edge. + * + * Content area gets flex: 1 to absorb extra space on wide + * screens. At ≤82em Furo collapses the TOC to position: fixed; + * override right offset so the drawer fully hides off-screen. + * ────────────────────────────────────────────────────────── */ +.toc-drawer { + min-width: 18em; + flex-shrink: 0; + padding-right: 0; +} + +.toc-sticky { + padding-right: 1.5em; +} + +.content { + width: auto; + max-width: 46em; + flex: 1 1 46em; + padding: 0 2em; +} + +@media (max-width: 82em) { + .toc-drawer { + right: -18em; + } +} + /* ── Body typography refinements ──────────────────────────── * Improve paragraph readability with wider line-height and * sharper text rendering. Furo already sets font-smoothing. From 1c93acf89ba8a0a22aa9f758128d120e5f180c07 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 17:23:58 -0600 Subject: [PATCH 30/89] docs(style[toc]): increase TOC font size from 81.25% to 87.5% why: Larger text improves readability for API pages with long method names. what: - Bump --toc-font-size from --font-size--small--2 to --font-size--small - Use body selector to match Furo's variable scoping (overrides :root) --- docs/_static/css/custom.css | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index b0f5ca0df5..7fe1581e83 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -90,11 +90,10 @@ article h6 { * Uses Furo CSS variable overrides where possible. * ────────────────────────────────────────────────────────── */ -/* TOC font sizes: items 75% → 81.25% (12→13px), - title 62.5% → 87.5% (10→14px) */ -:root { - --toc-font-size: var(--font-size--small--2); - --toc-title-font-size: var(--font-size--small); +/* TOC font sizes: override Furo defaults (75% → 87.5%) */ +body { + --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ + --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ } /* More generous line-height for wrapped TOC entries */ From a58026b64a6c9a169a600825c78f1b93ef431e3d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 12 Mar 2026 19:42:20 -0500 Subject: [PATCH 31/89] =?UTF-8?q?docs(style[headings]):=20refine=20heading?= =?UTF-8?q?=20hierarchy=20=E2=80=94=20scale,=20spacing,=20eyebrow=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: H2/H3 size gap was too narrow (1.22×) to instantly convey hierarchy, H2 bottom border introduced visual noise inconsistent with the rest of the page, and headings were visually heavy with semibold weight. what: - Bump H2 from 1.4em to 1.6em (H2/H3 ratio now 1.39×) and increase top margin to 2.5rem for clear section breaks - Remove H2 border-bottom — rely on spacing + type scale only - Set all headings to weight 500 (medium) — size carries hierarchy - Add eyebrow style for H4-H6: uppercase, letter-spacing, muted color - Override eyebrow style in #history h4 to preserve changelog formatting - Move TOC variable overrides from body to :root --- docs/_static/css/custom.css | 41 +++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 7fe1581e83..577d8ce209 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -21,44 +21,56 @@ } /* ── Global heading refinements ───────────────────────────── - * Reduce Furo's default heading sizes (~30%) for better - * proportions. Furo uses bare h1-h6 selectors, so `article` - * prefix provides sufficient specificity to override. + * Biome-inspired scale: medium weight (500) throughout — size + * and spacing carry hierarchy, not boldness. H4-H6 add eyebrow + * treatment (uppercase, muted). `article` prefix overrides + * Furo's bare h1-h6 selectors. * ────────────────────────────────────────────────────────── */ article h1 { font-size: 1.8em; + font-weight: 500; margin-top: 1.5rem; margin-bottom: 0.75rem; } article h2 { - font-size: 1.4em; - margin-top: 1.5rem; + font-size: 1.6em; + font-weight: 500; + margin-top: 2.5rem; margin-bottom: 0.5rem; } article h3 { font-size: 1.15em; - font-weight: 600; + font-weight: 500; + margin-top: 1.5rem; margin-bottom: 0.375rem; } article h4 { - font-size: 1.05em; - font-weight: 600; + font-size: 0.85em; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-foreground-secondary); + margin-top: 1rem; margin-bottom: 0.25rem; } article h5 { - font-size: 1em; - font-weight: 600; + font-size: 0.8em; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-foreground-secondary); } article h6 { - font-size: 0.875em; - font-weight: 600; + font-size: 0.75em; + font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; + color: var(--color-foreground-secondary); } /* ── Changelog heading extras ─────────────────────────────── @@ -82,6 +94,9 @@ article h6 { #history h4 { font-size: 1em; margin-top: 1rem; + text-transform: none; + letter-spacing: normal; + color: inherit; } /* ── Right-panel TOC refinements ──────────────────────────── @@ -91,7 +106,7 @@ article h6 { * ────────────────────────────────────────────────────────── */ /* TOC font sizes: override Furo defaults (75% → 87.5%) */ -body { +:root { --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ } From 9f13395cdcd32377c458e7230c816f8180ef79ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 17:44:20 -0500 Subject: [PATCH 32/89] docs(README[pre-load hook]): remove dead bootstrap_env.py reference why: bootstrap_env.py no longer exists in the repository. what: - Remove link to bootstrap_env.py from pre-load hook section - Fix unclosed parenthesis in surrounding sentence --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 1a1de04be9..c1a97d75a0 100644 --- a/README.md +++ b/README.md @@ -202,10 +202,8 @@ the CLI docs. # Pre-load hook -Run custom startup scripts (such as installing project dependencies +Run custom startup scripts (such as installing project dependencies) before loading tmux. See the -[bootstrap_env.py](https://github.com/tmux-python/tmuxp/blob/master/bootstrap_env.py) -and [before_script](http://tmuxp.git-pull.com/examples.html#bootstrap-project-before-launch) example From 9002a372b552d73e67c3c3c126ff905e61de50ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 13:16:05 -0500 Subject: [PATCH 33/89] ci(docs): temporarily add docs-fonts branch to trigger why: Test font deployment before merging to master. what: - Add docs-fonts to push trigger branches (revert after verification) --- .github/workflows/docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dd5f19f491..7c8185eeb3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - docs-fonts permissions: contents: read From 4d6ef275d17bccc7d839e81826e57dde96df7c49 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 12:19:34 -0500 Subject: [PATCH 34/89] docs(fonts): self-host IBM Plex via Fontsource CDN why: Standardize on IBM Plex Sans / Mono across projects without committing ~227KB of binary font files to the repo. what: - Add sphinx_fonts extension that downloads fonts at build time, caches in ~/.cache/sphinx-fonts/, and generates @font-face CSS - Configure IBM Plex Sans (400/500/600/700) and IBM Plex Mono (400) with CSS variable overrides for Furo theme - Add actions/cache step in docs workflow for font cache persistence - Gitignore generated font assets in docs/_static/ --- .github/workflows/docs.yml | 9 +++ .gitignore | 4 ++ docs/_ext/sphinx_fonts.py | 144 +++++++++++++++++++++++++++++++++++++ docs/conf.py | 27 +++++++ 4 files changed, 184 insertions(+) create mode 100644 docs/_ext/sphinx_fonts.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7c8185eeb3..dfdf570ee0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -63,6 +63,15 @@ jobs: python -V uv run python -V + - name: Cache sphinx fonts + if: env.PUBLISH == 'true' + uses: actions/cache@v5 + with: + path: ~/.cache/sphinx-fonts + key: sphinx-fonts-${{ hashFiles('docs/conf.py') }} + restore-keys: | + sphinx-fonts- + - name: Build documentation if: env.PUBLISH == 'true' run: | diff --git a/.gitignore b/.gitignore index f82c6fba00..9dadb843bd 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,10 @@ doc/_build/ # MonkeyType monkeytype.sqlite3 +# Generated by sphinx_fonts extension (downloaded at build time) +docs/_static/fonts/ +docs/_static/css/fonts.css + # Claude code **/CLAUDE.local.md **/CLAUDE.*.md diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py new file mode 100644 index 0000000000..c9d0396a89 --- /dev/null +++ b/docs/_ext/sphinx_fonts.py @@ -0,0 +1,144 @@ +"""Sphinx extension for self-hosted fonts via Fontsource CDN. + +Downloads font files at build time, caches them locally, and generates +CSS with @font-face declarations and CSS variable overrides. +""" + +from __future__ import annotations + +import logging +import pathlib +import shutil +import typing as t +import urllib.error +import urllib.request + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + +CDN_TEMPLATE = ( + "https://cdn.jsdelivr.net/npm/{package}@{version}" + "/files/{font_id}-{subset}-{weight}-{style}.woff2" +) + + +class SetupDict(t.TypedDict): + version: str + parallel_read_safe: bool + parallel_write_safe: bool + + +def _cache_dir() -> pathlib.Path: + return pathlib.Path.home() / ".cache" / "sphinx-fonts" + + +def _cdn_url( + package: str, + version: str, + font_id: str, + subset: str, + weight: int, + style: str, +) -> str: + return CDN_TEMPLATE.format( + package=package, + version=version, + font_id=font_id, + subset=subset, + weight=weight, + style=style, + ) + + +def _download_font(url: str, dest: pathlib.Path) -> bool: + if dest.exists(): + logger.debug("font cached: %s", dest.name) + return True + dest.parent.mkdir(parents=True, exist_ok=True) + try: + urllib.request.urlretrieve(url, dest) + logger.info("downloaded font: %s", dest.name) + except (urllib.error.URLError, OSError): + logger.warning("failed to download font: %s", url) + return False + return True + + +def _generate_css( + fonts: list[dict[str, t.Any]], + variables: dict[str, str], +) -> str: + lines: list[str] = [] + for font in fonts: + family = font["family"] + font_id = font["package"].split("/")[-1] + subset = font.get("subset", "latin") + for weight in font["weights"]: + for style in font["styles"]: + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + lines.append("@font-face {") + lines.append(f' font-family: "{family}";') + lines.append(f" font-style: {style};") + lines.append(f" font-weight: {weight};") + lines.append(" font-display: swap;") + lines.append(f' src: url("../fonts/{filename}") format("woff2");') + lines.append("}") + lines.append("") + + if variables: + lines.append(":root {") + for var, value in variables.items(): + lines.append(f" {var}: {value};") + lines.append("}") + lines.append("") + + return "\n".join(lines) + + +def _on_builder_inited(app: Sphinx) -> None: + if app.builder.format != "html": + return + + fonts: list[dict[str, t.Any]] = app.config.sphinx_fonts + variables: dict[str, str] = app.config.sphinx_font_css_variables + if not fonts: + return + + cache = _cache_dir() + static_dir = pathlib.Path(app.outdir) / "_static" + fonts_dir = static_dir / "fonts" + css_dir = static_dir / "css" + fonts_dir.mkdir(parents=True, exist_ok=True) + css_dir.mkdir(parents=True, exist_ok=True) + + for font in fonts: + font_id = font["package"].split("/")[-1] + version = font["version"] + package = font["package"] + subset = font.get("subset", "latin") + for weight in font["weights"]: + for style in font["styles"]: + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + cached = cache / filename + url = _cdn_url(package, version, font_id, subset, weight, style) + if _download_font(url, cached): + shutil.copy2(cached, fonts_dir / filename) + + css_content = _generate_css(fonts, variables) + (css_dir / "fonts.css").write_text(css_content, encoding="utf-8") + logger.info("generated fonts.css with %d font families", len(fonts)) + + app.add_css_file("css/fonts.css") + + +def setup(app: Sphinx) -> SetupDict: + app.add_config_value("sphinx_fonts", [], "html") + app.add_config_value("sphinx_font_css_variables", {}, "html") + app.connect("builder-inited", _on_builder_inited) + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/conf.py b/docs/conf.py index 9a1f957e6f..799c672140 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -36,6 +36,7 @@ "sphinx.ext.napoleon", "sphinx.ext.linkcode", "aafig", + "sphinx_fonts", "argparse_exemplar", # Custom sphinx-argparse replacement "sphinx_inline_tabs", "sphinx_copybutton", @@ -146,6 +147,32 @@ aafig_format = {"latex": "pdf", "html": "gif"} aafig_default_options = {"scale": 0.75, "aspect": 0.5, "proportional": True} +# sphinx_fonts — self-hosted IBM Plex via Fontsource CDN +sphinx_fonts = [ + { + "family": "IBM Plex Sans", + "package": "@fontsource/ibm-plex-sans", + "version": "5.2.8", + "weights": [400, 500, 600, 700], + "styles": ["normal", "italic"], + "subset": "latin", + }, + { + "family": "IBM Plex Mono", + "package": "@fontsource/ibm-plex-mono", + "version": "5.2.7", + "weights": [400], + "styles": ["normal", "italic"], + "subset": "latin", + }, +] + +sphinx_font_css_variables = { + "--font-stack": '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif', + "--font-stack--monospace": '"IBM Plex Mono", SFMono-Regular, Menlo, Consolas, monospace', + "--font-stack--headings": "var(--font-stack)", +} + intersphinx_mapping = { "python": ("https://docs.python.org/", None), "libtmux": ("https://libtmux.git-pull.com/", None), From 3bca8c5fd27c6695155354ada7e703ca2fbc49a0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 13:27:14 -0500 Subject: [PATCH 35/89] =?UTF-8?q?docs(fonts[css]):=20fix=20variable=20spec?= =?UTF-8?q?ificity=20=E2=80=94=20use=20body=20instead=20of=20:root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo sets --font-stack on body, which overrides :root via direct declaration. Our fonts.css loaded but never rendered. what: - Change CSS variable selector from :root to body in _generate_css() - Same specificity + later source order ensures our override wins --- docs/_ext/sphinx_fonts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index c9d0396a89..7903f30cd2 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -88,7 +88,7 @@ def _generate_css( lines.append("") if variables: - lines.append(":root {") + lines.append("body {") for var, value in variables.items(): lines.append(f" {var}: {value};") lines.append("}") From a3ff18c35ee7bac7c3c05f04d01c3fd3940e241b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 14:17:39 -0500 Subject: [PATCH 36/89] docs(fonts[preload]): add for critical font weights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The browser doesn't discover font URLs until it parses fonts.css, which itself must wait for the HTML to load. This creates a waterfall (HTML → CSS → font download) that causes a visible Flash of Unstyled Text (FOUT) — the page renders with system fallback fonts, then swaps to IBM Plex once the fonts arrive. Preload hints in tell the browser to start downloading fonts immediately, in parallel with CSS parsing, so fonts arrive before first paint and the swap is invisible. what: - Add sphinx_font_preload config option to sphinx_fonts extension accepting (family, weight, style) tuples for selective preloading - Compute preload filenames in _on_builder_inited() and pass them to templates via html-page-context event handler - Emit tags in page.html template's extrahead block - Preload only 3 critical above-the-fold weights: Sans 400 (body), Sans 700 (headings), Mono 400 (code) — other variants load on demand - Rename layout.html → page.html and extend !page.html instead of !layout.html — Furo theme blocks layout.html inheritance (its layout.html is deliberately an error page) note: The original approach used Sphinx's add_css_file() for preload tags, but Sphinx appends ?v= query strings to those URLs. The @font-face declarations in fonts.css reference fonts without query strings, so the browser treated them as different resources — downloading each font twice and defeating the preload entirely. The template-based approach produces URLs without query strings, matching @font-face exactly: one download, zero FOUT. --- docs/_ext/sphinx_fonts.py | 24 ++++++++++++++++++++++ docs/_templates/{layout.html => page.html} | 5 ++++- docs/conf.py | 6 ++++++ 3 files changed, 34 insertions(+), 1 deletion(-) rename docs/_templates/{layout.html => page.html} (91%) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index 7903f30cd2..586a83a042 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -130,13 +130,37 @@ def _on_builder_inited(app: Sphinx) -> None: (css_dir / "fonts.css").write_text(css_content, encoding="utf-8") logger.info("generated fonts.css with %d font families", len(fonts)) + preload_hrefs: list[str] = [] + preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload + for family_name, weight, style in preload_specs: + for font in fonts: + if font["family"] == family_name: + font_id = font["package"].split("/")[-1] + subset = font.get("subset", "latin") + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + preload_hrefs.append(filename) + break + app._font_preload_hrefs = preload_hrefs # type: ignore[attr-defined] + app.add_css_file("css/fonts.css") +def _on_html_page_context( + app: Sphinx, + pagename: str, + templatename: str, + context: dict[str, t.Any], + doctree: t.Any, +) -> None: + context["font_preload_hrefs"] = getattr(app, "_font_preload_hrefs", []) + + def setup(app: Sphinx) -> SetupDict: app.add_config_value("sphinx_fonts", [], "html") app.add_config_value("sphinx_font_css_variables", {}, "html") + app.add_config_value("sphinx_font_preload", [], "html") app.connect("builder-inited", _on_builder_inited) + app.connect("html-page-context", _on_html_page_context) return { "version": "1.0", "parallel_read_safe": True, diff --git a/docs/_templates/layout.html b/docs/_templates/page.html similarity index 91% rename from docs/_templates/layout.html rename to docs/_templates/page.html index 2943238cf7..c213992ac4 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/page.html @@ -1,6 +1,9 @@ -{% extends "!layout.html" %} +{% extends "!page.html" %} {%- block extrahead %} {{ super() }} + {%- for href in font_preload_hrefs|default([]) %} + + {%- endfor %} {%- if theme_show_meta_manifest_tag == true %} {% endif -%} diff --git a/docs/conf.py b/docs/conf.py index 799c672140..2d9c9f47a5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -167,6 +167,12 @@ }, ] +sphinx_font_preload = [ + ("IBM Plex Sans", 400, "normal"), # body text + ("IBM Plex Sans", 700, "normal"), # headings + ("IBM Plex Mono", 400, "normal"), # code blocks +] + sphinx_font_css_variables = { "--font-stack": '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif', "--font-stack--monospace": '"IBM Plex Mono", SFMono-Regular, Menlo, Consolas, monospace', From 409396127a0994af343a67b3641093600c3ecd48 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 14:42:50 -0500 Subject: [PATCH 37/89] docs(fonts[css]): add kerning, ligatures, and code rendering overrides why: IBM Plex's OpenType features (kern, liga) weren't being leveraged, and code blocks inherited prose text-rendering that can break monospace grid alignment. what: - Add font-kerning, font-variant-ligatures, letter-spacing to body - Add optimizeSpeed + kerning/ligature/spacing resets for pre/code/kbd/samp --- docs/_static/css/custom.css | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 577d8ce209..e53aa456f1 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -157,9 +157,31 @@ article h6 { /* ── Body typography refinements ──────────────────────────── * Improve paragraph readability with wider line-height and * sharper text rendering. Furo already sets font-smoothing. + * + * IBM Plex tracks slightly wide at default spacing; -0.01em + * tightens it to feel more natural (matches tony.sh/tony.nl). + * Kerning + ligatures polish AV/To pairs and fi/fl combos. * ────────────────────────────────────────────────────────── */ body { text-rendering: optimizeLegibility; + font-kerning: normal; + font-variant-ligatures: common-ligatures; + letter-spacing: -0.01em; +} + +/* ── Code block text rendering ──────────────────────────── + * Monospace needs fixed-width columns: disable kerning, + * ligatures, and letter-spacing that body sets for prose. + * optimizeSpeed skips heuristics that can shift the grid. + * ────────────────────────────────────────────────────────── */ +pre, +code, +kbd, +samp { + text-rendering: optimizeSpeed; + font-kerning: none; + font-variant-ligatures: none; + letter-spacing: normal; } article { From 98a4798117d9e1be116840483e2394ea4bdf552b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 15:33:10 -0500 Subject: [PATCH 38/89] docs(images[cls]): prevent layout shift and add non-blocking loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Every on the docs site lacked dimension hints, causing Cumulative Layout Shift (CLS) on page load — content jumped as images rendered. External badge images (shields.io, Codecov) were especially slow, and the 447KB demo GIF blocked rendering on hard refresh. what: - Add :width:, :height:, :loading: lazy to image directives in index.md (888×589), cli/shell.md (878×109), developing.md (1030×605) - Add CSS aspect-ratio per image to reserve proportional space before load; override docutils inline height with height: auto !important so Furo's max-width: 100% scales without distortion - Add content-visibility: auto on all img for off-screen decode skip - Add CSS height: 20px for shields.io / badge.svg / codecov.io badges to prevent 0→20px shift while external images load - Add sidebar/brand.html template override with width="200" height="200" decoding="async" on logo (above-fold, no lazy loading) --- docs/_static/css/custom.css | 40 ++++++++++++++++++++++++++++++ docs/_templates/sidebar/brand.html | 18 ++++++++++++++ docs/cli/shell.md | 4 ++- docs/developing.md | 4 ++- docs/index.md | 5 ++-- 5 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 docs/_templates/sidebar/brand.html diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index e53aa456f1..2f85829f84 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -187,3 +187,43 @@ samp { article { line-height: 1.6; } + +/* ── Image layout shift prevention ──────────────────────── + * Reserve space for images before they load. Furo already + * sets max-width: 100%; height: auto on img. We add + * content-visibility and badge-specific height to prevent CLS. + * ────────────────────────────────────────────────────────── */ +img { + content-visibility: auto; +} + +/* Docutils emits :width:/:height: as inline CSS (style="width: Xpx; + * height: Ypx;") rather than HTML attributes. When Furo's + * max-width: 100% constrains width below the declared value, + * the fixed height causes distortion. height: auto + aspect-ratio + * lets the browser compute the correct height from the intrinsic + * ratio once loaded; before load, aspect-ratio reserves space + * at the intended proportion — preventing both CLS and distortion. */ +article img[loading="lazy"] { + height: auto !important; +} + +/* Per-image aspect ratios for CLS reservation before load */ +img[src*="tmuxp-demo"] { + aspect-ratio: 888 / 589; +} + +img[src*="tmuxp-shell"] { + aspect-ratio: 878 / 109; +} + +img[src*="tmuxp-dev-screenshot"] { + aspect-ratio: 1030 / 605; +} + +img[src*="shields.io"], +img[src*="badge.svg"], +img[src*="codecov.io"] { + height: 20px; + width: auto; +} diff --git a/docs/_templates/sidebar/brand.html b/docs/_templates/sidebar/brand.html new file mode 100644 index 0000000000..7fe241c009 --- /dev/null +++ b/docs/_templates/sidebar/brand.html @@ -0,0 +1,18 @@ + diff --git a/docs/cli/shell.md b/docs/cli/shell.md index de47b9b9e1..586c305e54 100644 --- a/docs/cli/shell.md +++ b/docs/cli/shell.md @@ -23,7 +23,9 @@ $ tmuxp shell -c 'python code' ``` ```{image} ../_static/tmuxp-shell.gif -:width: 100% +:width: 878 +:height: 109 +:loading: lazy ``` ## Interactive usage diff --git a/docs/developing.md b/docs/developing.md index 9f2e221cfd..1b909363df 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -318,8 +318,10 @@ $ make SPHINXBUILD='uv run sphinx-build' watch ## tmuxp developer config ```{image} _static/tmuxp-dev-screenshot.png +:width: 1030 +:height: 605 :align: center - +:loading: lazy ``` After you {ref}`install-dev-env`, when inside the tmuxp checkout: diff --git a/docs/index.md b/docs/index.md index 14b69dedb0..fd8a21575f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,9 @@ ``` ```{image} _static/tmuxp-demo.gif -:width: 100% - +:width: 888 +:height: 589 +:loading: lazy ``` # Freeze a tmux session From ca64719b6a51f13bec344fe75c25754fe8352dd5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 16:26:13 -0500 Subject: [PATCH 39/89] docs(nav[spa]): add SPA-like navigation to avoid full page reloads why: Every page navigation re-downloads and re-parses 8 CSS files, 7 JS files, 3 fonts, re-renders the sidebar, SVG icons, and the entire layout. Only the article content, right-panel TOC, and active sidebar link actually change between pages. what: - Create docs/_static/js/spa-nav.js (~170 lines, vanilla JS, no deps) - Intercept internal link clicks via event delegation on document - Fetch target page, parse with DOMParser, swap three DOM regions: .article-container, .sidebar-tree, .toc-drawer - Preserve sidebar scroll position, theme state, all CSS/JS/fonts - Replicate Furo's cycleThemeOnce for swapped .content-icon-container - Inject copy buttons on new code blocks via cloneNode from template (ClipboardJS event delegation picks up new .copybtn elements) - Minimal scrollspy (~25 lines) replaces Gumshoe for swapped TOC - AbortController cancels in-flight fetches on rapid navigation - history.pushState/popstate for back/forward browser navigation - Debounced prefetch on hover (65ms) for near-instant transitions - Progressive enhancement: no-op if fetch/DOMParser/pushState absent - Skip interception for search.html, genindex, external links, modifier-key clicks, download attrs, and #sidebar-projects links - Fallback to window.location.href on network error or missing DOM - Register script in conf.py setup() with loading_method="defer" --- docs/_static/js/spa-nav.js | 228 +++++++++++++++++++++++++++++++++++++ docs/conf.py | 1 + 2 files changed, 229 insertions(+) create mode 100644 docs/_static/js/spa-nav.js diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js new file mode 100644 index 0000000000..fa7fd2ed6f --- /dev/null +++ b/docs/_static/js/spa-nav.js @@ -0,0 +1,228 @@ +/** + * SPA-like navigation for Sphinx/Furo docs. + * + * Intercepts internal link clicks and swaps only the content that changes + * (article, sidebar nav tree, TOC drawer), preserving sidebar scroll + * position, theme state, and avoiding full-page reloads. + * + * Progressive enhancement: no-op when fetch/DOMParser/pushState unavailable. + */ +(function () { + "use strict"; + + if (!window.fetch || !window.DOMParser || !window.history?.pushState) return; + + // --- Theme toggle (replicates Furo's cycleThemeOnce) --- + + function cycleTheme() { + var current = localStorage.getItem("theme") || "auto"; + var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + var next; + if (current === "auto") next = prefersDark ? "light" : "dark"; + else if (current === "dark") next = prefersDark ? "auto" : "light"; + else next = prefersDark ? "dark" : "auto"; + document.body.dataset.theme = next; + localStorage.setItem("theme", next); + } + + // --- Copy button injection --- + + var copyBtnTemplate = null; + + function captureCopyIcon() { + var btn = document.querySelector(".copybtn"); + if (btn) copyBtnTemplate = btn.cloneNode(true); + } + + function addCopyButtons() { + if (!copyBtnTemplate) captureCopyIcon(); + if (!copyBtnTemplate) return; + var cells = document.querySelectorAll("div.highlight pre"); + cells.forEach(function (cell, i) { + cell.id = "codecell" + i; + var next = cell.nextElementSibling; + if (next && next.classList.contains("copybtn")) { + next.setAttribute("data-clipboard-target", "#codecell" + i); + } else { + var btn = copyBtnTemplate.cloneNode(true); + btn.setAttribute("data-clipboard-target", "#codecell" + i); + cell.insertAdjacentElement("afterend", btn); + } + }); + } + + // --- Minimal scrollspy --- + + var scrollCleanup = null; + + function initScrollSpy() { + if (scrollCleanup) scrollCleanup(); + scrollCleanup = null; + + var links = document.querySelectorAll(".toc-tree a"); + if (!links.length) return; + + var entries = []; + links.forEach(function (a) { + var id = (a.getAttribute("href") || "").split("#")[1]; + var el = id && document.getElementById(id); + var li = a.closest("li"); + if (el && li) entries.push({ el: el, li: li }); + }); + if (!entries.length) return; + + function update() { + var offset = + parseFloat(getComputedStyle(document.documentElement).fontSize) * 4; + var active = null; + for (var i = entries.length - 1; i >= 0; i--) { + if (entries[i].el.getBoundingClientRect().top <= offset) { + active = entries[i]; + break; + } + } + entries.forEach(function (e) { + e.li.classList.remove("scroll-current"); + }); + if (active) active.li.classList.add("scroll-current"); + } + + window.addEventListener("scroll", update, { passive: true }); + update(); + scrollCleanup = function () { + window.removeEventListener("scroll", update); + }; + } + + // --- Link interception --- + + function shouldIntercept(link, e) { + if (e.defaultPrevented || e.button !== 0) return false; + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false; + if (link.origin !== location.origin) return false; + if (link.target && link.target !== "_self") return false; + if (link.hasAttribute("download")) return false; + + var path = link.pathname; + if (!path.endsWith(".html") && !path.endsWith("/")) return false; + + var base = path.split("/").pop() || ""; + if ( + base === "search.html" || + base === "genindex.html" || + base === "py-modindex.html" + ) + return false; + + if (link.closest("#sidebar-projects")) return false; + if (link.pathname === location.pathname && link.hash) return false; + + return true; + } + + // --- DOM swap --- + + function swap(doc) { + [".article-container", ".sidebar-tree", ".toc-drawer"].forEach( + function (sel) { + var fresh = doc.querySelector(sel); + var stale = document.querySelector(sel); + if (fresh && stale) stale.replaceWith(fresh); + }, + ); + var title = doc.querySelector("title"); + if (title) document.title = title.textContent || ""; + } + + function reinit() { + addCopyButtons(); + initScrollSpy(); + var btn = document.querySelector(".content-icon-container .theme-toggle"); + if (btn) btn.addEventListener("click", cycleTheme); + } + + // --- Navigation --- + + var currentCtrl = null; + + async function navigate(url, isPop) { + if (currentCtrl) currentCtrl.abort(); + var ctrl = new AbortController(); + currentCtrl = ctrl; + + try { + var resp = await fetch(url, { signal: ctrl.signal }); + if (!resp.ok) throw new Error(resp.status); + + var html = await resp.text(); + var doc = new DOMParser().parseFromString(html, "text/html"); + + if (!doc.querySelector(".article-container")) + throw new Error("no article"); + + swap(doc); + + if (!isPop) history.pushState({ spa: true }, "", url); + + if (!isPop) { + var hash = new URL(url, location.href).hash; + if (hash) { + var el = document.querySelector(hash); + if (el) el.scrollIntoView(); + } else { + window.scrollTo(0, 0); + } + } + + reinit(); + } catch (err) { + if (err.name === "AbortError") return; + window.location.href = url; + } finally { + if (currentCtrl === ctrl) currentCtrl = null; + } + } + + // --- Events --- + + document.addEventListener("click", function (e) { + var link = e.target.closest("a[href]"); + if (link && shouldIntercept(link, e)) { + e.preventDefault(); + navigate(link.href, false); + } + }); + + history.replaceState({ spa: true }, ""); + + window.addEventListener("popstate", function (e) { + if (e.state && e.state.spa) navigate(location.href, true); + }); + + // --- Hover prefetch --- + + var prefetchTimer = null; + + document.addEventListener("mouseover", function (e) { + var link = e.target.closest("a[href]"); + if (!link || link.origin !== location.origin) return; + if (!link.pathname.endsWith(".html") && !link.pathname.endsWith("/")) + return; + + clearTimeout(prefetchTimer); + prefetchTimer = setTimeout(function () { + fetch(link.href, { priority: "low" }).catch(function () {}); + }, 65); + }); + + document.addEventListener("mouseout", function (e) { + if (e.target.closest("a[href]")) clearTimeout(prefetchTimer); + }); + + // --- Init --- + + // Copy buttons are injected by copybutton.js on DOMContentLoaded. + // This defer script runs before DOMContentLoaded, so our handler + // fires after copybutton's handler (registration order preserved). + document.addEventListener("DOMContentLoaded", captureCopyIcon); +})(); diff --git a/docs/conf.py b/docs/conf.py index 2d9c9f47a5..d77b72f3c5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -265,4 +265,5 @@ def remove_tabs_js(app: Sphinx, exc: Exception) -> None: def setup(app: Sphinx) -> None: """Sphinx setup hook.""" + app.add_js_file("js/spa-nav.js", loading_method="defer") app.connect("build-finished", remove_tabs_js) From fb322d9bed4f762c8922399fdeb9962d1c4464d6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 18:21:40 -0500 Subject: [PATCH 40/89] docs(fonts[fallback]): add fallback font metrics to eliminate FOUT reflow why: font-display: swap causes visible text reflow when IBM Plex loads because the system fallback (Arial) has different character dimensions. what: - Add sphinx_font_fallbacks config with size-adjust/ascent/descent overrides - Generate fallback @font-face declarations in fonts.css (Capsize formula) - Include fallback families in --font-stack CSS variables --- docs/_ext/sphinx_fonts.py | 17 ++++++++++++++++- docs/conf.py | 23 +++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index 586a83a042..5a4fca47b2 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -69,6 +69,7 @@ def _download_font(url: str, dest: pathlib.Path) -> bool: def _generate_css( fonts: list[dict[str, t.Any]], variables: dict[str, str], + fallbacks: list[dict[str, str]] | None = None, ) -> str: lines: list[str] = [] for font in fonts: @@ -87,6 +88,18 @@ def _generate_css( lines.append("}") lines.append("") + if fallbacks: + for fb in fallbacks: + lines.append("@font-face {") + lines.append(f' font-family: "{fb["family"]}";') + lines.append(f" src: {fb['src']};") + lines.append(f" size-adjust: {fb['size_adjust']};") + lines.append(f" ascent-override: {fb['ascent_override']};") + lines.append(f" descent-override: {fb['descent_override']};") + lines.append(f" line-gap-override: {fb['line_gap_override']};") + lines.append("}") + lines.append("") + if variables: lines.append("body {") for var, value in variables.items(): @@ -126,7 +139,8 @@ def _on_builder_inited(app: Sphinx) -> None: if _download_font(url, cached): shutil.copy2(cached, fonts_dir / filename) - css_content = _generate_css(fonts, variables) + fallbacks: list[dict[str, str]] = app.config.sphinx_font_fallbacks + css_content = _generate_css(fonts, variables, fallbacks) (css_dir / "fonts.css").write_text(css_content, encoding="utf-8") logger.info("generated fonts.css with %d font families", len(fonts)) @@ -157,6 +171,7 @@ def _on_html_page_context( def setup(app: Sphinx) -> SetupDict: app.add_config_value("sphinx_fonts", [], "html") + app.add_config_value("sphinx_font_fallbacks", [], "html") app.add_config_value("sphinx_font_css_variables", {}, "html") app.add_config_value("sphinx_font_preload", [], "html") app.connect("builder-inited", _on_builder_inited) diff --git a/docs/conf.py b/docs/conf.py index d77b72f3c5..b401540b0a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -173,9 +173,28 @@ ("IBM Plex Mono", 400, "normal"), # code blocks ] +sphinx_font_fallbacks = [ + { + "family": "IBM Plex Sans Fallback", + "src": 'local("Arial"), local("Helvetica Neue"), local("Helvetica")', + "size_adjust": "110.6%", + "ascent_override": "92.7%", + "descent_override": "24.9%", + "line_gap_override": "0%", + }, + { + "family": "IBM Plex Mono Fallback", + "src": 'local("Courier New"), local("Courier")', + "size_adjust": "100%", + "ascent_override": "102.5%", + "descent_override": "27.5%", + "line_gap_override": "0%", + }, +] + sphinx_font_css_variables = { - "--font-stack": '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif', - "--font-stack--monospace": '"IBM Plex Mono", SFMono-Regular, Menlo, Consolas, monospace', + "--font-stack": '"IBM Plex Sans", "IBM Plex Sans Fallback", -apple-system, BlinkMacSystemFont, sans-serif', + "--font-stack--monospace": '"IBM Plex Mono", "IBM Plex Mono Fallback", SFMono-Regular, Menlo, Consolas, monospace', "--font-stack--headings": "var(--font-stack)", } From dadbe0272efcc9d127333f69c2db03c9d1b959d8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 18:22:20 -0500 Subject: [PATCH 41/89] docs(images[badges]): add placeholder sizing for external badge images why: badges flash in from zero width because width: auto computes to 0 before the image loads, causing cumulative layout shift. what: - Add min-width: 60px to reserve approximate badge width - Add border-radius: 3px matching shields.io badge shape - Add background placeholder that disappears behind loaded badge --- docs/_static/css/custom.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 2f85829f84..7c7d89d9f5 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -226,4 +226,7 @@ img[src*="badge.svg"], img[src*="codecov.io"] { height: 20px; width: auto; + min-width: 60px; + border-radius: 3px; + background: var(--color-background-secondary); } From 7f113a81fa780a31efbed951fca60e57c50b14c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 18:22:37 -0500 Subject: [PATCH 42/89] docs(sidebar[projects]): prevent active link flash with visibility gate why: all links render with class="current" then JS replaces the hostname-matching link with a bold span, causing a visible reflow when IBM Plex fonts load with different metrics than the fallback. what: - Remove misleading class="current" from all project links - Hide #sidebar-projects until JS resolves active state (.ready class) - Use textContent instead of innerHTML for safer DOM manipulation --- docs/_static/css/custom.css | 4 ++++ docs/_templates/sidebar/projects.html | 21 +++++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 7c7d89d9f5..c979ecbd60 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -16,6 +16,10 @@ margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); } +#sidebar-projects:not(.ready) { + visibility: hidden; +} + .sidebar-tree .active { font-weight: bold; } diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html index 97420c1adf..0c182a2b33 100644 --- a/docs/_templates/sidebar/projects.html +++ b/docs/_templates/sidebar/projects.html @@ -7,24 +7,24 @@

vcs-python - vcspull + vcspull (libvcs), g

tmux-python - tmuxp + tmuxp (libtmux)

cihai - unihan-etl + unihan-etl (db) - cihai + cihai (cli)

@@ -32,38 +32,39 @@

django - django-slugify-processor + django-slugify-processor - django-docutils + django-docutils

docs + tests - gp-libs + gp-libs

web - social-embed + social-embed

From b733808998a51778b14a8a60e870e18afebddf09 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 13 Mar 2026 18:22:42 -0500 Subject: [PATCH 43/89] docs(nav[spa]): wrap DOM swap in View Transitions API for smooth crossfade why: SPA navigation instantly replaces DOM content, causing a jarring visual jump between pages instead of a smooth transition. what: - Wrap swap+reinit in document.startViewTransition() when available - Add 150ms crossfade animation via ::view-transition pseudo-elements - Progressive enhancement: unsupported browsers get instant swap --- docs/_static/css/custom.css | 10 ++++++++++ docs/_static/js/spa-nav.js | 32 ++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index c979ecbd60..1e2c5327af 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -197,6 +197,16 @@ article { * sets max-width: 100%; height: auto on img. We add * content-visibility and badge-specific height to prevent CLS. * ────────────────────────────────────────────────────────── */ + +/* ── View Transitions (SPA navigation) ──────────────────── + * Crossfade between pages during SPA navigation. + * Browsers without View Transitions API get instant swap. + * ────────────────────────────────────────────────────────── */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 150ms; +} + img { content-visibility: auto; } diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js index fa7fd2ed6f..e00e521ab8 100644 --- a/docs/_static/js/spa-nav.js +++ b/docs/_static/js/spa-nav.js @@ -160,21 +160,29 @@ if (!doc.querySelector(".article-container")) throw new Error("no article"); - swap(doc); + var applySwap = function () { + swap(doc); + + if (!isPop) history.pushState({ spa: true }, "", url); + + if (!isPop) { + var hash = new URL(url, location.href).hash; + if (hash) { + var el = document.querySelector(hash); + if (el) el.scrollIntoView(); + } else { + window.scrollTo(0, 0); + } + } - if (!isPop) history.pushState({ spa: true }, "", url); + reinit(); + }; - if (!isPop) { - var hash = new URL(url, location.href).hash; - if (hash) { - var el = document.querySelector(hash); - if (el) el.scrollIntoView(); - } else { - window.scrollTo(0, 0); - } + if (document.startViewTransition) { + document.startViewTransition(applySwap); + } else { + applySwap(); } - - reinit(); } catch (err) { if (err.name === "AbortError") return; window.location.href = url; From 107f104ff688e57a0de31bf51426638db715c31b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 04:45:44 -0500 Subject: [PATCH 44/89] docs(css[structure]): move view transitions section after image rules why: The view transitions block was inserted between the "Image layout shift prevention" section header and its rules, orphaning the comment. what: - Move view transitions comment + rules to end of file - Keep image section header contiguous with its img/badge rules --- docs/_static/css/custom.css | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 1e2c5327af..ed6640c746 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -198,15 +198,6 @@ article { * content-visibility and badge-specific height to prevent CLS. * ────────────────────────────────────────────────────────── */ -/* ── View Transitions (SPA navigation) ──────────────────── - * Crossfade between pages during SPA navigation. - * Browsers without View Transitions API get instant swap. - * ────────────────────────────────────────────────────────── */ -::view-transition-old(root), -::view-transition-new(root) { - animation-duration: 150ms; -} - img { content-visibility: auto; } @@ -244,3 +235,12 @@ img[src*="codecov.io"] { border-radius: 3px; background: var(--color-background-secondary); } + +/* ── View Transitions (SPA navigation) ──────────────────── + * Crossfade between pages during SPA navigation. + * Browsers without View Transitions API get instant swap. + * ────────────────────────────────────────────────────────── */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 150ms; +} From a31731aa1531e64a1df6be10f7a5b8b90d4f53b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 04:45:48 -0500 Subject: [PATCH 45/89] docs(images[about]): add lazy loading to tao-tmux-screenshot why: Image is below the fold; lazy loading defers fetch until needed. what: - Add :loading: lazy to the figure directive in about_tmux.md --- docs/about_tmux.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/about_tmux.md b/docs/about_tmux.md index 5f10e2275e..2db7b3c4d7 100644 --- a/docs/about_tmux.md +++ b/docs/about_tmux.md @@ -5,6 +5,7 @@ :::{figure} /\_static/tao-tmux-screenshot.png :scale: 60% :align: center +:loading: lazy ISC-licensed terminal multiplexer. From 6564409f9640cb4bcab4c545daa466a6ce3f6b3c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 05:26:46 -0500 Subject: [PATCH 46/89] docs(fonts[loading]): switch to font-display block with inline CSS why: font-display swap causes visible text reflow (FOUT). Matching the tony.nl/cv approach: block rendering until preloaded fonts arrive, and inline the @font-face CSS to eliminate the extra fonts.css request. what: - Change font-display from swap to block - Move @font-face CSS from external fonts.css to inline + {%- endif %} {%- if theme_show_meta_manifest_tag == true %} {% endif -%} From 9c0caa15e277473c0a576c84e99211bda0c82a99 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:22:37 -0500 Subject: [PATCH 47/89] Revert "ci(docs): temporarily add docs-fonts branch to trigger" --- .github/workflows/docs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dfdf570ee0..d9dbf6716b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,7 +4,6 @@ on: push: branches: - master - - docs-fonts permissions: contents: read From b8500216172866e9c7bbcd4d3ab6001baa7d3aac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 16:03:44 -0500 Subject: [PATCH 48/89] test(docs[sphinx_fonts]) add tests and fix download failure bugs (#1023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: sphinx_fonts.py had zero test coverage, and two latent bugs were discovered during test development — font-face entries emitted for failed downloads, and partial files poisoning the cache. what: Test suite (21 functions, 545 lines) - _cache_dir: path construction - _cdn_url: parametrized URL formatting, template structure - _download_font: cached hit, success, URLError, OSError, partial cleanup - _on_builder_inited: non-html skip, empty fonts, font processing, download failure skip, explicit subset, preload match/no-match, fallbacks and CSS variables - _on_html_page_context: with/without app attributes - setup: return metadata, config values, event connections Bug fixes in sphinx_fonts.py - Move font_faces.append() inside if _download_font() block to skip font-face entries when download fails (was emitting CSS pointing to missing files) - Add dest.unlink() in except block to remove partial .woff2 files left by interrupted downloads (was poisoning cache) Docstrings - Add docstrings to SetupDict and setup() for ruff D101/D103 --- docs/_ext/sphinx_fonts.py | 21 +- tests/docs/_ext/test_sphinx_fonts.py | 545 +++++++++++++++++++++++++++ 2 files changed, 558 insertions(+), 8 deletions(-) create mode 100644 tests/docs/_ext/test_sphinx_fonts.py diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index b03362e921..e8d2a692ae 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -25,6 +25,8 @@ class SetupDict(t.TypedDict): + """Return type for Sphinx extension setup().""" + version: str parallel_read_safe: bool parallel_write_safe: bool @@ -61,6 +63,8 @@ def _download_font(url: str, dest: pathlib.Path) -> bool: urllib.request.urlretrieve(url, dest) logger.info("downloaded font: %s", dest.name) except (urllib.error.URLError, OSError): + if dest.exists(): + dest.unlink() logger.warning("failed to download font: %s", url) return False return True @@ -93,14 +97,14 @@ def _on_builder_inited(app: Sphinx) -> None: url = _cdn_url(package, version, font_id, subset, weight, style) if _download_font(url, cached): shutil.copy2(cached, fonts_dir / filename) - font_faces.append( - { - "family": font["family"], - "style": style, - "weight": str(weight), - "filename": filename, - } - ) + font_faces.append( + { + "family": font["family"], + "style": style, + "weight": str(weight), + "filename": filename, + } + ) preload_hrefs: list[str] = [] preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload @@ -135,6 +139,7 @@ def _on_html_page_context( def setup(app: Sphinx) -> SetupDict: + """Register config values, events, and return extension metadata.""" app.add_config_value("sphinx_fonts", [], "html") app.add_config_value("sphinx_font_fallbacks", [], "html") app.add_config_value("sphinx_font_css_variables", {}, "html") diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py new file mode 100644 index 0000000000..22f546a2e1 --- /dev/null +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -0,0 +1,545 @@ +"""Tests for sphinx_fonts Sphinx extension.""" + +from __future__ import annotations + +import logging +import pathlib +import types +import typing as t +import urllib.error + +import pytest +import sphinx_fonts + +# --- _cache_dir tests --- + + +def test_cache_dir_returns_home_cache_path() -> None: + """_cache_dir returns ~/.cache/sphinx-fonts.""" + result = sphinx_fonts._cache_dir() + assert result == pathlib.Path.home() / ".cache" / "sphinx-fonts" + + +# --- _cdn_url tests --- + + +class CdnUrlFixture(t.NamedTuple): + """Test fixture for CDN URL generation.""" + + test_id: str + package: str + version: str + font_id: str + subset: str + weight: int + style: str + expected_url: str + + +CDN_URL_FIXTURES: list[CdnUrlFixture] = [ + CdnUrlFixture( + test_id="normal_weight", + package="@fontsource/open-sans", + version="5.2.5", + font_id="open-sans", + subset="latin", + weight=400, + style="normal", + expected_url=( + "https://cdn.jsdelivr.net/npm/@fontsource/open-sans@5.2.5" + "/files/open-sans-latin-400-normal.woff2" + ), + ), + CdnUrlFixture( + test_id="bold_italic", + package="@fontsource/roboto", + version="5.0.0", + font_id="roboto", + subset="latin-ext", + weight=700, + style="italic", + expected_url=( + "https://cdn.jsdelivr.net/npm/@fontsource/roboto@5.0.0" + "/files/roboto-latin-ext-700-italic.woff2" + ), + ), +] + + +@pytest.mark.parametrize( + list(CdnUrlFixture._fields), + CDN_URL_FIXTURES, + ids=[f.test_id for f in CDN_URL_FIXTURES], +) +def test_cdn_url( + test_id: str, + package: str, + version: str, + font_id: str, + subset: str, + weight: int, + style: str, + expected_url: str, +) -> None: + """_cdn_url formats the CDN URL template correctly.""" + result = sphinx_fonts._cdn_url(package, version, font_id, subset, weight, style) + assert result == expected_url + + +def test_cdn_url_matches_template() -> None: + """_cdn_url produces URLs matching CDN_TEMPLATE structure.""" + url = sphinx_fonts._cdn_url( + "@fontsource/inter", "5.1.0", "inter", "latin", 400, "normal" + ) + assert url.startswith("https://cdn.jsdelivr.net/npm/") + assert "@fontsource/inter@5.1.0" in url + assert url.endswith(".woff2") + + +# --- _download_font tests --- + + +def test_download_font_cached( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font returns True and logs debug when file exists.""" + dest = tmp_path / "font.woff2" + dest.write_bytes(b"cached-data") + + with caplog.at_level(logging.DEBUG, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is True + debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG] + assert any("cached" in r.message for r in debug_records) + + +def test_download_font_success( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font downloads and returns True on success.""" + dest = tmp_path / "subdir" / "font.woff2" + + def fake_urlretrieve(url: str, filename: t.Any) -> tuple[str, t.Any]: + pathlib.Path(filename).write_bytes(b"font-data") + return (str(filename), None) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + with caplog.at_level(logging.INFO, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is True + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert any("downloaded" in r.message for r in info_records) + + +def test_download_font_url_error( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font returns False and warns on URLError.""" + dest = tmp_path / "font.woff2" + + msg = "network error" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise urllib.error.URLError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is False + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("failed" in r.message for r in warning_records) + + +def test_download_font_os_error( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font returns False and warns on OSError.""" + dest = tmp_path / "font.woff2" + + msg = "disk full" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise OSError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is False + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("failed" in r.message for r in warning_records) + + +def test_download_font_partial_file_cleanup( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_download_font removes partial file on failure.""" + dest = tmp_path / "cache" / "partial.woff2" + + msg = "disk full" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + pathlib.Path(filename).write_bytes(b"partial") + raise OSError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is False + assert not dest.exists() + + +# --- _on_builder_inited tests --- + + +def _make_app( + tmp_path: pathlib.Path, + *, + builder_format: str = "html", + fonts: list[dict[str, t.Any]] | None = None, + preload: list[tuple[str, int, str]] | None = None, + fallbacks: list[dict[str, str]] | None = None, + variables: dict[str, str] | None = None, +) -> types.SimpleNamespace: + """Create a fake Sphinx app namespace for testing.""" + config = types.SimpleNamespace( + sphinx_fonts=fonts if fonts is not None else [], + sphinx_font_preload=preload if preload is not None else [], + sphinx_font_fallbacks=fallbacks if fallbacks is not None else [], + sphinx_font_css_variables=variables if variables is not None else {}, + ) + builder = types.SimpleNamespace(format=builder_format) + return types.SimpleNamespace( + builder=builder, + config=config, + outdir=str(tmp_path / "output"), + ) + + +def test_on_builder_inited_non_html(tmp_path: pathlib.Path) -> None: + """_on_builder_inited returns early for non-HTML builders.""" + app = _make_app(tmp_path, builder_format="latex") + sphinx_fonts._on_builder_inited(app) + assert not hasattr(app, "_font_faces") + + +def test_on_builder_inited_empty_fonts(tmp_path: pathlib.Path) -> None: + """_on_builder_inited returns early when no fonts configured.""" + app = _make_app(tmp_path, fonts=[]) + sphinx_fonts._on_builder_inited(app) + assert not hasattr(app, "_font_faces") + + +def test_on_builder_inited_with_fonts( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited processes fonts and stores results on app.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/open-sans", + "version": "5.2.5", + "family": "Open Sans", + "weights": [400, 700], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + for weight in [400, 700]: + (cache / f"open-sans-latin-{weight}-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert len(app._font_faces) == 2 + assert app._font_faces[0]["family"] == "Open Sans" + assert app._font_faces[0]["weight"] == "400" + assert app._font_faces[1]["weight"] == "700" + assert app._font_preload_hrefs == [] + assert app._font_fallbacks == [] + assert app._font_css_variables == {} + + +def test_on_builder_inited_download_failure( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited skips font_faces entry on download failure.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + msg = "offline" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise urllib.error.URLError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + fonts = [ + { + "package": "@fontsource/inter", + "version": "5.0.0", + "family": "Inter", + "weights": [400], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + sphinx_fonts._on_builder_inited(app) + + assert len(app._font_faces) == 0 + + +def test_on_builder_inited_explicit_subset( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited respects explicit subset in font config.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/noto-sans", + "version": "5.0.0", + "family": "Noto Sans", + "subset": "latin-ext", + "weights": [400], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "noto-sans-latin-ext-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_faces[0]["filename"] == "noto-sans-latin-ext-400-normal.woff2" + + +def test_on_builder_inited_preload_match( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited builds preload_hrefs for matching preload specs.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/open-sans", + "version": "5.2.5", + "family": "Open Sans", + "weights": [400], + "styles": ["normal"], + }, + ] + preload = [("Open Sans", 400, "normal")] + app = _make_app(tmp_path, fonts=fonts, preload=preload) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_preload_hrefs == ["open-sans-latin-400-normal.woff2"] + + +def test_on_builder_inited_preload_no_match( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited produces empty preload when family doesn't match.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/open-sans", + "version": "5.2.5", + "family": "Open Sans", + "weights": [400], + "styles": ["normal"], + }, + ] + preload = [("Nonexistent Font", 400, "normal")] + app = _make_app(tmp_path, fonts=fonts, preload=preload) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_preload_hrefs == [] + + +def test_on_builder_inited_fallbacks_and_variables( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited stores fallbacks and CSS variables on app.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/inter", + "version": "5.0.0", + "family": "Inter", + "weights": [400], + "styles": ["normal"], + }, + ] + fallbacks = [{"family": "system-ui", "style": "normal", "weight": "400"}] + variables = {"--font-body": "Inter, system-ui"} + app = _make_app(tmp_path, fonts=fonts, fallbacks=fallbacks, variables=variables) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "inter-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_fallbacks == fallbacks + assert app._font_css_variables == variables + + +# --- _on_html_page_context tests --- + + +def test_on_html_page_context_with_attrs() -> None: + """_on_html_page_context injects font data from app attributes.""" + app = types.SimpleNamespace( + _font_preload_hrefs=["font-400.woff2"], + _font_faces=[ + { + "family": "Inter", + "weight": "400", + "style": "normal", + "filename": "font-400.woff2", + }, + ], + _font_fallbacks=[{"family": "system-ui"}], + _font_css_variables={"--font-body": "Inter"}, + ) + context: dict[str, t.Any] = {} + + sphinx_fonts._on_html_page_context( + app, + "index", + "page.html", + context, + None, + ) + + assert context["font_preload_hrefs"] == ["font-400.woff2"] + assert context["font_faces"] == app._font_faces + assert context["font_fallbacks"] == [{"family": "system-ui"}] + assert context["font_css_variables"] == {"--font-body": "Inter"} + + +def test_on_html_page_context_without_attrs() -> None: + """_on_html_page_context uses defaults when app attrs are missing.""" + app = types.SimpleNamespace() + context: dict[str, t.Any] = {} + + sphinx_fonts._on_html_page_context( + app, + "index", + "page.html", + context, + None, + ) + + assert context["font_preload_hrefs"] == [] + assert context["font_faces"] == [] + assert context["font_fallbacks"] == [] + assert context["font_css_variables"] == {} + + +# --- setup tests --- + + +def test_setup_return_value() -> None: + """Verify setup() returns correct metadata dict.""" + config_values: list[tuple[str, t.Any, str]] = [] + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + add_config_value=lambda name, default, rebuild: config_values.append( + (name, default, rebuild) + ), + connect=lambda event, handler: connections.append((event, handler)), + ) + + result = sphinx_fonts.setup(app) + + assert result == { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } + + +def test_setup_config_values() -> None: + """Verify setup() registers all expected config values.""" + config_values: list[tuple[str, t.Any, str]] = [] + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + add_config_value=lambda name, default, rebuild: config_values.append( + (name, default, rebuild) + ), + connect=lambda event, handler: connections.append((event, handler)), + ) + + sphinx_fonts.setup(app) + + config_names = [c[0] for c in config_values] + assert "sphinx_fonts" in config_names + assert "sphinx_font_fallbacks" in config_names + assert "sphinx_font_css_variables" in config_names + assert "sphinx_font_preload" in config_names + assert all(c[2] == "html" for c in config_values) + + +def test_setup_event_connections() -> None: + """Verify setup() connects to builder-inited and html-page-context events.""" + config_values: list[tuple[str, t.Any, str]] = [] + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + add_config_value=lambda name, default, rebuild: config_values.append( + (name, default, rebuild) + ), + connect=lambda event, handler: connections.append((event, handler)), + ) + + sphinx_fonts.setup(app) + + event_names = [c[0] for c in connections] + assert "builder-inited" in event_names + assert "html-page-context" in event_names + + handlers = {c[0]: c[1] for c in connections} + assert handlers["builder-inited"] is sphinx_fonts._on_builder_inited + assert handlers["html-page-context"] is sphinx_fonts._on_html_page_context From b32f996eec8959ea5ecdc80da1116d879a6cab68 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 18:23:52 -0500 Subject: [PATCH 49/89] chore(mypy[overrides]): Add sphinx_fonts to ignore_missing_imports why: mypy reports `import-not-found` for `sphinx_fonts` since the package does not ship type stubs or a py.typed marker. what: - Add "sphinx_fonts" to the [[tool.mypy.overrides]] module list - Position it alongside other Sphinx extension ignores (sphinx_argparse_neo, sphinx_argparse_neo.*) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e394750acc..9e3f8c75fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,7 @@ module = [ "bpython", "sphinx_argparse_neo", "sphinx_argparse_neo.*", + "sphinx_fonts", "cli_usage_lexer", "argparse_lexer", "argparse_roles", From 7ae91c0c2b5680a06d5af5213338c92be9988a60 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 06:44:51 -0500 Subject: [PATCH 50/89] docs: Improve command formatting (#1024) why: Standardize shell code blocks to follow documentation guidelines what: - Use `console` language tag with `$ ` prefix for shell commands - Split long pipx install command with `\` line continuations - Split long env commands in docs/developing.md - Add Shell Command Formatting rules to AGENTS.md --- AGENTS.md | 38 +++++++++++++++++++++++++++++++++++++- CHANGES | 18 +++++++++++------- docs/cli/completion.md | 12 ++++++------ docs/developing.md | 6 ++++-- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bdbe24f926..7e230283db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,7 +282,7 @@ Raw `print()` is forbidden in command/business logic. The `print()` call lives o When writing documentation (README, CHANGES, docs/), follow these rules for code blocks: -**One command per code block.** This makes commands individually copyable. +**One command per code block.** This makes commands individually copyable. For sequential commands, either use separate code blocks or chain them with `&&` or `;` and `\` continuations (keeping it one logical command). **Put explanations outside the code block**, not as comments inside. @@ -310,6 +310,42 @@ $ uv run pytest $ uv run pytest --cov ``` +### Shell Command Formatting + +These rules apply to shell commands in documentation (README, CHANGES, docs/), **not** to Python doctests. + +**Use `console` language tag with `$ ` prefix.** This distinguishes interactive commands from scripts and enables prompt-aware copy in many terminals. + +Good: + +```console +$ uv run pytest +``` + +Bad: + +```bash +uv run pytest +``` + +**Split long commands with `\` for readability.** Each flag or flag+value pair gets its own continuation line, indented. Positional parameters go on the final line. + +Good: + +```console +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' +``` + +Bad: + +```console +$ pipx install --suffix=@next --pip-args '\--pre' --force 'tmuxp' +``` + ## Important Notes - **QA every edit**: Run formatting and tests before committing diff --git a/CHANGES b/CHANGES index 707359f3ac..0330651353 100644 --- a/CHANGES +++ b/CHANGES @@ -23,7 +23,11 @@ $ uvx --from 'tmuxp' --prerelease allow tmuxp [pipx](https://pypa.github.io/pipx/docs/): ```console -$ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force +$ pipx install \ + --suffix=@next \ + --pip-args '\--pre' \ + --force \ + 'tmuxp' // Usage: tmuxp@next load yoursession ``` @@ -453,8 +457,8 @@ _Maintenance only, no bug fixes or new features_ via ruff v0.8.4, all automated lint fixes, including unsafe and previews were applied for Python 3.9: - ```sh - ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . + ```console + $ ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . ``` ## tmuxp 1.49.0 (2024-11-26) @@ -560,14 +564,14 @@ _Maintenance only, no bug fixes or new features_ via ruff v0.3.4, all automated lint fixes, including unsafe and previews were applied: - ```sh - ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . + ```console + $ ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . ``` Branches were treated with: - ```sh - git rebase \ + ```console + $ git rebase \ --strategy-option=theirs \ --exec 'poetry run ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; poetry run ruff format .; git add src tests; git commit --amend --no-edit' \ origin/master diff --git a/docs/cli/completion.md b/docs/cli/completion.md index ee7f1f3fa1..410aed4ee3 100644 --- a/docs/cli/completion.md +++ b/docs/cli/completion.md @@ -32,8 +32,8 @@ $ uvx shtab --help :::{tab} bash -```bash -shtab --shell=bash -u tmuxp.cli.create_parser \ +```console +$ shtab --shell=bash -u tmuxp.cli.create_parser \ | sudo tee "$BASH_COMPLETION_COMPAT_DIR"/TMUXP ``` @@ -41,8 +41,8 @@ shtab --shell=bash -u tmuxp.cli.create_parser \ :::{tab} zsh -```zsh -shtab --shell=zsh -u tmuxp.cli.create_parser \ +```console +$ shtab --shell=zsh -u tmuxp.cli.create_parser \ | sudo tee /usr/local/share/zsh/site-functions/_TMUXP ``` @@ -50,8 +50,8 @@ shtab --shell=zsh -u tmuxp.cli.create_parser \ :::{tab} tcsh -```zsh -shtab --shell=tcsh -u tmuxp.cli.create_parser \ +```console +$ shtab --shell=tcsh -u tmuxp.cli.create_parser \ | sudo tee /etc/profile.d/TMUXP.completion.csh ``` diff --git a/docs/developing.md b/docs/developing.md index 1b909363df..cceb9eb61e 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -175,13 +175,15 @@ $ env PYTEST_ADDOPTS="tests/workspace/test_builder.py" uv run make start Drop into `test_automatic_rename_option()` in `tests/workspace/test_builder.py`: ```console -$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py" uv run make start +$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py" \ + uv run make start ``` Drop into `test_automatic_rename_option()` in `tests/workspace/test_builder.py` and stop on first error: ```console -$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py::test_automatic_rename_option" uv run make start +$ env PYTEST_ADDOPTS="-s -x -vv tests/workspace/test_builder.py::test_automatic_rename_option" \ + uv run make start ``` Drop into `pdb` on first error: From 0d13b6458f23d98dcbb2d1f7d474d3fa191d224a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 17 Mar 2026 17:44:59 -0500 Subject: [PATCH 51/89] docs(fix[copybutton]): Enable line continuation for multiline console blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: sphinx-copybutton's copy button silently drops continuation lines in multiline `\`-continued console commands. For example, clicking copy on: ```console $ pipx install \ --suffix=@next \ --pip-args '\--pre' \ --force \ 'libtmux' ``` Only copies `pipx install \` — the continuation lines are discarded. Root cause: `copybutton_line_continuation_character` defaults to `""`. In copybutton_funcs.js, `useLineCont = !!""` evaluates to `false`, so `gotLineCont` never activates. Lines not matching the prompt regex (`$ `) are silently dropped by the formatCopyText() line-processing loop. Setting it to `"\\"` makes `useLineCont = true`, and any line ending in `\` sets `gotLineCont = true` for the next iteration, including it regardless of prompt match. This is the standard fix adopted by Apache Arrow, NVIDIA TensorRT-LLM, Flower, Crate docs theme, and Nextstrain sphinx theme. Refs: - https://sphinx-copybutton.readthedocs.io/en/latest/use.html - https://github.com/executablebooks/sphinx-copybutton/pull/126 - https://github.com/executablebooks/sphinx-copybutton/issues/65 - https://github.com/executablebooks/sphinx-copybutton/issues/52 what: - Add `copybutton_line_continuation_character = "\\"` to docs/conf.py --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index b401540b0a..478e5c1f01 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -138,6 +138,7 @@ ) copybutton_prompt_is_regexp = True copybutton_remove_prompts = True +copybutton_line_continuation_character = "\\" # sphinxext-rediraffe rediraffe_redirects = "redirects.txt" From a1eec5de4efb48356ebadd473ee90b1c1bdcf308 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 05:29:07 -0500 Subject: [PATCH 52/89] py(deps[dev]) Bump dev packages --- uv.lock | 458 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 237 insertions(+), 221 deletions(-) diff --git a/uv.lock b/uv.lock index 5606904017..379131738e 100644 --- a/uv.lock +++ b/uv.lock @@ -84,91 +84,107 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, - { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, - { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, - { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, - { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, - { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, - { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, - { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, - { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, - { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, - { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, - { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, - { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, - { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, - { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, - { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, - { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, - { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, - { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, - { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, - { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, - { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, - { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, - { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, - { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, - { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, - { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, - { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, - { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, - { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, - { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, - { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, - { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, - { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, - { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, - { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, - { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, - { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, - { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, - { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, - { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, - { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, - { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, - { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, - { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, - { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, - { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, - { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, - { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, - { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, - { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] @@ -207,115 +223,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, - { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, - { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, - { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, - { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, - { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, - { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, - { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, - { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, - { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, - { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, - { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, - { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [package.optional-dependencies] @@ -1069,27 +1085,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, - { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, - { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, - { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, - { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, - { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] [[package]] @@ -1582,11 +1598,11 @@ wheels = [ [[package]] name = "types-docutils" -version = "0.22.3.20260223" +version = "0.22.3.20260316" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/33/92c0129283363e3b3ba270bf6a2b7d077d949d2f90afc4abaf6e73578563/types_docutils-0.22.3.20260223.tar.gz", hash = "sha256:e90e868da82df615ea2217cf36dff31f09660daa15fc0f956af53f89c1364501", size = 57230, upload-time = "2026-02-23T04:11:21.806Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/27/a7f16b3a2fad0a4ddd85a668319f9a1d0311c4bd9578894f6471c7e6c788/types_docutils-0.22.3.20260316.tar.gz", hash = "sha256:8ef27d565b9831ff094fe2eac75337a74151013e2d21ecabd445c2955f891564", size = 57263, upload-time = "2026-03-16T04:29:12.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/c7/a4ae6a75d5b07d63089d5c04d450a0de4a5d48ffcb84b95659b22d3885fe/types_docutils-0.22.3.20260223-py3-none-any.whl", hash = "sha256:cc2d6b7560a28e351903db0989091474aa619ad287843a018324baee9c4d9a8f", size = 91969, upload-time = "2026-02-23T04:11:20.966Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/c1f22b7cfc4837d5419e5a2d8702c7d65f03343f866364b71cccd8a73b79/types_docutils-0.22.3.20260316-py3-none-any.whl", hash = "sha256:083c7091b8072c242998ec51da1bf1492f0332387da81c3b085efbf5ca754c7d", size = 91968, upload-time = "2026-03-16T04:29:11.114Z" }, ] [[package]] @@ -1639,16 +1655,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.41.0" +version = "0.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [[package]] From 29273984c72b684da6a18299246cb930d5e657f2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 05:32:59 -0500 Subject: [PATCH 53/89] .tool-versions(uv) uv 0.10.9 -> 0.10.12 See also: - uv: - https://github.com/astral-sh/uv/releases/tag/0.10.12 - https://github.com/astral-sh/uv/blob/0.10.12/CHANGELOG.md --- .tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index 0c2c7c1d5d..3e09cddfcb 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ just 1.46.0 -uv 0.10.9 +uv 0.10.12 python 3.14 3.13 3.12 3.11 3.10 From d834c79372eeac3b6c81b250609f82d7f95e973f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 05:39:50 -0500 Subject: [PATCH 54/89] .tool-versions(just) just 1.46 -> 1.47 - just - https://github.com/casey/just/blob/1.47.0/CHANGELOG.md - https://github.com/casey/just/releases/tag/1.47.0 - https://github.com/casey/just/blob/1.47.1/CHANGELOG.md - https://github.com/casey/just/releases/tag/1.47.1 --- .tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index 3e09cddfcb..9c32c2514e 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -just 1.46.0 +just 1.47 uv 0.10.12 python 3.14 3.13 3.12 3.11 3.10 From a896ce3a0c24c1efee4fefa89d6b4af759becfab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 05:52:20 -0500 Subject: [PATCH 55/89] py(test[filterwarnings]): Fix aafigure SyntaxWarning filter for Python 3.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: On Python 3.14, invalid escape sequences emit SyntaxWarning at compile time. The compiler derives the warning's module from the full filesystem path (e.g. /home/…/site-packages/aafigure/aafigure), not the dotted module name. Since Python's warning filter uses re.match() (anchored at start), the bare `aafigure` pattern failed to match the full path, allowing the warning to slip through. what: - Change module regex from `aafigure` to `.*aafigure.*` so it matches the full file path that CPython's compiler passes as the module name --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9e3f8c75fa..a5c8826633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -249,5 +249,5 @@ testpaths = [ ] filterwarnings = [ "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", - "ignore:.*invalid escape sequence.*:SyntaxWarning:aafigure:", + "ignore:.*invalid escape sequence.*:SyntaxWarning:.*aafigure.*:", ] From a198e3c9f0cd0df771fe1593691238cbd59d9856 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 08:27:11 -0500 Subject: [PATCH 56/89] ai(claude[skills]): Add update-libtmux dependency workflow skill why: Codify the recurring workflow for bumping libtmux in tmuxp into a reusable Claude Code skill with auto-discovery via description matching. what: - Add .claude/skills/update-libtmux/SKILL.md - Encodes 7-step workflow: preflight, study upstream, branch, package commit, code commit(s), test commit(s), CHANGES commit - Produces separate atomic commits with rich messages following project conventions (see ff52d0d2 as exemplar) - Includes user confirmation gate after impact analysis - References past libtmux bump commits as templates --- .claude/skills/update-libtmux/SKILL.md | 359 +++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 .claude/skills/update-libtmux/SKILL.md diff --git a/.claude/skills/update-libtmux/SKILL.md b/.claude/skills/update-libtmux/SKILL.md new file mode 100644 index 0000000000..530ae6d9e1 --- /dev/null +++ b/.claude/skills/update-libtmux/SKILL.md @@ -0,0 +1,359 @@ +--- +name: update-libtmux +description: >- + Use when the user asks to "update libtmux", "bump libtmux", + "upgrade libtmux dependency", "check for new libtmux version", + or when investigating whether tmuxp needs a libtmux update. + Guides the full workflow: studying upstream changes, updating + the dependency, migrating code and tests, and producing + separate atomic commits with rich messages. +user-invocable: true +argument-hint: "[target-version] (optional, defaults to latest on PyPI)" +--- + +# Update libtmux Dependency + +Workflow for updating the libtmux dependency in tmuxp with separate, atomic commits. + +## Overview + +This skill produces up to four atomic commits on a dedicated branch, then opens a PR: + +1. **Package commit** — bump `pyproject.toml` + `uv.lock` +2. **Code commit(s)** — API migrations, new feature adoption (if needed) +3. **Test commit(s)** — test updates for changed/new APIs (if needed) +4. **CHANGES commit** — changelog entry documenting the bump + +Each commit stands alone, passes tests independently, and has a rich commit body. + +## Step 0: Preflight + +Gather current state before making any changes. + +### 0a. Current dependency + +Read `pyproject.toml` and find the `libtmux~=X.Y.Z` specifier in `[project] dependencies`. + +### 0b. Latest version on PyPI + +```bash +pip index versions libtmux +``` + +If the user provided a target version, use that. Otherwise use the latest from PyPI. + +### 0c. Short-circuit check + +If the current specifier already covers the target version, inform the user and stop. + +### 0d. Ensure local libtmux clone is current + +The local libtmux clone lives at `~/work/python/libtmux`. Fetch and check: + +```bash +cd ~/work/python/libtmux && git fetch --tags && git log --oneline -5 +``` + +Verify the target version tag exists. If not, the version may not be released yet — warn the user. + +## Step 1: Study upstream changes + +This is the most important step. Read the libtmux CHANGES file to understand what changed between the current pinned version and the target. + +### 1a. Read libtmux CHANGES + +Read `~/work/python/libtmux/CHANGES` from the section for the target version back through all versions since the current pin. + +Categorize changes into: + +| Category | Action needed in tmuxp | +|----------|----------------------| +| **Breaking changes** | Must fix code/tests | +| **Deprecations** | Should migrate away | +| **New APIs** | Optionally adopt | +| **Bug fixes** | Note for commit message | +| **Internal/docs** | Note for commit message only | + +### 1b. Check for API impact in tmuxp + +For each breaking change or deprecation, grep tmuxp source and tests: + +```bash +# Example: if Window.rename_window() changed signature +rg "rename_window" src/ tests/ +``` + +Search patterns to check (adapt based on actual changes): +- Method/function names that changed +- Constructor parameters that changed +- Import paths that moved +- Exception types that changed +- Return type changes + +### 1c. Check libtmux git log for details + +For breaking changes where the CHANGES entry is unclear, read the actual commits: + +```bash +cd ~/work/python/libtmux && git log --oneline v{CURRENT}..v{TARGET} -- src/ +``` + +### 1d. Summarize findings + +Present findings to the user before proceeding: +- Versions being skipped (e.g., "0.53.1, 0.54.0, 0.55.0") +- Breaking changes requiring code updates +- New APIs available for adoption +- Test impact assessment +- Estimated commit count + +Get user confirmation to proceed. + +## Step 2: Create branch + +```bash +git checkout -b deps/libtmux-{TARGET_VERSION} +``` + +Branch naming convention: `deps/libtmux-X.Y.Z` + +## Step 3: Package commit + +Update the dependency specifier and lock file. + +### 3a. Edit pyproject.toml + +Change the `libtmux~=X.Y.Z` line in `[project] dependencies`. + +### 3b. Update lock file + +```bash +uv lock +``` + +### 3c. Verify installation + +```bash +uv sync +``` + +### 3d. Run tests (smoke check) + +```bash +uv run py.test tests/ -x -q 2>&1 | tail -20 +``` + +Note any failures — these indicate code changes needed in Step 4. + +### 3e. Commit + +Commit message format (use heredoc for multiline): + +``` +deps(libtmux[~=X.Y.Z]): Bump from ~=A.B.C + +why: Pick up N libtmux release(s) (list versions) bringing +[brief summary of key changes]. + +what: +- Bump libtmux dependency specifier ~=A.B.C -> ~=X.Y.Z in pyproject.toml +- Update uv.lock + +libtmux X.Y.Z (date): +- [key change 1] +- [key change 2] + +[repeat for each intermediate version] + +Release: https://github.com/tmux-python/libtmux/releases/tag/vX.Y.Z +Changelog: https://libtmux.git-pull.com/history.html#libtmux-X-Y-Z-YYYY-MM-DD +``` + +Stage only `pyproject.toml` and `uv.lock`. + +## Step 4: Code commit(s) — if needed + +Skip this step if no breaking changes or API migrations are needed. + +### 4a. Fix breaking changes + +Address each breaking change identified in Step 1b. Make minimal, targeted fixes. + +### 4b. Adopt new APIs (optional) + +Only if the user requested it or it simplifies existing code significantly. + +### 4c. Run linting and type checking + +```bash +uv run ruff check . --fix --show-fixes +uv run ruff format . +uv run mypy +``` + +### 4d. Run tests + +```bash +uv run py.test tests/ -x -q +``` + +### 4e. Commit + +One commit per logical change. Format: + +``` +Scope(type[detail]): description of the migration + +why: libtmux X.Y.Z changed [what changed]. +what: +- [specific code change 1] +- [specific code change 2] +``` + +Use the project's standard scope conventions: +- `workspace/builder(fix[method])` for builder changes +- `cli/load(fix[feature])` for CLI changes +- `plugin(fix[hook])` for plugin changes + +## Step 5: Test commit(s) — if needed + +Skip if no test changes are required beyond what was fixed in Step 4. + +### 5a. Update tests for API changes + +Fix any tests that broke due to upstream changes. + +### 5b. Add tests for newly adopted APIs + +If Step 4 adopted new libtmux features, add tests. + +### 5c. Run full test suite + +```bash +uv run py.test +``` + +All tests must pass (doctests included — pytest is configured with `--doctest-modules`). + +### 5d. Commit + +``` +tests(scope[detail]): description + +why: Adapt tests for libtmux X.Y.Z [change]. +what: +- [specific test change 1] +- [specific test change 2] +``` + +## Step 6: CHANGES commit + +### 6a. Determine placement + +The CHANGES file has a placeholder section for the next unreleased version at the top. Add the entry below the placeholder comments. + +### 6b. Write the entry + +Add under `### Breaking Changes` if the bump changes minimum version, or `### Development` / `### Dependencies` for non-breaking bumps: + +For a breaking bump: + +```markdown +#### **libtmux** minimum bumped from `~=A.B.C` to `~=X.Y.Z` + + Picks up N releases: [version list with brief descriptions]. +``` + +For a non-breaking bump, use `### Dependencies`: + +```markdown +### Dependencies + +- Bump libtmux `~=A.B.C` -> `~=X.Y.Z` ([key changes summary]) +``` + +### 6c. Commit + +``` +docs(CHANGES): libtmux ~=A.B.C -> ~=X.Y.Z + +why: Document the dependency bump for the upcoming release. +what: +- Add entry for libtmux bump under [section name] +- Summarize key upstream changes +``` + +## Step 7: Push and open PR + +### 7a. Push the branch + +```bash +git push -u origin deps/libtmux-{TARGET_VERSION} +``` + +### 7b. Open PR + +```bash +gh pr create \ + --title "deps(libtmux[~=X.Y.Z]): Bump from ~=A.B.C" \ + --body "$(cat <<'EOF' +## Summary + +- Bump libtmux from `~=A.B.C` to `~=X.Y.Z` +- [N] upstream releases included +- [Breaking changes summary, or "No breaking changes"] + +## Upstream changes + +### libtmux X.Y.Z (date) +- [changes] + +[repeat for intermediate versions] + +## Changes in this PR + +- **Package**: pyproject.toml + uv.lock +- **Code**: [summary or "No code changes needed"] +- **Tests**: [summary or "No test changes needed"] +- **CHANGES**: Documented bump + +## Test plan + +- [ ] `uv run py.test` passes +- [ ] `uv run mypy` passes +- [ ] `uv run ruff check .` passes +EOF +)" +``` + +### 7c. Report to user + +Provide the PR URL and a summary of all commits created. + +## Reference: Past libtmux bumps + +These exemplar commits show the established patterns: + +| Version bump | Deps commit | CHANGES commit | PR | +|---|---|---|---| +| 0.53.0 → 0.55.0 | `ff52d0d2` | `094800f4` | #1019 | +| 0.52.1 → 0.53.0 | `5ff6400f` | `240d85fe` | #1003 | +| 0.51.0 → 0.52.1 | `fabd678f` | (in same commit) | #1001 | +| 0.50.1 → 0.51.0 | (in merge) | (in merge) | #999 | + +The 0.53→0.55 bump (`ff52d0d2`) is the gold standard for commit message richness — per-version changelogs, upstream links, and clear why/what structure. + +## Checklist + +Use this as a progress tracker: + +- [ ] Preflight: identify current and target versions +- [ ] Study upstream CHANGES and identify impact +- [ ] Summarize findings and get user confirmation +- [ ] Create branch `deps/libtmux-X.Y.Z` +- [ ] Package commit: pyproject.toml + uv.lock +- [ ] Code commit(s): API migrations (if needed) +- [ ] Test commit(s): test updates (if needed) +- [ ] CHANGES commit: changelog entry +- [ ] Push and open PR +- [ ] Report PR URL to user From 5d81fc9928dd52cf530fe526e973a57ba22a24b3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 08:41:38 -0500 Subject: [PATCH 57/89] docs(sidebar): Add libtmux-mcp links and path-aware active detection why: Update sidebar to include libtmux-mcp and pytest sub-links under tmux-python group, and upgrade JS to support path-aware active detection for sub-project URLs. what: - Switch tmux-python to indented layout with mcp and pytest sub-links - Replace hostname-only JS matching with URL-aware path prefix matching --- docs/_templates/sidebar/projects.html | 36 +++++++++++++++++++-------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html index 0c182a2b33..f70e6fe032 100644 --- a/docs/_templates/sidebar/projects.html +++ b/docs/_templates/sidebar/projects.html @@ -13,8 +13,14 @@

tmux-python - tmuxp - (libtmux) + + tmuxp + + + libtmux + (mcp, + pytest) +

@@ -56,15 +62,23 @@ From 33da7da2b7216ce51a9866ad63a41a2962dee4cc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 19:53:11 -0500 Subject: [PATCH 58/89] docs(contributing): Update for project tooling why: Template referenced flake8 and make test; project uses ruff, mypy, and uv. what: - Rewrite PR process for ruff, mypy, pytest, uv workflow - Reference AGENTS.md and developing docs --- .github/contributing.md | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/.github/contributing.md b/.github/contributing.md index 5712dcf03a..c0eddac2c2 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -3,25 +3,17 @@ When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the maintainers of this repository before making a change. -Please note we have a code of conduct, please follow it in all your interactions with the project. +See [developing](../docs/developing.md) for environment setup and [AGENTS.md](../AGENTS.md) for +detailed coding standards. ## Pull Request Process -1. Ensure any install or build dependencies are removed before the end of the layer when doing a - build. -2. This project uses flake8 to conform with common Python standards. Make sure - to run your code through linter using latest version of flake8, before pull request. -3. Bad documnentation is a Bug. If your change demands documentation update, please do so. If you - find an issue with documentation, take the time to improve or fix it. -4. pytest is used for automated testing. Please make sure to update tests that are needed, and to run - `make test` before submitting your pull request. This should prevent issues with TravisCI and - make the review and merging process easier and faster. -5. Update the README.md with details of changes to the interface, this includes new environment - variables, exposed ports, useful file locations and container parameters. -6. Increase the version numbers in any examples files and the README.md to the new version that this - Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). -7. You may merge the Pull Request in once you have the sign-off of one other developer. If you - do not have permission to do that, you may request reviewer to merge it for you. +1. **Format and lint**: `uv run ruff format .` then `uv run ruff check . --fix --show-fixes` +2. **Type check**: `uv run mypy` +3. **Test**: `uv run pytest` — all tests must pass before submitting +4. **Document**: Update docs if your change affects the public interface +5. You may merge the Pull Request once you have the sign-off of one other developer. If you + do not have permission to do that, you may request a reviewer to merge it for you. ## Decorum From 3869c11fe476a0d4ec4d210854ed12d8ba7fd0d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 17:50:05 -0500 Subject: [PATCH 59/89] py(deps[docs]): add sphinx-design, annotate doc dependencies with site URLs why: sphinx-design is needed for grid cards in the documentation landing pages and section indexes. Doc-site URLs added as inline comments for quick reference when managing dependencies. what: - Add sphinx-design to docs and dev dependency groups - Annotate all doc dependencies with their documentation URLs --- pyproject.toml | 54 ++++++++++++++++++++++++++------------------------ uv.lock | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a5c8826633..02fc0e28f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,19 +55,20 @@ tmuxp = 'tmuxp:cli.cli' [dependency-groups] dev = [ # Docs - "aafigure", - "pillow", - "sphinx<9", - "furo", - "gp-libs", - "sphinx-autobuild", - "sphinx-autodoc-typehints", - "sphinx-inline-tabs", - "sphinxext-opengraph", - "sphinx-copybutton", - "sphinxext-rediraffe", - "myst-parser", - "linkify-it-py", + "aafigure", # https://launchpad.net/aafigure + "pillow", # https://pillow.readthedocs.io/ + "sphinx<9", # https://www.sphinx-doc.org/ + "furo", # https://pradyunsg.me/furo/ + "gp-libs", # https://gp-libs.git-pull.com/ + "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html + "sphinx-autodoc-typehints", # https://sphinx-autodoc-typehints.readthedocs.io/ + "sphinx-inline-tabs", # https://sphinx-inline-tabs.readthedocs.io/ + "sphinxext-opengraph", # https://sphinxext-opengraph.readthedocs.io/ + "sphinx-copybutton", # https://sphinx-copybutton.readthedocs.io/ + "sphinxext-rediraffe", # https://sphinxext-rediraffe.readthedocs.io/ + "sphinx-design", # https://sphinx-design.readthedocs.io/ + "myst-parser", # https://myst-parser.readthedocs.io/ + "linkify-it-py", # https://github.com/tsutsu3/linkify-it-py # Testing "gp-libs", "pytest", @@ -87,19 +88,20 @@ dev = [ ] docs = [ - "aafigure", - "pillow", - "sphinx<9", - "furo", - "gp-libs", - "sphinx-autobuild", - "sphinx-autodoc-typehints", - "sphinx-inline-tabs", - "sphinxext-opengraph", - "sphinx-copybutton", - "sphinxext-rediraffe", - "myst-parser", - "linkify-it-py", + "aafigure", # https://launchpad.net/aafigure + "pillow", # https://pillow.readthedocs.io/ + "sphinx<9", # https://www.sphinx-doc.org/ + "furo", # https://pradyunsg.me/furo/ + "gp-libs", # https://gp-libs.git-pull.com/ + "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html + "sphinx-autodoc-typehints", # https://sphinx-autodoc-typehints.readthedocs.io/ + "sphinx-inline-tabs", # https://sphinx-inline-tabs.readthedocs.io/ + "sphinxext-opengraph", # https://sphinxext-opengraph.readthedocs.io/ + "sphinx-copybutton", # https://sphinx-copybutton.readthedocs.io/ + "sphinxext-rediraffe", # https://sphinxext-rediraffe.readthedocs.io/ + "sphinx-design", # https://sphinx-design.readthedocs.io/ + "myst-parser", # https://myst-parser.readthedocs.io/ + "linkify-it-py", # https://github.com/tsutsu3/linkify-it-py ] testing = [ "gp-libs", diff --git a/uv.lock b/uv.lock index 379131738e..d937343d61 100644 --- a/uv.lock +++ b/uv.lock @@ -1287,6 +1287,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, ] +[[package]] +name = "sphinx-design" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, +] + [[package]] name = "sphinx-inline-tabs" version = "2025.12.21.14" @@ -1432,6 +1463,8 @@ dev = [ { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-copybutton" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, @@ -1454,6 +1487,8 @@ docs = [ { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-copybutton" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, @@ -1505,6 +1540,7 @@ dev = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-typehints" }, { name = "sphinx-copybutton" }, + { name = "sphinx-design" }, { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, @@ -1523,6 +1559,7 @@ docs = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-typehints" }, { name = "sphinx-copybutton" }, + { name = "sphinx-design" }, { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, From 07c0ff95056e70080d1d395832e7ca1d749ee1e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 17:50:05 -0500 Subject: [PATCH 60/89] docs(redesign): restructure documentation to CLI Frontend Skeleton pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The documentation mixed user-facing CLI docs with internal Python API reference at the same sidebar level, the landing page dumped the entire README (12 H1 headings), and contributor docs were scattered. This restructure follows the Python Documentation Skeletons spec where cli/ is the primary reference surface for a CLI package and the Python API is explicitly internal. what: Structure: - Move entire docs/api/ to docs/internals/api/ (Python API is internal for a CLI package) - Rename inner api/internals/ to api/_internal/ to avoid path stutter - Create topics/ directory with workflows, troubleshooting, library-vs-cli, and plugins (moved from plugins/) - Create project/ directory (contributing, code-style, releasing) - Move developing.md to project/contributing.md - Fold about.md (stale 2016 content) into topics/index.md as a brief tmuxinator/teamocil comparison note - Delete about.md New pages: - cli/exit-codes.md — exit codes for scripting and automation - cli/recipes.md — copy-pasteable command invocations - internals/index.md — explicit "not for end users" warning - internals/architecture.md — CLI dispatch flow diagram - topics/workflows.md — CI integration, scripting patterns - topics/troubleshooting.md — common shell/PATH/tmux issues - topics/library-vs-cli.md — when to use tmuxp CLI vs libtmux, concept mapping table, what the CLI can't express - project/code-style.md — ruff, mypy, NumPy docstrings - project/releasing.md — git tags, OIDC trusted publishing Landing page: - Compose standalone homepage (no README.md includes) - One-sentence intro, 3+2 grid cards, 3-command install, inline YAML example + tmuxp load command, demo GIF Section indexes: - cli/index.md: heading "CLI Reference", 2x3 card grid for key commands + exit-codes and recipes - topics/index.md: 2x2 card grid with comparison note - project/index.md: 2x2 card grid for contributor pages (3 items) - configuration/index.md: 1x3 card grid for reference subpages Navigation: - Sidebar primary: Quickstart, CLI Reference, Workspace files, Topics, Internals, Project, Changelog - Sidebar "More" caption: The Tao of tmux, Migration, Glossary - 35 redirects for all moved files (every individual api/ file covered) - README.md URLs updated to new doc structure paths, http → https Dependencies: - Add sphinx-design to docs and dev dependency groups - Annotate all doc dependencies with documentation site URLs conf.py: - Add sphinx_design extension - Add myst_heading_anchors = 4 --- README.md | 16 +-- docs/about.md | 104 ------------------ docs/cli/exit-codes.md | 30 +++++ docs/cli/index.md | 51 ++++++++- docs/cli/recipes.md | 77 +++++++++++++ docs/conf.py | 3 + docs/configuration/index.md | 25 ++++- docs/index.md | 100 ++++++++++++++--- .../api/_internal}/colors.md | 0 .../api/_internal}/config_reader.md | 0 .../api/_internal}/index.md | 4 +- .../api/_internal}/private_path.md | 0 .../api/_internal}/types.md | 0 docs/{ => internals}/api/cli/convert.md | 0 docs/{ => internals}/api/cli/debug_info.md | 0 docs/{ => internals}/api/cli/edit.md | 0 docs/{ => internals}/api/cli/freeze.md | 0 docs/{ => internals}/api/cli/import_config.md | 0 docs/{ => internals}/api/cli/index.md | 0 docs/{ => internals}/api/cli/load.md | 0 docs/{ => internals}/api/cli/ls.md | 0 docs/{ => internals}/api/cli/progress.md | 0 docs/{ => internals}/api/cli/search.md | 0 docs/{ => internals}/api/cli/shell.md | 0 docs/{ => internals}/api/cli/utils.md | 0 docs/{ => internals}/api/exc.md | 0 docs/{ => internals}/api/index.md | 2 +- docs/{ => internals}/api/log.md | 0 docs/{ => internals}/api/plugin.md | 0 docs/{ => internals}/api/shell.md | 0 docs/{ => internals}/api/types.md | 0 docs/{ => internals}/api/util.md | 0 docs/{ => internals}/api/workspace/builder.md | 0 .../api/workspace/constants.md | 0 docs/{ => internals}/api/workspace/finders.md | 0 docs/{ => internals}/api/workspace/freezer.md | 0 .../api/workspace/importers.md | 0 docs/{ => internals}/api/workspace/index.md | 0 docs/{ => internals}/api/workspace/loader.md | 0 .../api/workspace/validation.md | 0 docs/internals/architecture.md | 39 +++++++ docs/internals/index.md | 19 ++++ docs/project/code-style.md | 32 ++++++ .../contributing.md} | 0 docs/project/index.md | 36 ++++++ docs/project/releasing.md | 50 +++++++++ docs/redirects.txt | 35 ++++++ docs/topics/index.md | 52 +++++++++ docs/topics/library-vs-cli.md | 62 +++++++++++ docs/{plugins/index.md => topics/plugins.md} | 0 docs/topics/troubleshooting.md | 40 +++++++ docs/topics/workflows.md | 32 ++++++ 52 files changed, 674 insertions(+), 135 deletions(-) delete mode 100644 docs/about.md create mode 100644 docs/cli/exit-codes.md create mode 100644 docs/cli/recipes.md rename docs/{api/internals => internals/api/_internal}/colors.md (100%) rename docs/{api/internals => internals/api/_internal}/config_reader.md (100%) rename docs/{api/internals => internals/api/_internal}/index.md (90%) rename docs/{api/internals => internals/api/_internal}/private_path.md (100%) rename docs/{api/internals => internals/api/_internal}/types.md (100%) rename docs/{ => internals}/api/cli/convert.md (100%) rename docs/{ => internals}/api/cli/debug_info.md (100%) rename docs/{ => internals}/api/cli/edit.md (100%) rename docs/{ => internals}/api/cli/freeze.md (100%) rename docs/{ => internals}/api/cli/import_config.md (100%) rename docs/{ => internals}/api/cli/index.md (100%) rename docs/{ => internals}/api/cli/load.md (100%) rename docs/{ => internals}/api/cli/ls.md (100%) rename docs/{ => internals}/api/cli/progress.md (100%) rename docs/{ => internals}/api/cli/search.md (100%) rename docs/{ => internals}/api/cli/shell.md (100%) rename docs/{ => internals}/api/cli/utils.md (100%) rename docs/{ => internals}/api/exc.md (100%) rename docs/{ => internals}/api/index.md (94%) rename docs/{ => internals}/api/log.md (100%) rename docs/{ => internals}/api/plugin.md (100%) rename docs/{ => internals}/api/shell.md (100%) rename docs/{ => internals}/api/types.md (100%) rename docs/{ => internals}/api/util.md (100%) rename docs/{ => internals}/api/workspace/builder.md (100%) rename docs/{ => internals}/api/workspace/constants.md (100%) rename docs/{ => internals}/api/workspace/finders.md (100%) rename docs/{ => internals}/api/workspace/freezer.md (100%) rename docs/{ => internals}/api/workspace/importers.md (100%) rename docs/{ => internals}/api/workspace/index.md (100%) rename docs/{ => internals}/api/workspace/loader.md (100%) rename docs/{ => internals}/api/workspace/validation.md (100%) create mode 100644 docs/internals/architecture.md create mode 100644 docs/internals/index.md create mode 100644 docs/project/code-style.md rename docs/{developing.md => project/contributing.md} (100%) create mode 100644 docs/project/index.md create mode 100644 docs/project/releasing.md create mode 100644 docs/topics/index.md create mode 100644 docs/topics/library-vs-cli.md rename docs/{plugins/index.md => topics/plugins.md} (100%) create mode 100644 docs/topics/troubleshooting.md create mode 100644 docs/topics/workflows.md diff --git a/README.md b/README.md index c1a97d75a0..b53d3e100a 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,9 @@ Name a session: tmuxp load -s session_name ./mysession.yaml ``` -[simple](http://tmuxp.git-pull.com/examples.html#short-hand-inline) and +[simple](https://tmuxp.git-pull.com/configuration/examples.html#short-hand-inline-style) and [very -elaborate](http://tmuxp.git-pull.com/examples.html#super-advanced-dev-environment) +elaborate](https://tmuxp.git-pull.com/configuration/examples.html#super-advanced-dev-environment) config examples # User-level configurations @@ -204,7 +204,7 @@ the CLI docs. Run custom startup scripts (such as installing project dependencies) before loading tmux. See the -[before_script](http://tmuxp.git-pull.com/examples.html#bootstrap-project-before-launch) +[before_script](https://tmuxp.git-pull.com/configuration/examples.html#bootstrap-project-before-launch) example # Load in detached state @@ -247,7 +247,7 @@ $ tmuxp convert --yes filename # Plugin System tmuxp has a plugin system to allow for custom behavior. See more about -the [Plugin System](http://tmuxp.git-pull.com/plugin_system.html). +the [Plugin System](https://tmuxp.git-pull.com/topics/plugins.html). # Debugging Helpers @@ -272,13 +272,13 @@ environment: # Docs / Reading material -See the [Quickstart](http://tmuxp.git-pull.com/quickstart.html). +See the [Quickstart](https://tmuxp.git-pull.com/quickstart.html). -[Documentation](http://tmuxp.git-pull.com) homepage (also in +[Documentation](https://tmuxp.git-pull.com) homepage (also in [中文](http://tmuxp-zh.rtfd.org/)) Want to learn more about tmux itself? [Read The Tao of Tmux -online](http://tmuxp.git-pull.com/about_tmux.html). +online](https://tmuxp.git-pull.com/about_tmux.html). # Donations @@ -295,7 +295,7 @@ See donation options at . - python support: >= 3.10, pypy, pypy3 - Source: - Docs: -- API: +- API: - Changelog: - Issues: - Test Coverage: diff --git a/docs/about.md b/docs/about.md deleted file mode 100644 index 0d380728db..0000000000 --- a/docs/about.md +++ /dev/null @@ -1,104 +0,0 @@ -```{module} tmuxp - -``` - -(about)= - -# About - -tmuxp helps you manage tmux workspaces. - -Built on an object relational mapper for tmux. tmux users can reload common -workspaces from YAML, JSON and {py:obj}`dict` workspace files like -[tmuxinator] and [teamocil]. - -tmuxp is used by developers for tmux automation at great companies like -[Bugsnag], [Pragmatic Coders] and many others. - -To jump right in, see {ref}`quickstart` and {ref}`examples`. - -Interested in some kung-fu or joining the effort? {ref}`api` and -{ref}`developing`. - -[MIT-licensed]. Code on [github](http://github.com/tmux-python/tmuxp). - -[bugsnag]: https://blog.bugsnag.com/benefits-of-using-tmux/ -[pragmatic coders]: http://pragmaticcoders.com/blog/tmuxp-preconfigured-sessions/ - -## Compared to tmuxinator / teamocil - -### Similarities - -**Load sessions** Loads tmux sessions from config - -**YAML** Supports YAML format - -**Inlining / shorthand configuration** All three support short-hand and -simplified markup for panes that have one command. - -**Maturity and stability** As of 2016, all three are considered stable, -well tested and adopted. - -### Missing - -**Version support** tmuxp only supports `tmux >= 3.2`. Teamocil and -tmuxinator may have support for earlier versions. - -### Differences - -**Programming Language** python. teamocil and tmuxinator use ruby. - -**Workspace building process** teamocil and tmuxinator process configs -directly shell commands. tmuxp processes configuration via ORM layer. - -## Additional Features - -**CLI** tmuxp's CLI can attach and kill sessions with tab-completion -support. See {ref}`commands`. - -**Import config** import configs from Teamocil / Tmuxinator [^id4]. See -{ref}`cli-import`. - -**Session freezing** Supports session freezing into YAML and JSON -format [^id4]. See {ref}`cli-freeze`. - -**JSON config** JSON config support. See {ref}`Examples`. - -**ORM-based API** via [libtmux] - Utilizes tmux's unique IDs for -panes, windows and sessions to create an object relational view of the tmux -{class}`~libtmux.Server`, its {class}`~libtmux.Session`, -{class}`~libtmux.Window`, and {class}`~libtmux.Pane`. -See {ref}`libtmux's internals `. - -**Conversion** `$ tmuxp convert ` can convert files to and -from JSON and YAML. - -[^id4]: On freezing - - While freezing and importing sessions is a great way to save time, - tweaking will probably be required - There is no substitute to a - config made with love. - -[libtmux]: https://libtmux.git-pull.com - -## Minor tweaks - -- Unit tests against live tmux version to test statefulness of tmux - sessions, windows and panes. See {ref}`gh-actions`. -- Load + switch to new session from inside tmux. -- Resume session if config loaded. -- Pre-commands virtualenv / rvm / any other commands. -- Load config from anywhere `$ tmuxp load /full/file/path.json`. -- Load config `.tmuxp.yaml` or `.tmuxp.json` from current working - directory with `$ tmuxp load .`. -- `$ tmuxp -2`, `$ tmuxp -8` for forcing term colors a la - {term}`tmux(1)`. -- `$ tmuxp -L`, `$ tmuxp -S` for sockets and - `$ tmuxp -f ` for config file. - -[attempt at 1.7 test]: https://travis-ci.org/tmux-python/tmuxp/jobs/12348263 -[mit-licensed]: http://opensource.org/licenses/MIT -[tmuxinator]: https://github.com/aziz/tmuxinator -[teamocil]: https://github.com/remiprev/teamocil -[erb]: http://ruby-doc.org/stdlib-2.0.0/libdoc/erb/rdoc/ERB.html -[edit this page]: https://github.com/tmux-python/tmuxp/edit/master/doc/about.rst diff --git a/docs/cli/exit-codes.md b/docs/cli/exit-codes.md new file mode 100644 index 0000000000..e8a80e892d --- /dev/null +++ b/docs/cli/exit-codes.md @@ -0,0 +1,30 @@ +(cli-exit-codes)= + +# Exit Codes + +tmuxp uses standard exit codes for scripting and automation. + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | General error (config validation, tmux command failure) | +| `2` | Usage error (invalid arguments, missing required options) | + +## Usage in Scripts + +```bash +#!/bin/bash +tmuxp load my-workspace.yaml +if [ $? -ne 0 ]; then + echo "Failed to load workspace" + exit 1 +fi +``` + +```bash +#!/bin/bash +tmuxp load -d my-workspace.yaml || { + echo "tmuxp failed with exit code $?" + exit 1 +} +``` diff --git a/docs/cli/index.md b/docs/cli/index.md index cb8bf94b3b..c1c6b605fc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -2,7 +2,48 @@ (commands)= -# Commands +# CLI Reference + +::::{grid} 2 +:gutter: 3 + +:::{grid-item-card} tmuxp load +:link: load +:link-type: doc +Load tmux sessions from workspace configs. +::: + +:::{grid-item-card} tmuxp shell +:link: shell +:link-type: doc +Interactive Python shell with tmux context. +::: + +:::{grid-item-card} tmuxp freeze +:link: freeze +:link-type: doc +Export running sessions to config files. +::: + +:::{grid-item-card} tmuxp convert +:link: convert +:link-type: doc +Convert between YAML and JSON formats. +::: + +:::{grid-item-card} Exit Codes +:link: exit-codes +:link-type: doc +Exit codes for scripting and automation. +::: + +:::{grid-item-card} Recipes +:link: recipes +:link-type: doc +Copy-pasteable command invocations. +::: + +:::: ```{toctree} :caption: General commands @@ -38,6 +79,14 @@ debug-info completion ``` +```{toctree} +:caption: Reference +:maxdepth: 1 + +exit-codes +recipes +``` + (cli-main)= (tmuxp-main)= diff --git a/docs/cli/recipes.md b/docs/cli/recipes.md new file mode 100644 index 0000000000..fdbdd7dad9 --- /dev/null +++ b/docs/cli/recipes.md @@ -0,0 +1,77 @@ +(cli-recipes)= + +# Recipes + +Copy-pasteable command invocations for common tasks. + +## Load a workspace + +```console +$ tmuxp load my-workspace.yaml +``` + +## Load in detached mode + +```console +$ tmuxp load -d my-workspace.yaml +``` + +## Load from a project directory + +```console +$ tmuxp load . +``` + +## Freeze a running session + +```console +$ tmuxp freeze my-session +``` + +## Convert YAML to JSON + +```console +$ tmuxp convert my-workspace.yaml +``` + +## Convert JSON to YAML + +```console +$ tmuxp convert my-workspace.json +``` + +## List available workspaces + +```console +$ tmuxp ls +``` + +## Search workspaces + +```console +$ tmuxp search my-project +``` + +## Edit a workspace config + +```console +$ tmuxp edit my-workspace +``` + +## Collect debug info + +```console +$ tmuxp debug-info +``` + +## Shell with tmux context + +```console +$ tmuxp shell +``` + +Access libtmux objects directly: + +```console +$ tmuxp shell --best --command 'print(server.sessions)' +``` diff --git a/docs/conf.py b/docs/conf.py index 478e5c1f01..3d77fc8993 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -44,6 +44,7 @@ "sphinxext.rediraffe", "myst_parser", "linkify_issues", + "sphinx_design", ] myst_enable_extensions = [ @@ -54,6 +55,8 @@ "linkify", ] +myst_heading_anchors = 4 + templates_path = ["_templates"] source_suffix = {".rst": "restructuredtext", ".md": "markdown"} diff --git a/docs/configuration/index.md b/docs/configuration/index.md index aa59b017ee..89b37b2a34 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -167,10 +167,33 @@ $ tmuxp load /opt/myapp ## Reference and usage +::::{grid} 3 +:gutter: 3 + +:::{grid-item-card} Top-level Options +:link: top-level +:link-type: doc +Session and window configuration keys. +::: + +:::{grid-item-card} Environment Variables +:link: environmental-variables +:link-type: doc +TMUXP_CONFIGDIR and other env vars. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +Sample workspace configurations. +::: + +:::: + ```{toctree} +:hidden: top-level environmental-variables examples - ``` diff --git a/docs/index.md b/docs/index.md index fd8a21575f..9d8d12db2b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,81 @@ (index)= -```{include} ../README.md -:end-before: ``` diff --git a/docs/api/internals/colors.md b/docs/internals/api/_internal/colors.md similarity index 100% rename from docs/api/internals/colors.md rename to docs/internals/api/_internal/colors.md diff --git a/docs/api/internals/config_reader.md b/docs/internals/api/_internal/config_reader.md similarity index 100% rename from docs/api/internals/config_reader.md rename to docs/internals/api/_internal/config_reader.md diff --git a/docs/api/internals/index.md b/docs/internals/api/_internal/index.md similarity index 90% rename from docs/api/internals/index.md rename to docs/internals/api/_internal/index.md index b96fc8657b..391a80b60f 100644 --- a/docs/api/internals/index.md +++ b/docs/internals/api/_internal/index.md @@ -1,6 +1,6 @@ -(internals)= +(api-internal)= -# Internals +# Internal Modules :::{warning} Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! diff --git a/docs/api/internals/private_path.md b/docs/internals/api/_internal/private_path.md similarity index 100% rename from docs/api/internals/private_path.md rename to docs/internals/api/_internal/private_path.md diff --git a/docs/api/internals/types.md b/docs/internals/api/_internal/types.md similarity index 100% rename from docs/api/internals/types.md rename to docs/internals/api/_internal/types.md diff --git a/docs/api/cli/convert.md b/docs/internals/api/cli/convert.md similarity index 100% rename from docs/api/cli/convert.md rename to docs/internals/api/cli/convert.md diff --git a/docs/api/cli/debug_info.md b/docs/internals/api/cli/debug_info.md similarity index 100% rename from docs/api/cli/debug_info.md rename to docs/internals/api/cli/debug_info.md diff --git a/docs/api/cli/edit.md b/docs/internals/api/cli/edit.md similarity index 100% rename from docs/api/cli/edit.md rename to docs/internals/api/cli/edit.md diff --git a/docs/api/cli/freeze.md b/docs/internals/api/cli/freeze.md similarity index 100% rename from docs/api/cli/freeze.md rename to docs/internals/api/cli/freeze.md diff --git a/docs/api/cli/import_config.md b/docs/internals/api/cli/import_config.md similarity index 100% rename from docs/api/cli/import_config.md rename to docs/internals/api/cli/import_config.md diff --git a/docs/api/cli/index.md b/docs/internals/api/cli/index.md similarity index 100% rename from docs/api/cli/index.md rename to docs/internals/api/cli/index.md diff --git a/docs/api/cli/load.md b/docs/internals/api/cli/load.md similarity index 100% rename from docs/api/cli/load.md rename to docs/internals/api/cli/load.md diff --git a/docs/api/cli/ls.md b/docs/internals/api/cli/ls.md similarity index 100% rename from docs/api/cli/ls.md rename to docs/internals/api/cli/ls.md diff --git a/docs/api/cli/progress.md b/docs/internals/api/cli/progress.md similarity index 100% rename from docs/api/cli/progress.md rename to docs/internals/api/cli/progress.md diff --git a/docs/api/cli/search.md b/docs/internals/api/cli/search.md similarity index 100% rename from docs/api/cli/search.md rename to docs/internals/api/cli/search.md diff --git a/docs/api/cli/shell.md b/docs/internals/api/cli/shell.md similarity index 100% rename from docs/api/cli/shell.md rename to docs/internals/api/cli/shell.md diff --git a/docs/api/cli/utils.md b/docs/internals/api/cli/utils.md similarity index 100% rename from docs/api/cli/utils.md rename to docs/internals/api/cli/utils.md diff --git a/docs/api/exc.md b/docs/internals/api/exc.md similarity index 100% rename from docs/api/exc.md rename to docs/internals/api/exc.md diff --git a/docs/api/index.md b/docs/internals/api/index.md similarity index 94% rename from docs/api/index.md rename to docs/internals/api/index.md index 89ec3d508b..2debbe3f26 100644 --- a/docs/api/index.md +++ b/docs/internals/api/index.md @@ -8,7 +8,7 @@ tmux via python API calls. ::: ```{toctree} -internals/index +_internal/index cli/index workspace/index exc diff --git a/docs/api/log.md b/docs/internals/api/log.md similarity index 100% rename from docs/api/log.md rename to docs/internals/api/log.md diff --git a/docs/api/plugin.md b/docs/internals/api/plugin.md similarity index 100% rename from docs/api/plugin.md rename to docs/internals/api/plugin.md diff --git a/docs/api/shell.md b/docs/internals/api/shell.md similarity index 100% rename from docs/api/shell.md rename to docs/internals/api/shell.md diff --git a/docs/api/types.md b/docs/internals/api/types.md similarity index 100% rename from docs/api/types.md rename to docs/internals/api/types.md diff --git a/docs/api/util.md b/docs/internals/api/util.md similarity index 100% rename from docs/api/util.md rename to docs/internals/api/util.md diff --git a/docs/api/workspace/builder.md b/docs/internals/api/workspace/builder.md similarity index 100% rename from docs/api/workspace/builder.md rename to docs/internals/api/workspace/builder.md diff --git a/docs/api/workspace/constants.md b/docs/internals/api/workspace/constants.md similarity index 100% rename from docs/api/workspace/constants.md rename to docs/internals/api/workspace/constants.md diff --git a/docs/api/workspace/finders.md b/docs/internals/api/workspace/finders.md similarity index 100% rename from docs/api/workspace/finders.md rename to docs/internals/api/workspace/finders.md diff --git a/docs/api/workspace/freezer.md b/docs/internals/api/workspace/freezer.md similarity index 100% rename from docs/api/workspace/freezer.md rename to docs/internals/api/workspace/freezer.md diff --git a/docs/api/workspace/importers.md b/docs/internals/api/workspace/importers.md similarity index 100% rename from docs/api/workspace/importers.md rename to docs/internals/api/workspace/importers.md diff --git a/docs/api/workspace/index.md b/docs/internals/api/workspace/index.md similarity index 100% rename from docs/api/workspace/index.md rename to docs/internals/api/workspace/index.md diff --git a/docs/api/workspace/loader.md b/docs/internals/api/workspace/loader.md similarity index 100% rename from docs/api/workspace/loader.md rename to docs/internals/api/workspace/loader.md diff --git a/docs/api/workspace/validation.md b/docs/internals/api/workspace/validation.md similarity index 100% rename from docs/api/workspace/validation.md rename to docs/internals/api/workspace/validation.md diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md new file mode 100644 index 0000000000..7a104dbfea --- /dev/null +++ b/docs/internals/architecture.md @@ -0,0 +1,39 @@ +# Architecture + +How the tmuxp CLI dispatches commands to the underlying library. + +## Request Flow + +``` +tmuxp CLI (argparse) + │ + ├── tmuxp load ──→ workspace.loader ──→ workspace.builder ──→ libtmux + ├── tmuxp freeze ──→ workspace.freezer ──→ libtmux + ├── tmuxp convert ──→ _internal.config_reader + ├── tmuxp shell ──→ libtmux (interactive) + └── tmuxp ls/search ──→ workspace.finders +``` + +## Key Components + +### CLI Layer (`tmuxp.cli`) + +The CLI uses Python's `argparse` with a custom formatter ({mod}`tmuxp.cli._formatter`). +Each subcommand lives in its own module under `tmuxp.cli`. + +The entry point is `tmuxp.cli.cli()`, registered as a console script in `pyproject.toml`. + +### Workspace Layer (`tmuxp.workspace`) + +The workspace layer handles configuration lifecycle: + +1. **Finding**: {mod}`tmuxp.workspace.finders` locates config files +2. **Loading**: {mod}`tmuxp.workspace.loader` reads and validates configs +3. **Building**: {mod}`tmuxp.workspace.builder` creates tmux sessions via libtmux +4. **Freezing**: {mod}`tmuxp.workspace.freezer` exports running sessions + +### Library Layer (libtmux) + +tmuxp delegates all tmux operations to [libtmux](https://libtmux.git-pull.com/). +The `WorkspaceBuilder` creates libtmux `Server`, `Session`, `Window`, and `Pane` +objects to construct the requested workspace. diff --git a/docs/internals/index.md b/docs/internals/index.md new file mode 100644 index 0000000000..9db3cf96a3 --- /dev/null +++ b/docs/internals/index.md @@ -0,0 +1,19 @@ +(internals)= + +# Internals + +```{warning} +Everything in this section is **internal implementation detail**. There is +no stability guarantee. Interfaces may change or be removed without notice +between any release. + +If you are building an application with tmuxp, use the [CLI](../cli/index.md) +or refer to the [libtmux API](https://libtmux.git-pull.com/api/). +``` + +```{toctree} +:maxdepth: 2 + +architecture +api/index +``` diff --git a/docs/project/code-style.md b/docs/project/code-style.md new file mode 100644 index 0000000000..1ea71dc8c3 --- /dev/null +++ b/docs/project/code-style.md @@ -0,0 +1,32 @@ +# Code Style + +## Formatting + +tmuxp uses [ruff](https://github.com/astral-sh/ruff) for both linting and formatting. + +```console +$ uv run ruff format . +``` + +```console +$ uv run ruff check . --fix --show-fixes +``` + +## Type Checking + +Strict [mypy](https://mypy-lang.org/) is enforced. + +```console +$ uv run mypy +``` + +## Docstrings + +All public functions and methods use NumPy-style docstrings. + +## Imports + +- Standard library: namespace imports (`import pathlib`, not `from pathlib import Path`) + - Exception: `from dataclasses import dataclass, field` +- Typing: `import typing as t`, access via `t.Optional`, `t.NamedTuple`, etc. +- All files: `from __future__ import annotations` diff --git a/docs/developing.md b/docs/project/contributing.md similarity index 100% rename from docs/developing.md rename to docs/project/contributing.md diff --git a/docs/project/index.md b/docs/project/index.md new file mode 100644 index 0000000000..8c37a15d84 --- /dev/null +++ b/docs/project/index.md @@ -0,0 +1,36 @@ +(project)= + +# Project + +Information for contributors and maintainers. + +::::{grid} 2 +:gutter: 3 + +:::{grid-item-card} Contributing +:link: contributing +:link-type: doc +Development setup, running tests, submitting PRs. +::: + +:::{grid-item-card} Code Style +:link: code-style +:link-type: doc +Ruff, mypy, NumPy docstrings, import conventions. +::: + +:::{grid-item-card} Releasing +:link: releasing +:link-type: doc +Release checklist and version policy. +::: + +:::: + +```{toctree} +:hidden: + +contributing +code-style +releasing +``` diff --git a/docs/project/releasing.md b/docs/project/releasing.md new file mode 100644 index 0000000000..37b31aa3a7 --- /dev/null +++ b/docs/project/releasing.md @@ -0,0 +1,50 @@ +# Releasing + +## Release Process + +Releases are triggered by git tags and published to PyPI via OIDC trusted publishing. + +1. Update `CHANGES` with the release notes + +2. Bump version in `src/tmuxp/__about__.py` + +3. Commit: + + ```console + $ git commit -m "tmuxp " + ``` + +4. Tag: + + ```console + $ git tag v + ``` + +5. Push: + + ```console + $ git push && git push --tags + ``` + +6. CI builds and publishes to PyPI automatically via trusted publishing + +## Changelog Format + +The `CHANGES` file uses this format: + +```text +tmuxp () +------------------------ + +### What's new + +- Description of feature (#issue) + +### Bug fixes + +- Description of fix (#issue) + +### Breaking changes + +- Description of break, migration path (#issue) +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index 66d2cb860b..b22b99cfd9 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -3,3 +3,38 @@ "examples.md" "configuration/examples.md" "plugin_system.md" "plugins/index.md" "commands/index.md" "cli/index.md" +"api/index.md" "internals/api/index.md" +"api/exc.md" "internals/api/exc.md" +"api/log.md" "internals/api/log.md" +"api/plugin.md" "internals/api/plugin.md" +"api/shell.md" "internals/api/shell.md" +"api/types.md" "internals/api/types.md" +"api/util.md" "internals/api/util.md" +"api/cli/index.md" "internals/api/cli/index.md" +"api/cli/convert.md" "internals/api/cli/convert.md" +"api/cli/debug_info.md" "internals/api/cli/debug_info.md" +"api/cli/edit.md" "internals/api/cli/edit.md" +"api/cli/freeze.md" "internals/api/cli/freeze.md" +"api/cli/import_config.md" "internals/api/cli/import_config.md" +"api/cli/load.md" "internals/api/cli/load.md" +"api/cli/ls.md" "internals/api/cli/ls.md" +"api/cli/progress.md" "internals/api/cli/progress.md" +"api/cli/search.md" "internals/api/cli/search.md" +"api/cli/shell.md" "internals/api/cli/shell.md" +"api/cli/utils.md" "internals/api/cli/utils.md" +"api/workspace/index.md" "internals/api/workspace/index.md" +"api/workspace/builder.md" "internals/api/workspace/builder.md" +"api/workspace/constants.md" "internals/api/workspace/constants.md" +"api/workspace/finders.md" "internals/api/workspace/finders.md" +"api/workspace/freezer.md" "internals/api/workspace/freezer.md" +"api/workspace/importers.md" "internals/api/workspace/importers.md" +"api/workspace/loader.md" "internals/api/workspace/loader.md" +"api/workspace/validation.md" "internals/api/workspace/validation.md" +"api/internals/index.md" "internals/api/_internal/index.md" +"api/internals/colors.md" "internals/api/_internal/colors.md" +"api/internals/config_reader.md" "internals/api/_internal/config_reader.md" +"api/internals/private_path.md" "internals/api/_internal/private_path.md" +"api/internals/types.md" "internals/api/_internal/types.md" +"plugins/index.md" "topics/plugins.md" +"developing.md" "project/contributing.md" +"about.md" "topics/index.md" diff --git a/docs/topics/index.md b/docs/topics/index.md new file mode 100644 index 0000000000..a7be5c5792 --- /dev/null +++ b/docs/topics/index.md @@ -0,0 +1,52 @@ +(topics)= + +# Topics + +Conceptual guides and workflow documentation. + +::::{grid} 2 +:gutter: 3 + +:::{grid-item-card} Workflows +:link: workflows +:link-type: doc +CI integration, scripting, and automation patterns. +::: + +:::{grid-item-card} Plugins +:link: plugins +:link-type: doc +Plugin system for custom behavior. +::: + +:::{grid-item-card} Library vs CLI +:link: library-vs-cli +:link-type: doc +When to use tmuxp CLI vs libtmux directly. +::: + +:::{grid-item-card} Troubleshooting +:link: troubleshooting +:link-type: doc +Common shell, PATH, and tmux issues. +::: + +:::: + +## Compared to tmuxinator / teamocil + +tmuxp, [tmuxinator](https://github.com/aziz/tmuxinator), and +[teamocil](https://github.com/remiprev/teamocil) all load tmux sessions +from config files. Key differences: tmuxp is Python (not Ruby), builds +sessions through [libtmux](https://libtmux.git-pull.com/)'s ORM layer +instead of raw shell commands, supports JSON and YAML, and can +[freeze](../cli/freeze.md) running sessions back to config. + +```{toctree} +:hidden: + +workflows +plugins +library-vs-cli +troubleshooting +``` diff --git a/docs/topics/library-vs-cli.md b/docs/topics/library-vs-cli.md new file mode 100644 index 0000000000..8c431caa3d --- /dev/null +++ b/docs/topics/library-vs-cli.md @@ -0,0 +1,62 @@ +# Library vs CLI + +tmuxp is a CLI tool. [libtmux](https://libtmux.git-pull.com/) is the Python library it's built on. Both control tmux, but they serve different needs. + +## When to Use the CLI + +Use `tmuxp` when: + +- You want **declarative workspace configs** — define your layout in YAML, load it with one command +- You're setting up **daily development environments** — same windows, same panes, every time +- You need **CI/CD tmux sessions** — `tmuxp load -d` in a script +- You prefer **configuration over code** — no Python needed + +```console +$ tmuxp load my-workspace.yaml +``` + +## When to Use libtmux + +Use [libtmux](https://libtmux.git-pull.com/) directly when: + +- You need **dynamic logic** — conditionals, loops, branching based on state +- You want to **read pane output** — capture what's on screen and react to it +- You're **testing** tmux interactions — libtmux provides pytest fixtures +- You need **multi-server orchestration** — manage multiple tmux servers programmatically +- The CLI's config format **can't express** what you need + +```python +import libtmux + +server = libtmux.Server() +session = server.new_session("my-project") +window = session.new_window("editor") +pane = window.split() +pane.send_keys("vim .") +``` + +## Concept Mapping + +How tmuxp config keys map to libtmux API calls: + +| tmuxp YAML | libtmux equivalent | +|------------|-------------------| +| `session_name: foo` | `server.new_session(session_name="foo")` | +| `windows:` | `session.new_window(...)` | +| `panes:` | `window.split(...)` | +| `shell_command:` | `pane.send_keys(...)` | +| `layout: main-vertical` | `window.select_layout("main-vertical")` | +| `start_directory: ~/project` | `session.new_window(start_directory="~/project")` | +| `before_script:` | Run via `subprocess` before building | + +## What the CLI Can't Express + +tmuxp configs are static declarations. They can't: + +- **Branch on conditions** — "only create this pane if a file exists" +- **Read pane output** — "wait until the server is ready, then open the browser" +- **React to state** — "if this session already has 3 windows, add a 4th" +- **Orchestrate across servers** — "connect to both local and remote tmux" +- **Build layouts dynamically** — "create N panes based on a list of services" + +For these, use libtmux directly. See the [libtmux quickstart](https://libtmux.git-pull.com/quickstart.html). diff --git a/docs/plugins/index.md b/docs/topics/plugins.md similarity index 100% rename from docs/plugins/index.md rename to docs/topics/plugins.md diff --git a/docs/topics/troubleshooting.md b/docs/topics/troubleshooting.md new file mode 100644 index 0000000000..bfacad2c34 --- /dev/null +++ b/docs/topics/troubleshooting.md @@ -0,0 +1,40 @@ +# Troubleshooting + +## tmuxp command not found + +Ensure tmuxp is installed and on your `PATH`: + +```console +$ which tmuxp +``` + +If installed with `pip install --user`, ensure `~/.local/bin` is in your `PATH`. + +## tmux server not found + +tmuxp requires a running tmux server or will start one automatically. +Ensure tmux is installed: + +```console +$ tmux -V +``` + +Minimum required version: tmux 3.2a. + +## Configuration errors + +Use `tmuxp debug-info` to collect system information for bug reports: + +```console +$ tmuxp debug-info +``` + +## Session already exists + +If a session with the same name already exists, tmuxp will prompt you. +Use `tmuxp load -d` to load in detached mode alongside existing sessions. + +## Shell completion not working + +See [Shell Completion](../cli/completion.md) for setup instructions +for bash, zsh, and fish. diff --git a/docs/topics/workflows.md b/docs/topics/workflows.md new file mode 100644 index 0000000000..5965653c0d --- /dev/null +++ b/docs/topics/workflows.md @@ -0,0 +1,32 @@ +# Workflows + +## CI Integration + +tmuxp can set up tmux sessions in CI pipelines for integration testing: + +```console +$ tmuxp load -d my-workspace.yaml +``` + +The `-d` flag loads the session in detached mode, useful for headless environments. + +## Scripting + +tmuxp's exit codes enable scripting and error handling. See +[Exit Codes](../cli/exit-codes.md) for the complete list. + +## Automating Development Environments + +Use tmuxp to codify your development environment: + +1. Set up your ideal tmux layout manually +2. Freeze it: `tmuxp freeze my-session` +3. Edit the generated YAML to add commands +4. Load it on any machine: `tmuxp load my-workspace.yaml` + +## User-Level Configuration + +Workspace configs can be stored in: +- `~/.tmuxp/` (legacy) +- `~/.config/tmuxp/` (XDG default) +- Project-local `.tmuxp.yaml` or `.tmuxp/` directory From 475d049b76ff18e269600d16e7089a74d6034a6f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 18:09:22 -0500 Subject: [PATCH 61/89] fix(docs[spa-nav]): always navigate on popstate to fix back-button content update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The popstate handler guarded on `e.state && e.state.spa`, but browsers can deliver null state for the initial history entry even after replaceState({spa: true}). This caused three symptoms: - Back button changed URL but didn't update page content - Sidebar links resolved wrong after back-navigating across depths - Brand logo href went stale after SPA navigation what: - Remove state guard from popstate handler — always call navigate() - Safe: navigate() falls back to full page load on error, and isPop=true prevents pushing duplicate history entries --- docs/_static/js/spa-nav.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js index e00e521ab8..c26ba23171 100644 --- a/docs/_static/js/spa-nav.js +++ b/docs/_static/js/spa-nav.js @@ -203,8 +203,8 @@ history.replaceState({ spa: true }, ""); - window.addEventListener("popstate", function (e) { - if (e.state && e.state.spa) navigate(location.href, true); + window.addEventListener("popstate", function () { + navigate(location.href, true); }); // --- Hover prefetch --- From 877c9aed2983e908b0f5da87f57f9a249adffd6d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 19:06:38 -0500 Subject: [PATCH 62/89] fix(docs[spa-nav]): update brand links and logo srcs after SPA navigation why: The sidebar brand link, mobile header brand link, and logo image srcs use relative paths that go stale after SPA navigation across directory depths. They live outside the swap regions (.article-container, .sidebar-tree, .toc-drawer) so swap() never updates them. what: - After swapping content regions, copy brand href and logo src from the fetched document (which has correct relative paths for the target) - Covers: .sidebar-brand href, .header-center a href, .sidebar-logo src --- docs/_static/js/spa-nav.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js index c26ba23171..cd99233fb7 100644 --- a/docs/_static/js/spa-nav.js +++ b/docs/_static/js/spa-nav.js @@ -132,6 +132,24 @@ ); var title = doc.querySelector("title"); if (title) document.title = title.textContent || ""; + + // Brand links and logo images live outside swapped regions. + // Their relative hrefs/srcs go stale after cross-depth navigation. + // Copy the correct values from the fetched document. + [".sidebar-brand", ".header-center a"].forEach(function (sel) { + var fresh = doc.querySelector(sel); + if (!fresh) return; + document.querySelectorAll(sel).forEach(function (el) { + el.setAttribute("href", fresh.getAttribute("href")); + }); + }); + var freshLogos = doc.querySelectorAll(".sidebar-logo"); + var staleLogos = document.querySelectorAll(".sidebar-logo"); + freshLogos.forEach(function (fresh, i) { + if (staleLogos[i]) { + staleLogos[i].setAttribute("src", fresh.getAttribute("src")); + } + }); } function reinit() { From 03a559bf0963b39fe59150914276a06c9e4b32a7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 19:23:08 -0500 Subject: [PATCH 63/89] docs(build): switch from html to dirhtml builder for clean URLs why: The html builder generates relative paths like ../index.html and # that go stale after SPA navigation across directory depths. The dirhtml builder generates clean URLs natively (/api/ instead of /api/index.html). what: - Change html, start, and design recipes to use -b dirhtml - sphinx-autobuild serves dirhtml correctly (StaticFiles html=True) - SPA nav already handles / endings (shouldIntercept checks endsWith("/")) - rediraffe supports dirhtml natively (DirectoryHTMLBuilder detection) - No changes needed to redirects.txt, conf.py, or documentation source --- docs/justfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/justfile b/docs/justfile index 171766555a..afeec6b509 100644 --- a/docs/justfile +++ b/docs/justfile @@ -27,7 +27,7 @@ default: # Build HTML documentation [group: 'build'] html: - {{ sphinxbuild }} -b html {{ allsphinxopts }} {{ builddir }}/html + {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/html @echo "" @echo "Build finished. The HTML pages are in {{ builddir }}/html." @@ -202,9 +202,9 @@ dev: # Start sphinx-autobuild server [group: 'dev'] start: - uv run sphinx-autobuild "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} + uv run sphinx-autobuild -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} # Design mode: watch static files and disable incremental builds [group: 'dev'] design: - uv run sphinx-autobuild "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} --watch "." -a + uv run sphinx-autobuild -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} --watch "." -a From 6745bd6b69ed245f6d6d9b9a60bb31e7c3ffc298 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 19:28:25 -0500 Subject: [PATCH 64/89] docs(readme): update links to clean URLs for dirhtml builder why: Switched to dirhtml builder which generates clean URLs (/quickstart/ instead of /quickstart.html). README links need to match since they render on GitHub/PyPI where redirects don't apply. what: - Convert all .html doc links to trailing-slash format - Covers: cli/, configuration/, topics/, quickstart, history, about_tmux --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b53d3e100a..359ae17202 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,9 @@ Name a session: tmuxp load -s session_name ./mysession.yaml ``` -[simple](https://tmuxp.git-pull.com/configuration/examples.html#short-hand-inline-style) and +[simple](https://tmuxp.git-pull.com/configuration/examples/#short-hand-inline-style) and [very -elaborate](https://tmuxp.git-pull.com/configuration/examples.html#super-advanced-dev-environment) +elaborate](https://tmuxp.git-pull.com/configuration/examples/#super-advanced-dev-environment) config examples # User-level configurations @@ -197,14 +197,14 @@ $ tmuxp shell -c 'print(window.name.upper())' MY_WINDOW ``` -Read more on [tmuxp shell](https://tmuxp.git-pull.com/cli/shell.html) in +Read more on [tmuxp shell](https://tmuxp.git-pull.com/cli/shell/) in the CLI docs. # Pre-load hook Run custom startup scripts (such as installing project dependencies) before loading tmux. See the -[before_script](https://tmuxp.git-pull.com/configuration/examples.html#bootstrap-project-before-launch) +[before_script](https://tmuxp.git-pull.com/configuration/examples/#bootstrap-project-before-launch) example # Load in detached state @@ -224,7 +224,7 @@ $ tmuxp freeze session-name ``` See more about [freezing -tmux](https://tmuxp.git-pull.com/cli/freeze.html) sessions. +tmux](https://tmuxp.git-pull.com/cli/freeze/) sessions. # Convert a session file @@ -247,7 +247,7 @@ $ tmuxp convert --yes filename # Plugin System tmuxp has a plugin system to allow for custom behavior. See more about -the [Plugin System](https://tmuxp.git-pull.com/topics/plugins.html). +the [Plugin System](https://tmuxp.git-pull.com/topics/plugins/). # Debugging Helpers @@ -272,13 +272,13 @@ environment: # Docs / Reading material -See the [Quickstart](https://tmuxp.git-pull.com/quickstart.html). +See the [Quickstart](https://tmuxp.git-pull.com/quickstart/). [Documentation](https://tmuxp.git-pull.com) homepage (also in [中文](http://tmuxp-zh.rtfd.org/)) Want to learn more about tmux itself? [Read The Tao of Tmux -online](https://tmuxp.git-pull.com/about_tmux.html). +online](https://tmuxp.git-pull.com/about_tmux/). # Donations @@ -295,8 +295,8 @@ See donation options at . - python support: >= 3.10, pypy, pypy3 - Source: - Docs: -- API: -- Changelog: +- API: +- Changelog: - Issues: - Test Coverage: - pypi: From 63edc1fad7b7b69924ff264945e65890ea2fab65 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 19:32:34 -0500 Subject: [PATCH 65/89] fix(docs[redirects]): remove dirhtml-conflicting redirect entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: With dirhtml builder, api.md and api/index.md both produce api/index.html. The redirect chain api.md → api/index.md → internals/api/index.md causes rediraffe to write two redirects at the same output path, failing the build. what: - Comment out api.md redirect (api/index.md redirect handles it) --- docs/redirects.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/redirects.txt b/docs/redirects.txt index b22b99cfd9..ba67fdc915 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -1,5 +1,5 @@ "cli.md" "commands/index.md" -"api.md" "api/index.md" +# "api.md" → api/index.md: not needed with dirhtml (same output path as api/index.md redirect) "examples.md" "configuration/examples.md" "plugin_system.md" "plugins/index.md" "commands/index.md" "cli/index.md" From b5b434c38c6eb0b9c13fb5428d62e0504f0d82b0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 19:38:45 -0500 Subject: [PATCH 66/89] fix(docs[conf]): suppress docutils duplicate label warnings for dirhtml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The dirhtml builder generates duplicate implicit labels across CLI pages — each argparse-generated page has "Usage", "Options", "Examples" headings that create colliding labels. These warnings cause the build to exit with code 1. what: - Add "docutils" to suppress_warnings in conf.py --- docs/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 3d77fc8993..4385b3323d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -128,6 +128,9 @@ # (types in TYPE_CHECKING blocks used for circular import avoidance) suppress_warnings = [ "sphinx_autodoc_typehints.forward_reference", + # dirhtml builder creates duplicate implicit labels across CLI pages + # (each argparse page generates "Usage", "Options", "Examples" headings) + "docutils", ] # sphinxext.opengraph From d288c8b2ea8592b8993c19f039bccda8c2426e30 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 19:51:32 -0500 Subject: [PATCH 67/89] fix(docs[redirects]): remove cli.md self-redirect for dirhtml, revert suppress_warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: cli.md → commands/index.md → cli/index.md chain produces same dirhtml output path (cli/index.html) as the real cli/index.md file. Rediraffe reports "already exists" and exits with code 1. what: - Comment out cli.md redirect (same dirhtml output as cli/index.md) - Revert docutils suppress_warnings (didn't help, wrong approach) --- docs/conf.py | 3 --- docs/redirects.txt | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4385b3323d..3d77fc8993 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -128,9 +128,6 @@ # (types in TYPE_CHECKING blocks used for circular import avoidance) suppress_warnings = [ "sphinx_autodoc_typehints.forward_reference", - # dirhtml builder creates duplicate implicit labels across CLI pages - # (each argparse page generates "Usage", "Options", "Examples" headings) - "docutils", ] # sphinxext.opengraph diff --git a/docs/redirects.txt b/docs/redirects.txt index ba67fdc915..be9e1bdd10 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -1,4 +1,4 @@ -"cli.md" "commands/index.md" +# "cli.md" → commands/index.md → cli/index.md: not needed with dirhtml (same output path) # "api.md" → api/index.md: not needed with dirhtml (same output path as api/index.md redirect) "examples.md" "configuration/examples.md" "plugin_system.md" "plugins/index.md" From c956490fbd8753c6f48e678e6dd4a1e326330660 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 23 Mar 2026 20:40:41 -0500 Subject: [PATCH 68/89] docs(grid): make grid cards responsive for phone and tablet why: Grid cards used single-value column counts ({grid} 2, {grid} 3) which don't collapse on small screens, squeezing content uncomfortably. what: - 2-col grids: {grid} 1 1 2 2 (stack on phone/tablet, 2-col on desktop) - 3-col grids: {grid} 1 2 3 3 (1-col phone, 2-col tablet, 3-col desktop) - Gutters: 2 2 3 3 (tighter spacing on mobile) - Uses sphinx-design's responsive breakpoint syntax (xs sm md lg) --- docs/cli/index.md | 4 ++-- docs/configuration/index.md | 4 ++-- docs/index.md | 8 ++++---- docs/project/index.md | 4 ++-- docs/topics/index.md | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index c1c6b605fc..fd38b681ea 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -4,8 +4,8 @@ # CLI Reference -::::{grid} 2 -:gutter: 3 +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 :::{grid-item-card} tmuxp load :link: load diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 89b37b2a34..166017226a 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -167,8 +167,8 @@ $ tmuxp load /opt/myapp ## Reference and usage -::::{grid} 3 -:gutter: 3 +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 :::{grid-item-card} Top-level Options :link: top-level diff --git a/docs/index.md b/docs/index.md index 9d8d12db2b..64308bd782 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,8 @@ Session manager for tmux. Load, freeze, and convert tmux sessions through YAML/JSON configuration files. Powered by [libtmux](https://libtmux.git-pull.com/). -::::{grid} 3 -:gutter: 3 +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 :::{grid-item-card} Quickstart :link: quickstart @@ -28,8 +28,8 @@ Config format, examples, and environment variables. :::: -::::{grid} 2 -:gutter: 3 +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 :::{grid-item-card} Topics :link: topics/index diff --git a/docs/project/index.md b/docs/project/index.md index 8c37a15d84..2b60711f5c 100644 --- a/docs/project/index.md +++ b/docs/project/index.md @@ -4,8 +4,8 @@ Information for contributors and maintainers. -::::{grid} 2 -:gutter: 3 +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 :::{grid-item-card} Contributing :link: contributing diff --git a/docs/topics/index.md b/docs/topics/index.md index a7be5c5792..050ddb9d58 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -4,8 +4,8 @@ Conceptual guides and workflow documentation. -::::{grid} 2 -:gutter: 3 +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 :::{grid-item-card} Workflows :link: workflows From 6dd0024f370d2e9ab5aff179b87f3e027c828ab0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 24 Mar 2026 03:57:46 -0500 Subject: [PATCH 69/89] docs(sections): add grid cards to configuration and internals index pages why: Configuration and Internals were the only section indexes without grid cards at the top. Configuration had cards at the bottom but not above the fold. All other sections (CLI, Topics, Project) have cards immediately after the heading. what: - configuration/index.md: add 1x3 responsive card grid at top linking to Top-level Options, Environment Variables, and Examples - internals/index.md: add 1x2 responsive card grid for Architecture and Python API, hide toctree for sidebar-only nav --- docs/configuration/index.md | 23 +++++++++++++++++++++++ docs/internals/index.md | 19 ++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 166017226a..619cb62710 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -6,6 +6,29 @@ # Workspace files +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} Top-level Options +:link: top-level +:link-type: doc +Session and window configuration keys. +::: + +:::{grid-item-card} Environment Variables +:link: environmental-variables +:link-type: doc +TMUXP_CONFIGDIR and other env vars. +::: + +:::{grid-item-card} Examples +:link: examples +:link-type: doc +Sample workspace configurations. +::: + +:::: + tmuxp loads your terminal workspace into tmux using workspace files. The workspace file can be JSON or YAML. It's declarative style resembles tmux's object hierarchy: session, window and panes. diff --git a/docs/internals/index.md b/docs/internals/index.md index 9db3cf96a3..82a8c121c0 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -11,8 +11,25 @@ If you are building an application with tmuxp, use the [CLI](../cli/index.md) or refer to the [libtmux API](https://libtmux.git-pull.com/api/). ``` +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Architecture +:link: architecture +:link-type: doc +How the CLI dispatches to the workspace builder and libtmux. +::: + +:::{grid-item-card} Python API +:link: api/index +:link-type: doc +Internal module reference for contributors and plugin authors. +::: + +:::: + ```{toctree} -:maxdepth: 2 +:hidden: architecture api/index From 520d4605f3a5192d93b139b3540e9ac63acaa1dd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 24 Mar 2026 04:03:04 -0500 Subject: [PATCH 70/89] fix(docs[aafig]): use builder target URI for image paths (dirhtml compat) why: The aafig extension computed image paths using the raw docname instead of the builder's target URI. With dirhtml, this produces ../_images/ instead of ../../_images/ because dirhtml outputs files one directory deeper (page/index.html vs page.html). what: - Change relative_uri(docname, "_images") to relative_uri(get_target_uri(docname), "_images") - Matches how Sphinx itself computes image paths (sphinx/builders/html/__init__.py:655) --- docs/_ext/aafig.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/_ext/aafig.py b/docs/_ext/aafig.py index 418dbbc028..6f90d51378 100644 --- a/docs/_ext/aafig.py +++ b/docs/_ext/aafig.py @@ -176,7 +176,8 @@ def render_aafigure( fname = "{}.{}".format(get_basename(text, options), options["format"]) if app.builder.format == "html": # HTML - imgpath = relative_uri(app.builder.env.docname, "_images") + target_uri = app.builder.get_target_uri(app.builder.env.docname) + imgpath = relative_uri(target_uri, "_images") relfn = posixpath.join(imgpath, fname) outfn = path.join(app.builder.outdir, "_images", fname) else: From 8b1e11e0835f19f0984b81190a4a95cd0a28d8b9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 04:40:02 -0500 Subject: [PATCH 71/89] py(deps[dev]) Bump dev packages --- uv.lock | 172 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/uv.lock b/uv.lock index d937343d61..2c031d824e 100644 --- a/uv.lock +++ b/uv.lock @@ -39,16 +39,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -933,16 +933,16 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1049,7 +1049,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1057,9 +1057,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] @@ -1085,27 +1085,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] [[package]] @@ -1413,15 +1413,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] @@ -1581,65 +1581,65 @@ testing = [ [[package]] name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "types-docutils" -version = "0.22.3.20260316" +version = "0.22.3.20260322" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/27/a7f16b3a2fad0a4ddd85a668319f9a1d0311c4bd9578894f6471c7e6c788/types_docutils-0.22.3.20260316.tar.gz", hash = "sha256:8ef27d565b9831ff094fe2eac75337a74151013e2d21ecabd445c2955f891564", size = 57263, upload-time = "2026-03-16T04:29:12.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/60/c1f22b7cfc4837d5419e5a2d8702c7d65f03343f866364b71cccd8a73b79/types_docutils-0.22.3.20260316-py3-none-any.whl", hash = "sha256:083c7091b8072c242998ec51da1bf1492f0332387da81c3b085efbf5ca754c7d", size = 91968, upload-time = "2026-03-16T04:29:11.114Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" }, ] [[package]] From 0413e08d564c6be5543e0621b603d124e98c59d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 04:44:21 -0500 Subject: [PATCH 72/89] .tool-versions(uv) uv 0.10.12 -> 0.11.2 See also: - uv: - https://github.com/astral-sh/uv/releases/tag/0.11.2 - https://github.com/astral-sh/uv/blob/0.11.2/CHANGELOG.md --- .tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index 9c32c2514e..1ced26b3e9 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ just 1.47 -uv 0.10.12 +uv 0.11.2 python 3.14 3.13 3.12 3.11 3.10 From 1ee42b0e232874df2eaac1fb43e94fca6faedc65 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 06:02:02 -0500 Subject: [PATCH 73/89] AGENTS(ai[rules]): Add git commit standards why: This repo documented testing and coding workflow but did not spell out the commit message structure used across recent history. Adding the commit guidance from libtmux gives agents an explicit what/why format to follow instead of inferring it from past commits. what: - Add a Git Commit Standards section to AGENTS.md - Document the Scope(type[detail]) subject pattern and common commit types - Include a what/why body template and heredoc example for multi-line commits --- AGENTS.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 7e230283db..0f87f4ce20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,55 @@ windows: - **Type imports**: Use `import typing as t` and access via namespace (e.g., `t.Optional`) - **Development workflow**: Format → Test → Commit → Lint/Type Check → Test → Final Commit +## Git Commit Standards + +Format commit messages as: +``` +Scope(type[detail]): concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic +``` + +Common commit types: +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting +- **py(deps)**: Dependencies +- **py(deps[dev])**: Dev Dependencies +- **ai(rules[AGENTS])**: AI rule updates +- **ai(claude[rules])**: Claude Code rules (CLAUDE.md) +- **ai(claude[command])**: Claude Code command changes + +Example: +``` +Pane(feat[send_keys]): Add support for literal flag + +why: Enable sending literal characters without tmux interpretation +what: +- Add literal parameter to send_keys method +- Update send_keys to pass -l flag when literal=True +- Add tests for literal key sending +``` +For multi-line commits, use heredoc to preserve formatting: +```bash +git commit -m "$(cat <<'EOF' +feat(Component[method]) add feature description + +why: Explanation of the change. +what: +- First change +- Second change +EOF +)" +``` + ## Logging Standards These rules guide future logging changes; existing code may not yet conform. From e1d43b7d1bf5396dacd5b32c18e745abae4c951d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 09:10:28 -0500 Subject: [PATCH 74/89] py(deps[dev]) Bump dev packages --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 2c031d824e..90e0fc9b54 100644 --- a/uv.lock +++ b/uv.lock @@ -906,11 +906,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] From 83265512bced6ef24732becd9b4682c99d319697 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 31 Mar 2026 17:23:14 -0500 Subject: [PATCH 75/89] docs(conf): add IBM Plex Mono weights 500/600/700 why: Only weight 400 was downloaded. Bold monospace text (badge labels at font-weight 650, in code) used browser faux-bolding which looks noticeably worse than the real typeface weights. what: - Match IBM Plex Sans weight range: [400, 500, 600, 700] --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 3d77fc8993..9539c52438 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -165,7 +165,7 @@ "family": "IBM Plex Mono", "package": "@fontsource/ibm-plex-mono", "version": "5.2.7", - "weights": [400], + "weights": [400, 500, 600, 700], "styles": ["normal", "italic"], "subset": "latin", }, From 598e4e5abaa8e06bd2ccf12620660e8b2ad94c70 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 31 Mar 2026 17:23:33 -0500 Subject: [PATCH 76/89] docs(sphinx_fonts): multi-subset support with unicode-range descriptors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Only the "latin" subset was downloaded. Characters outside basic Latin (accented letters like ñ, ř, ž common in contributor names and code comments) fell back to system fonts, breaking the typographic consistency of IBM Plex. what: - Add _UNICODE_RANGES dict mapping Fontsource subset names to CSS unicode-range descriptors for latin, latin-ext, cyrillic, cyrillic-ext, greek, and vietnamese - Add _unicode_range() lookup function with empty-string fallback for unknown subsets (omitting the descriptor = all codepoints) - Support "subsets" (list) config key alongside legacy "subset" (str) in _on_builder_inited — iterates all subsets in the download loop - Emit unicode-range in font_faces dict; template renders it in @font-face blocks so browsers only download subset files when characters from that range appear on the page (zero cost for pages that only use ASCII) - Preload uses the first (primary) subset only — typically "latin" - Update page.html template: conditional unicode-range line in @font-face blocks - Add 8 tests: _unicode_range (4), multi-subset builder-inited (2), legacy subset backward compat (1), preload primary subset (1) --- docs/_ext/sphinx_fonts.py | 92 +++++++++++++++---- docs/_templates/page.html | 3 + tests/docs/_ext/test_sphinx_fonts.py | 129 +++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 18 deletions(-) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index e8d2a692ae..7a7e1a2780 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -54,6 +54,57 @@ def _cdn_url( ) +# Unicode range descriptors per subset — tells the browser to only download +# the file when characters from this range appear on the page. Ranges are +# from Fontsource / Google Fonts CSS (CSS unicode-range values). +_UNICODE_RANGES: dict[str, str] = { + "latin": ( + "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6," + " U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F," + " U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215," + " U+FEFF, U+FFFD" + ), + "latin-ext": ( + "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7," + " U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF," + " U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB," + " U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF" + ), + "cyrillic": ("U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116"), + "cyrillic-ext": ( + "U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F" + ), + "greek": ( + "U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF" + ), + "vietnamese": ( + "U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169," + " U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304," + " U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB" + ), +} + + +def _unicode_range(subset: str) -> str: + """Return the CSS ``unicode-range`` descriptor for *subset*. + + Falls back to an empty string for unknown subsets (omitting the + descriptor causes the browser to treat the face as covering all + codepoints, which is the correct fallback). + + Parameters + ---------- + subset : str + Fontsource subset name (e.g. ``"latin"``, ``"latin-ext"``). + + Returns + ------- + str + CSS ``unicode-range`` value, or ``""`` if unknown. + """ + return _UNICODE_RANGES.get(subset, "") + + def _download_font(url: str, dest: pathlib.Path) -> bool: if dest.exists(): logger.debug("font cached: %s", dest.name) @@ -89,22 +140,25 @@ def _on_builder_inited(app: Sphinx) -> None: font_id = font["package"].split("/")[-1] version = font["version"] package = font["package"] - subset = font.get("subset", "latin") - for weight in font["weights"]: - for style in font["styles"]: - filename = f"{font_id}-{subset}-{weight}-{style}.woff2" - cached = cache / filename - url = _cdn_url(package, version, font_id, subset, weight, style) - if _download_font(url, cached): - shutil.copy2(cached, fonts_dir / filename) - font_faces.append( - { - "family": font["family"], - "style": style, - "weight": str(weight), - "filename": filename, - } - ) + # Accept "subsets" (list) or legacy "subset" (str). + subsets: list[str] = font.get("subsets", [font.get("subset", "latin")]) + for subset in subsets: + for weight in font["weights"]: + for style in font["styles"]: + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + cached = cache / filename + url = _cdn_url(package, version, font_id, subset, weight, style) + if _download_font(url, cached): + shutil.copy2(cached, fonts_dir / filename) + font_faces.append( + { + "family": font["family"], + "style": style, + "weight": str(weight), + "filename": filename, + "unicode_range": _unicode_range(subset), + } + ) preload_hrefs: list[str] = [] preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload @@ -112,8 +166,10 @@ def _on_builder_inited(app: Sphinx) -> None: for font in fonts: if font["family"] == family_name: font_id = font["package"].split("/")[-1] - subset = font.get("subset", "latin") - filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + # Preload the first (primary) subset only — typically "latin". + subsets = font.get("subsets", [font.get("subset", "latin")]) + primary = subsets[0] if subsets else "latin" + filename = f"{font_id}-{primary}-{weight}-{style}.woff2" preload_hrefs.append(filename) break diff --git a/docs/_templates/page.html b/docs/_templates/page.html index 7c0b561789..0c1ad27ea1 100644 --- a/docs/_templates/page.html +++ b/docs/_templates/page.html @@ -13,6 +13,9 @@ font-weight: {{ face.weight }}; font-display: block; src: url("{{ pathto('_static/fonts/' + face.filename, 1) }}") format("woff2"); + {%- if face.unicode_range %} + unicode-range: {{ face.unicode_range }}; + {%- endif %} } {%- endfor %} {%- for fb in font_fallbacks|default([]) %} diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py index 22f546a2e1..e96a879be3 100644 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -96,6 +96,36 @@ def test_cdn_url_matches_template() -> None: assert url.endswith(".woff2") +# --- _unicode_range tests --- + + +def test_unicode_range_latin() -> None: + """_unicode_range returns a non-empty range for 'latin'.""" + result = sphinx_fonts._unicode_range("latin") + assert result.startswith("U+") + assert "U+0000" in result + + +def test_unicode_range_latin_ext() -> None: + """_unicode_range returns a non-empty range for 'latin-ext'.""" + result = sphinx_fonts._unicode_range("latin-ext") + assert result.startswith("U+") + assert result != sphinx_fonts._unicode_range("latin") + + +def test_unicode_range_unknown_subset() -> None: + """_unicode_range returns empty string for unknown subsets.""" + result = sphinx_fonts._unicode_range("klingon") + assert result == "" + + +def test_unicode_range_all_known_subsets_non_empty() -> None: + """Every subset in _UNICODE_RANGES produces a non-empty range.""" + for subset, urange in sphinx_fonts._UNICODE_RANGES.items(): + assert urange.startswith("U+"), f"subset {subset!r} has invalid range" + assert sphinx_fonts._unicode_range(subset) == urange + + # --- _download_font tests --- @@ -337,6 +367,105 @@ def test_on_builder_inited_explicit_subset( assert app._font_faces[0]["filename"] == "noto-sans-latin-ext-400-normal.woff2" +def test_on_builder_inited_multiple_subsets( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited downloads files for each subset and includes unicode_range.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/ibm-plex-sans", + "version": "5.2.8", + "family": "IBM Plex Sans", + "subsets": ["latin", "latin-ext"], + "weights": [400], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "ibm-plex-sans-latin-400-normal.woff2").write_bytes(b"data") + (cache / "ibm-plex-sans-latin-ext-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert len(app._font_faces) == 2 + filenames = [f["filename"] for f in app._font_faces] + assert "ibm-plex-sans-latin-400-normal.woff2" in filenames + assert "ibm-plex-sans-latin-ext-400-normal.woff2" in filenames + + # unicode_range should be populated for known subsets + latin_face = next(f for f in app._font_faces if "latin-400" in f["filename"]) + assert latin_face["unicode_range"].startswith("U+") + latin_ext_face = next(f for f in app._font_faces if "latin-ext" in f["filename"]) + assert latin_ext_face["unicode_range"].startswith("U+") + assert latin_face["unicode_range"] != latin_ext_face["unicode_range"] + + +def test_on_builder_inited_legacy_subset_gets_unicode_range( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Legacy single 'subset' config still produces unicode_range in font_faces.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/noto-sans", + "version": "5.0.0", + "family": "Noto Sans", + "subset": "latin", + "weights": [400], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "noto-sans-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert len(app._font_faces) == 1 + assert app._font_faces[0]["unicode_range"].startswith("U+") + + +def test_on_builder_inited_preload_uses_primary_subset( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preload uses the first (primary) subset when multiple are configured.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/ibm-plex-sans", + "version": "5.2.8", + "family": "IBM Plex Sans", + "subsets": ["latin", "latin-ext"], + "weights": [400], + "styles": ["normal"], + }, + ] + preload = [("IBM Plex Sans", 400, "normal")] + app = _make_app(tmp_path, fonts=fonts, preload=preload) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "ibm-plex-sans-latin-400-normal.woff2").write_bytes(b"data") + (cache / "ibm-plex-sans-latin-ext-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + # Preload should only include the primary (first) subset + assert app._font_preload_hrefs == ["ibm-plex-sans-latin-400-normal.woff2"] + + def test_on_builder_inited_preload_match( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From 4760f29e9934ed8e8308219a6a012d6fa1370467 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 31 Mar 2026 17:23:45 -0500 Subject: [PATCH 77/89] docs(conf): add latin-ext subset for IBM Plex Sans and Mono MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Accented characters (ñ, ř, ž, ö) in contributor names, docstrings, and code comments fell back to system fonts. latin-ext covers U+0100-02FF and extended Latin ranges used across European languages. what: - Change "subset": "latin" to "subsets": ["latin", "latin-ext"] for both IBM Plex Sans and IBM Plex Mono - Total font files: 32 (2 fonts × 2 subsets × 4 weights × 2 styles) - Zero performance cost for ASCII-only pages: unicode-range descriptors tell the browser to skip latin-ext downloads when no extended chars appear on the page --- docs/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9539c52438..0e393d32e3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -159,7 +159,7 @@ "version": "5.2.8", "weights": [400, 500, 600, 700], "styles": ["normal", "italic"], - "subset": "latin", + "subsets": ["latin", "latin-ext"], }, { "family": "IBM Plex Mono", @@ -167,7 +167,7 @@ "version": "5.2.7", "weights": [400, 500, 600, 700], "styles": ["normal", "italic"], - "subset": "latin", + "subsets": ["latin", "latin-ext"], }, ] From 097f6178f9968e96d14ca2d8dbdded26147d309b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 06:09:52 -0500 Subject: [PATCH 78/89] py(deps[dev]) Bump dev packages --- uv.lock | 536 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 274 insertions(+), 262 deletions(-) diff --git a/uv.lock b/uv.lock index 90e0fc9b54..0a708b6f9c 100644 --- a/uv.lock +++ b/uv.lock @@ -84,119 +84,119 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, - { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, - { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, - { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, - { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, - { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, - { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, - { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, - { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, - { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, - { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, - { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, - { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, - { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, - { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, - { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, - { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, - { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, - { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, - { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, - { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, - { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, - { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, - { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, - { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -685,7 +685,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -694,39 +694,51 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" }, + { url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" }, + { url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" }, + { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, ] [[package]] @@ -799,100 +811,100 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, - { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, - { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] @@ -1049,7 +1061,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.0" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1057,9 +1069,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -1085,27 +1097,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, - { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, - { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, - { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, - { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, - { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, ] [[package]] @@ -1644,14 +1656,14 @@ wheels = [ [[package]] name = "types-pygments" -version = "2.19.0.20251121" +version = "2.19.0.20260402" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-docutils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/3b/cd650700ce9e26b56bd1a6aa4af397bbbc1784e22a03971cb633cdb0b601/types_pygments-2.19.0.20251121.tar.gz", hash = "sha256:eef114fde2ef6265365522045eac0f8354978a566852f69e75c531f0553822b1", size = 18590, upload-time = "2025-11-21T03:03:46.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/a8/5834c55d900ce31b31367eedb82e664347dfa551a957b74a0ce0cd9f4f9a/types_pygments-2.19.0.20260402.tar.gz", hash = "sha256:bd26e1f662c9a3b8ea56668ddc099b809ffd54931bee97b15853bc4a8e5d4250", size = 18808, upload-time = "2026-04-02T04:21:13.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/8a/9244b21f1d60dcc62e261435d76b02f1853b4771663d7ec7d287e47a9ba9/types_pygments-2.19.0.20251121-py3-none-any.whl", hash = "sha256:cb3bfde34eb75b984c98fb733ce4f795213bd3378f855c32e75b49318371bb25", size = 25674, upload-time = "2025-11-21T03:03:45.72Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f2/b23659df4219fc4f39a45ede527cc209d961c7ff957678536a7a9d9ac9c5/types_pygments-2.19.0.20260402-py3-none-any.whl", hash = "sha256:5b0d863cec1c43ba38c946fb6e89d389c1cf287806f72360336fba4482f8daeb", size = 25672, upload-time = "2026-04-02T04:21:11.916Z" }, ] [[package]] @@ -1692,16 +1704,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.42.0" +version = "0.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/f2/368268300fb8af33743508d738ef7bb4d56afdb46c6d9c0fa3dd515df171/uvicorn-0.43.0.tar.gz", hash = "sha256:ab1652d2fb23abf124f36ccc399828558880def222c3cb3d98d24021520dc6e8", size = 85686, upload-time = "2026-04-03T18:37:48.984Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl", hash = "sha256:46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620", size = 68591, upload-time = "2026-04-03T18:37:47.64Z" }, ] [[package]] From 6b1651f4499c4641552b9a5702843ac286ee57be Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 08:45:19 -0500 Subject: [PATCH 79/89] .tool-versions(uv, just) uv 0.11.2 -> 0.11.3, just 1.47 -> 1.49 - just - https://github.com/casey/just/blob/1.49.0/CHANGELOG.md - https://github.com/casey/just/releases/tag/1.48.0 - https://github.com/casey/just/releases/tag/1.49.0 - uv: - https://github.com/astral-sh/uv/releases/tag/0.11.3 - https://github.com/astral-sh/uv/blob/0.11.3/CHANGELOG.md --- .tool-versions | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.tool-versions b/.tool-versions index 1ced26b3e9..88d422f528 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -just 1.47 -uv 0.11.2 +just 1.49 +uv 0.11.3 python 3.14 3.13 3.12 3.11 3.10 From 903041edbc6400acd02e931ad4c298ef8c311ce9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 08:45:24 -0500 Subject: [PATCH 80/89] py(deps[dev]) Bump dev packages --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 0a708b6f9c..67edd3767c 100644 --- a/uv.lock +++ b/uv.lock @@ -1656,14 +1656,14 @@ wheels = [ [[package]] name = "types-pygments" -version = "2.19.0.20260402" +version = "2.20.0.20260405" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-docutils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/a8/5834c55d900ce31b31367eedb82e664347dfa551a957b74a0ce0cd9f4f9a/types_pygments-2.19.0.20260402.tar.gz", hash = "sha256:bd26e1f662c9a3b8ea56668ddc099b809ffd54931bee97b15853bc4a8e5d4250", size = 18808, upload-time = "2026-04-02T04:21:13.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/28/7a11c06b290e370eca368dd59d9738a79657a7518f5a4021b1e187c1a16d/types_pygments-2.20.0.20260405.tar.gz", hash = "sha256:f06fe34d6457044ce7587a5a6cf73e6bc5c769c933cd9edf033379bcd7ed2897", size = 19342, upload-time = "2026-04-05T04:27:06.184Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/f2/b23659df4219fc4f39a45ede527cc209d961c7ff957678536a7a9d9ac9c5/types_pygments-2.19.0.20260402-py3-none-any.whl", hash = "sha256:5b0d863cec1c43ba38c946fb6e89d389c1cf287806f72360336fba4482f8daeb", size = 25672, upload-time = "2026-04-02T04:21:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/dabb479c2cda4fbed99a0f2045aee2bb91487c50c654a7ad6dfa327c5b82/types_pygments-2.20.0.20260405-py3-none-any.whl", hash = "sha256:79dc975f7a9c6cbfdcc32f3d31b7eb507d39a41031c3b2124f16fc2e42326954", size = 26688, upload-time = "2026-04-05T04:27:05.215Z" }, ] [[package]] From a4d11b52bf404f9f661a18bb062562e29db69fe5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 07:30:10 -0500 Subject: [PATCH 81/89] py(deps[dev]): Replace sphinx/furo stack with gp-sphinx==0.0.1a0 why: Consolidate docs dependencies into the gp-sphinx shared platform, resolving from PyPI now that 0.0.1a0 is published. what: - Remove sphinx<9, furo, sphinx-autodoc-typehints, sphinx-inline-tabs, sphinxext-opengraph, sphinx-copybutton, sphinxext-rediraffe, sphinx-design, myst-parser, linkify-it-py from dev/docs groups - Add gp-sphinx==0.0.1a0 and sphinx-argparse-neo==0.0.1a0 - Remove cli_usage_lexer, argparse_lexer, argparse_roles from mypy overrides - Update uv.lock to resolve from PyPI --- pyproject.toml | 27 ++--------- uv.lock | 124 ++++++++++++++++++++++++++++++------------------- 2 files changed, 80 insertions(+), 71 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 02fc0e28f6..50327c2125 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,18 +57,10 @@ dev = [ # Docs "aafigure", # https://launchpad.net/aafigure "pillow", # https://pillow.readthedocs.io/ - "sphinx<9", # https://www.sphinx-doc.org/ - "furo", # https://pradyunsg.me/furo/ + "gp-sphinx==0.0.1a0", # https://gp-sphinx.git-pull.com/ + "sphinx-argparse-neo==0.0.1a0", # https://gp-sphinx.git-pull.com/ "gp-libs", # https://gp-libs.git-pull.com/ "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html - "sphinx-autodoc-typehints", # https://sphinx-autodoc-typehints.readthedocs.io/ - "sphinx-inline-tabs", # https://sphinx-inline-tabs.readthedocs.io/ - "sphinxext-opengraph", # https://sphinxext-opengraph.readthedocs.io/ - "sphinx-copybutton", # https://sphinx-copybutton.readthedocs.io/ - "sphinxext-rediraffe", # https://sphinxext-rediraffe.readthedocs.io/ - "sphinx-design", # https://sphinx-design.readthedocs.io/ - "myst-parser", # https://myst-parser.readthedocs.io/ - "linkify-it-py", # https://github.com/tsutsu3/linkify-it-py # Testing "gp-libs", "pytest", @@ -90,18 +82,10 @@ dev = [ docs = [ "aafigure", # https://launchpad.net/aafigure "pillow", # https://pillow.readthedocs.io/ - "sphinx<9", # https://www.sphinx-doc.org/ - "furo", # https://pradyunsg.me/furo/ + "gp-sphinx==0.0.1a0", # https://gp-sphinx.git-pull.com/ + "sphinx-argparse-neo==0.0.1a0", # https://gp-sphinx.git-pull.com/ "gp-libs", # https://gp-libs.git-pull.com/ "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html - "sphinx-autodoc-typehints", # https://sphinx-autodoc-typehints.readthedocs.io/ - "sphinx-inline-tabs", # https://sphinx-inline-tabs.readthedocs.io/ - "sphinxext-opengraph", # https://sphinxext-opengraph.readthedocs.io/ - "sphinx-copybutton", # https://sphinx-copybutton.readthedocs.io/ - "sphinxext-rediraffe", # https://sphinxext-rediraffe.readthedocs.io/ - "sphinx-design", # https://sphinx-design.readthedocs.io/ - "myst-parser", # https://myst-parser.readthedocs.io/ - "linkify-it-py", # https://github.com/tsutsu3/linkify-it-py ] testing = [ "gp-libs", @@ -176,9 +160,6 @@ module = [ "sphinx_argparse_neo", "sphinx_argparse_neo.*", "sphinx_fonts", - "cli_usage_lexer", - "argparse_lexer", - "argparse_roles", "docutils", "docutils.*", "pygments", diff --git a/uv.lock b/uv.lock index 67edd3767c..32264f4b46 100644 --- a/uv.lock +++ b/uv.lock @@ -391,6 +391,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/f9/5d78d1dda9cb0f27d6f2305e95a58edbff935a62d53ec3227a3518cb4f72/gp_libs-0.0.17-py3-none-any.whl", hash = "sha256:7ce96d5e09980c0dc82062ab3e3b911600bd44da97a64fb78379f1af9a79d4d3", size = 16157, upload-time = "2025-12-07T22:44:48.036Z" }, ] +[[package]] +name = "gp-sphinx" +version = "0.0.1a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "gp-libs" }, + { name = "linkify-it-py" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-fonts" }, + { name = "sphinx-gptheme" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinxext-opengraph" }, + { name = "sphinxext-rediraffe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/57/7a8ea21c53c83e7c54b17610ed0c48e8db6254c2ff017c1e44ae4f7132ca/gp_sphinx-0.0.1a0.tar.gz", hash = "sha256:5cf583c06dffe6697b05a9a5f0593aa41cfe35fed8a1577324ccc87e0c0c92f7", size = 13989, upload-time = "2026-04-05T10:10:23.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/8e/5e0a0364be9c80e18bd07ec2bf43fd760c5938629035a356c172f1234daa/gp_sphinx-0.0.1a0-py3-none-any.whl", hash = "sha256:fb8310dd73ffb52827ed834f49d2e769ed3136359b54879aadd9d55ff7c6048d", size = 14399, upload-time = "2026-04-05T10:04:29.578Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1201,6 +1229,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, ] +[[package]] +name = "sphinx-argparse-neo" +version = "0.0.1a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/37/83215aabda61647f0fb0ab489e0c7227a59e041f565e9b44f2af073f6008/sphinx_argparse_neo-0.0.1a0.tar.gz", hash = "sha256:d40c931a687fe79dc465d850fb9904f552de575952b6dee291de61149c7bc66a", size = 37123, upload-time = "2026-04-05T10:10:23.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/4c/6b9ac99f0639eebcecef7d294c079b66c06d56f0cc9a285ebbe2b05c68c9/sphinx_argparse_neo-0.0.1a0-py3-none-any.whl", hash = "sha256:19cf9ba32d14ca686112c1d8509f268f0ab2b1822a003875de64bbd9449ab5ef", size = 41428, upload-time = "2026-04-05T10:04:31.212Z" }, +] + [[package]] name = "sphinx-autobuild" version = "2024.10.3" @@ -1330,6 +1373,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, ] +[[package]] +name = "sphinx-fonts" +version = "0.0.1a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/4f/be4fe35f90d0bc5090a8bd1367c53d063d5808e367e22274f16cc6978796/sphinx_fonts-0.0.1a0.tar.gz", hash = "sha256:9ca77ba151fa27963e90f899d92b1e43680e223efa3acdd3c532d5e4f0b29eed", size = 5628, upload-time = "2026-04-05T10:10:28.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/68/c8793bc5a08aee5644aed1ac0eb1ef2368cc61e31d4c1d6fd6cc52192a15/sphinx_fonts-0.0.1a0-py3-none-any.whl", hash = "sha256:aae888b35cc901ad2947c3d171a0bf02b724bc78d2677827673113c8c73e11fd", size = 4345, upload-time = "2026-04-05T10:09:11.134Z" }, +] + +[[package]] +name = "sphinx-gptheme" +version = "0.0.1a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "furo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/7c/71908e74939fd4d33d83bc39d31398deae895218dd319f626f6a3e4a1068/sphinx_gptheme-0.0.1a0.tar.gz", hash = "sha256:06f222f557dbd0e3256494f145cdbc1bc971d665e9203db19bc9c105283132ac", size = 13697, upload-time = "2026-04-05T10:10:29.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/ec/7fe7909d31da9007232a77ac5750da9b9329921e938c3d73d409e4caa4ec/sphinx_gptheme-0.0.1a0-py3-none-any.whl", hash = "sha256:da0e6bb047b01c93a7df2f81be693e46b0709a1960b250991597648f7b320dfa", size = 14690, upload-time = "2026-04-05T10:10:21.577Z" }, +] + [[package]] name = "sphinx-inline-tabs" version = "2025.12.21.14" @@ -1455,12 +1523,9 @@ dev = [ { name = "aafigure" }, { name = "codecov" }, { name = "coverage" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, + { name = "gp-sphinx" }, { name = "mypy" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pillow" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1468,42 +1533,21 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, { name = "ruff" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-argparse-neo" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, ] docs = [ { name = "aafigure" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "gp-sphinx" }, { name = "pillow" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-argparse-neo" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, ] lint = [ { name = "mypy" }, @@ -1536,11 +1580,9 @@ dev = [ { name = "aafigure" }, { name = "codecov" }, { name = "coverage" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, + { name = "gp-sphinx", specifier = "==0.0.1a0" }, { name = "mypy" }, - { name = "myst-parser" }, { name = "pillow" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1548,33 +1590,19 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, { name = "ruff" }, - { name = "sphinx", specifier = "<9" }, + { name = "sphinx-argparse-neo", specifier = "==0.0.1a0" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, ] docs = [ { name = "aafigure" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, - { name = "myst-parser" }, + { name = "gp-sphinx", specifier = "==0.0.1a0" }, { name = "pillow" }, - { name = "sphinx", specifier = "<9" }, + { name = "sphinx-argparse-neo", specifier = "==0.0.1a0" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, ] lint = [ { name = "mypy" }, From 587ec3705b88836dc38d63b6817ddb0fc279dcdb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 07:25:03 -0500 Subject: [PATCH 82/89] docs(chore): Remove bundled extensions and migrate conf.py to gp-sphinx why: sphinx-argparse-neo and sphinx-fonts are now provided as PyPI packages via gp-sphinx; bundled copies in docs/_ext/ are no longer needed. what: - Delete docs/_ext/sphinx_fonts.py, sphinx_argparse_neo/, argparse_exemplar.py, argparse_lexer.py, argparse_roles.py, cli_usage_lexer.py - Delete docs/_static/js/spa-nav.js (now bundled in sphinx-gptheme) - Migrate docs/conf.py to use merge_sphinx_config() from gp_sphinx - Update tests/docs/_ext/ imports to reference installed packages --- docs/_ext/argparse_exemplar.py | 1305 -------------------- docs/_ext/argparse_lexer.py | 429 ------- docs/_ext/argparse_roles.py | 370 ------ docs/_ext/cli_usage_lexer.py | 115 -- docs/_ext/sphinx_argparse_neo/__init__.py | 101 -- docs/_ext/sphinx_argparse_neo/compat.py | 271 ---- docs/_ext/sphinx_argparse_neo/directive.py | 240 ---- docs/_ext/sphinx_argparse_neo/nodes.py | 647 ---------- docs/_ext/sphinx_argparse_neo/parser.py | 659 ---------- docs/_ext/sphinx_argparse_neo/renderer.py | 604 --------- docs/_ext/sphinx_argparse_neo/utils.py | 78 -- docs/_ext/sphinx_fonts.py | 209 ---- docs/_static/js/spa-nav.js | 254 ---- docs/conf.py | 300 +---- tests/docs/_ext/conftest.py | 8 - tests/docs/_ext/test_argparse_exemplar.py | 4 +- tests/docs/_ext/test_argparse_lexer.py | 2 +- tests/docs/_ext/test_argparse_roles.py | 4 +- tests/docs/_ext/test_cli_usage_lexer.py | 2 +- tests/docs/_ext/test_sphinx_fonts.py | 2 +- 20 files changed, 35 insertions(+), 5569 deletions(-) delete mode 100644 docs/_ext/argparse_exemplar.py delete mode 100644 docs/_ext/argparse_lexer.py delete mode 100644 docs/_ext/argparse_roles.py delete mode 100644 docs/_ext/cli_usage_lexer.py delete mode 100644 docs/_ext/sphinx_argparse_neo/__init__.py delete mode 100644 docs/_ext/sphinx_argparse_neo/compat.py delete mode 100644 docs/_ext/sphinx_argparse_neo/directive.py delete mode 100644 docs/_ext/sphinx_argparse_neo/nodes.py delete mode 100644 docs/_ext/sphinx_argparse_neo/parser.py delete mode 100644 docs/_ext/sphinx_argparse_neo/renderer.py delete mode 100644 docs/_ext/sphinx_argparse_neo/utils.py delete mode 100644 docs/_ext/sphinx_fonts.py delete mode 100644 docs/_static/js/spa-nav.js diff --git a/docs/_ext/argparse_exemplar.py b/docs/_ext/argparse_exemplar.py deleted file mode 100644 index a4a7e1fc8b..0000000000 --- a/docs/_ext/argparse_exemplar.py +++ /dev/null @@ -1,1305 +0,0 @@ -"""Transform argparse epilog "examples:" definition lists into documentation sections. - -This Sphinx extension post-processes sphinx_argparse_neo output to convert -specially-formatted "examples:" definition lists in argparse epilogs into -proper documentation sections with syntax-highlighted code blocks. - -The extension is designed to be generic and reusable across different projects. -All behavior can be customized via Sphinx configuration options. - -Purpose -------- -When documenting CLI tools with argparse, it's useful to include examples in -the epilog. This extension recognizes a specific definition list format and -transforms it into structured documentation sections that appear in the TOC. - -Input Format ------------- -Format your argparse epilog with definition lists where terms end with "examples:": - -.. code-block:: python - - parser = argparse.ArgumentParser( - epilog=textwrap.dedent(''' - examples: - myapp sync - myapp sync myrepo - - Machine-readable output examples: - myapp sync --json - myapp sync -F json myrepo - '''), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - -The epilog text will be parsed as a definition list by docutils, with: -- Terms: "examples:", "Machine-readable output examples:", etc. -- Definitions: The example commands (one per line) - -Output ------- -The extension transforms these into proper sections: - -- A base "examples:" term creates an "Examples" section -- Category-prefixed terms like "Machine-readable output examples:" create - subsections nested under the parent Examples section -- Each command line becomes a syntax-highlighted console code block - -Configuration -------------- -Configure via conf.py. All options have sensible defaults. - -**Term Detection:** - -``argparse_examples_term_suffix`` : str (default: "examples") - Term must end with this string to be treated as an examples header. - -``argparse_examples_base_term`` : str (default: "examples") - Exact match for the base examples section (case-insensitive). - -``argparse_examples_section_title`` : str (default: "Examples") - Title used for the base examples section. - -**Usage Detection:** - -``argparse_usage_pattern`` : str (default: "usage:") - Text must start with this to be treated as a usage block (case-insensitive). - -**Code Block Formatting:** - -``argparse_examples_command_prefix`` : str (default: "$ ") - Prefix added to each command line in examples code blocks. - -``argparse_examples_code_language`` : str (default: "console") - Language identifier for examples code blocks. - -``argparse_examples_code_classes`` : list[str] (default: ["highlight-console"]) - CSS classes added to examples code blocks. - -``argparse_usage_code_language`` : str (default: "cli-usage") - Language identifier for usage blocks. - -**Behavior:** - -``argparse_reorder_usage_before_examples`` : bool (default: True) - Whether to reorder nodes so usage appears before examples. - -Additional Features -------------------- -- Removes ANSI escape codes (useful when FORCE_COLOR is set) -- Applies syntax highlighting to usage blocks -- Reorders sections so usage appears before examples in the output -- Extracts sections from argparse_program containers for TOC visibility - -Project-Specific Setup ----------------------- -Projects using this extension should register their own lexers and CSS in -their conf.py setup() function. For example:: - - def setup(app): - from my_lexer import MyLexer - app.add_lexer("my-output", MyLexer) - app.add_css_file("css/my-highlight.css") -""" - -from __future__ import annotations - -import dataclasses -import typing as t - -from docutils import nodes -from sphinx_argparse_neo.directive import ArgparseDirective -from sphinx_argparse_neo.utils import strip_ansi - -if t.TYPE_CHECKING: - import sphinx.config - from sphinx.application import Sphinx - - -@dataclasses.dataclass -class ExemplarConfig: - """Configuration for argparse_exemplar transformation. - - This dataclass provides all configurable options for the argparse_exemplar - extension. Functions accept an optional config parameter with a factory - default, allowing them to work standalone with defaults or accept custom - config for full control. - - Attributes - ---------- - examples_term_suffix : str - Term must end with this string (case-insensitive) to be treated as an - examples header. Default: "examples". - examples_base_term : str - Exact match (case-insensitive, after stripping ":") for the base - examples section. Default: "examples". - examples_section_title : str - Title used for the base examples section. Default: "Examples". - usage_pattern : str - Text must start with this string (case-insensitive, after stripping - whitespace) to be treated as a usage block. Default: "usage:". - command_prefix : str - Prefix added to each command line in examples code blocks. - Default: "$ ". - code_language : str - Language identifier for examples code blocks. Default: "console". - code_classes : tuple[str, ...] - CSS classes added to examples code blocks. - Default: ("highlight-console",). - usage_code_language : str - Language identifier for usage blocks. Default: "cli-usage". - reorder_usage_before_examples : bool - Whether to reorder nodes so usage appears before examples. - Default: True. - - Examples - -------- - Using default configuration: - - >>> config = ExemplarConfig() - >>> config.examples_term_suffix - 'examples' - >>> config.command_prefix - '$ ' - - Custom configuration: - - >>> config = ExemplarConfig( - ... command_prefix="> ", - ... code_language="bash", - ... ) - >>> config.command_prefix - '> ' - >>> config.code_language - 'bash' - """ - - # Term detection - examples_term_suffix: str = "examples" - examples_base_term: str = "examples" - examples_section_title: str = "Examples" - - # Usage detection - usage_pattern: str = "usage:" - - # Code block formatting - command_prefix: str = "$ " - code_language: str = "console" - code_classes: tuple[str, ...] = ("highlight-console",) - usage_code_language: str = "cli-usage" - - # Behavior - reorder_usage_before_examples: bool = True - - @classmethod - def from_sphinx_config(cls, config: sphinx.config.Config) -> ExemplarConfig: - """Create ExemplarConfig from Sphinx configuration. - - Parameters - ---------- - config : sphinx.config.Config - The Sphinx configuration object. - - Returns - ------- - ExemplarConfig - Configuration populated from Sphinx config values. - - Examples - -------- - This is typically called from a directive's run() method: - - >>> # In CleanArgParseDirective.run(): - >>> # config = ExemplarConfig.from_sphinx_config(self.env.config) - """ - # Get code_classes as tuple (Sphinx stores lists) - code_classes_raw = getattr( - config, "argparse_examples_code_classes", ("highlight-console",) - ) - code_classes = ( - tuple(code_classes_raw) - if isinstance(code_classes_raw, list) - else code_classes_raw - ) - - return cls( - examples_term_suffix=getattr( - config, "argparse_examples_term_suffix", "examples" - ), - examples_base_term=getattr( - config, "argparse_examples_base_term", "examples" - ), - examples_section_title=getattr( - config, "argparse_examples_section_title", "Examples" - ), - usage_pattern=getattr(config, "argparse_usage_pattern", "usage:"), - command_prefix=getattr(config, "argparse_examples_command_prefix", "$ "), - code_language=getattr(config, "argparse_examples_code_language", "console"), - code_classes=code_classes, - usage_code_language=getattr( - config, "argparse_usage_code_language", "cli-usage" - ), - reorder_usage_before_examples=getattr( - config, "argparse_reorder_usage_before_examples", True - ), - ) - - -# Re-export for backwards compatibility and public API -__all__ = [ - "CleanArgParseDirective", - "ExemplarConfig", - "is_base_examples_term", - "is_examples_term", - "make_section_id", - "make_section_title", - "process_node", - "strip_ansi", - "transform_definition_list", -] - - -def is_examples_term(term_text: str, *, config: ExemplarConfig | None = None) -> bool: - """Check if a definition term is an examples header. - - Parameters - ---------- - term_text : str - The text content of a definition term. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - bool - True if this is an examples header. - - Examples - -------- - >>> is_examples_term("examples:") - True - >>> is_examples_term("Machine-readable output examples:") - True - >>> is_examples_term("Usage:") - False - - With custom configuration: - - >>> custom_config = ExemplarConfig(examples_term_suffix="demos") - >>> is_examples_term("demos:", config=custom_config) - True - >>> is_examples_term("examples:", config=custom_config) - False - """ - config = config or ExemplarConfig() - return term_text.lower().rstrip(":").endswith(config.examples_term_suffix) - - -def is_base_examples_term( - term_text: str, *, config: ExemplarConfig | None = None -) -> bool: - """Check if a definition term is a base "examples:" header (no prefix). - - Parameters - ---------- - term_text : str - The text content of a definition term. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - bool - True if this is just "examples:" with no category prefix. - - Examples - -------- - >>> is_base_examples_term("examples:") - True - >>> is_base_examples_term("Examples") - True - >>> is_base_examples_term("Field-scoped examples:") - False - - With custom configuration: - - >>> custom_config = ExemplarConfig(examples_base_term="demos") - >>> is_base_examples_term("demos:", config=custom_config) - True - >>> is_base_examples_term("examples:", config=custom_config) - False - """ - config = config or ExemplarConfig() - return term_text.lower().rstrip(":").strip() == config.examples_base_term - - -def make_section_id( - term_text: str, - counter: int = 0, - *, - is_subsection: bool = False, - page_prefix: str = "", - config: ExemplarConfig | None = None, -) -> str: - """Generate a section ID from an examples term. - - Parameters - ---------- - term_text : str - The examples term text (e.g., "Machine-readable output: examples:") - counter : int - Counter for uniqueness if multiple examples sections exist. - is_subsection : bool - If True, omit "-examples" suffix for cleaner nested IDs. - page_prefix : str - Optional prefix from the page name (e.g., "sync", "add") to ensure - uniqueness across different documentation pages. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - str - A normalized section ID. - - Examples - -------- - >>> make_section_id("examples:") - 'examples' - >>> make_section_id("examples:", page_prefix="sync") - 'sync-examples' - >>> make_section_id("Machine-readable output examples:") - 'machine-readable-output-examples' - >>> make_section_id("Field-scoped examples:", is_subsection=True) - 'field-scoped' - >>> make_section_id("examples:", counter=1) - 'examples-1' - - With custom configuration: - - >>> custom_config = ExemplarConfig(examples_term_suffix="demos") - >>> make_section_id("demos:", config=custom_config) - 'demos' - >>> make_section_id("Machine-readable output demos:", config=custom_config) - 'machine-readable-output-demos' - """ - config = config or ExemplarConfig() - term_suffix = config.examples_term_suffix - - # Extract prefix before the term suffix (e.g., "Machine-readable output") - lower_text = term_text.lower().rstrip(":") - if term_suffix in lower_text: - prefix = lower_text.rsplit(term_suffix, 1)[0].strip() - # Remove trailing colon from prefix (handles ": examples" pattern) - prefix = prefix.rstrip(":").strip() - if prefix: - normalized_prefix = prefix.replace(" ", "-") - # Subsections don't need "-examples" suffix - if is_subsection: - section_id = normalized_prefix - else: - section_id = f"{normalized_prefix}-{term_suffix}" - else: - # Plain "examples" - add page prefix if provided for uniqueness - section_id = f"{page_prefix}-{term_suffix}" if page_prefix else term_suffix - else: - section_id = term_suffix - - # Add counter suffix for uniqueness - if counter > 0: - section_id = f"{section_id}-{counter}" - - return section_id - - -def make_section_title( - term_text: str, - *, - is_subsection: bool = False, - config: ExemplarConfig | None = None, -) -> str: - """Generate a section title from an examples term. - - Parameters - ---------- - term_text : str - The examples term text (e.g., "Machine-readable output: examples:") - is_subsection : bool - If True, omit "Examples" suffix for cleaner nested titles. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - str - A proper title (e.g., "Machine-readable Output Examples" or just - "Machine-Readable Output" if is_subsection=True). - - Examples - -------- - >>> make_section_title("examples:") - 'Examples' - >>> make_section_title("Machine-readable output examples:") - 'Machine-Readable Output Examples' - >>> make_section_title("Field-scoped examples:", is_subsection=True) - 'Field-Scoped' - - With custom configuration: - - >>> custom_config = ExemplarConfig( - ... examples_base_term="demos", - ... examples_term_suffix="demos", - ... examples_section_title="Demos", - ... ) - >>> make_section_title("demos:", config=custom_config) - 'Demos' - >>> make_section_title("Machine-readable output demos:", config=custom_config) - 'Machine-Readable Output Demos' - """ - config = config or ExemplarConfig() - base_term = config.examples_base_term - term_suffix = config.examples_term_suffix - section_title = config.examples_section_title - - # Remove trailing colon and normalize - text = term_text.rstrip(":").strip() - # Handle base term case (e.g., "examples:") - if text.lower() == base_term: - return section_title - - # Extract the prefix (category name) before the term suffix - lower = text.lower() - colon_suffix = f": {term_suffix}" - space_suffix = f" {term_suffix}" - if lower.endswith(colon_suffix): - prefix = text[: -len(colon_suffix)] - elif lower.endswith(space_suffix): - prefix = text[: -len(space_suffix)] - else: - prefix = text - - # Title case the prefix - titled_prefix = prefix.title() - - # For subsections, just use the prefix (cleaner nested titles) - if is_subsection: - return titled_prefix - - # For top-level sections, append the section title - return f"{titled_prefix} {section_title}" - - -def _create_example_section( - term_text: str, - def_node: nodes.definition, - *, - is_subsection: bool = False, - page_prefix: str = "", - config: ExemplarConfig | None = None, -) -> nodes.section: - """Create a section node for an examples item. - - Parameters - ---------- - term_text : str - The examples term text. - def_node : nodes.definition - The definition node containing example commands. - is_subsection : bool - If True, create a subsection with simpler title/id. - page_prefix : str - Optional prefix from the page name for unique section IDs. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - nodes.section - A section node with title and code blocks. - - Examples - -------- - Create a section from a definition node containing example commands: - - >>> from docutils import nodes - >>> def_node = nodes.definition() - >>> def_node += nodes.paragraph(text="myapp sync") - >>> section = _create_example_section("examples:", def_node) - >>> section["ids"] - ['examples'] - >>> section[0].astext() - 'Examples' - - With a page prefix for uniqueness across documentation pages: - - >>> section = _create_example_section("examples:", def_node, page_prefix="sync") - >>> section["ids"] - ['sync-examples'] - - Category-prefixed examples create descriptive section IDs: - - >>> section = _create_example_section("Machine-readable output examples:", def_node) - >>> section["ids"] - ['machine-readable-output-examples'] - >>> section[0].astext() - 'Machine-Readable Output Examples' - """ - config = config or ExemplarConfig() - section_id = make_section_id( - term_text, is_subsection=is_subsection, page_prefix=page_prefix, config=config - ) - section_title = make_section_title( - term_text, is_subsection=is_subsection, config=config - ) - - section = nodes.section() - section["ids"] = [section_id] - section["names"] = [nodes.fully_normalize_name(section_title)] - - title = nodes.title(text=section_title) - section += title - - # Extract commands from definition and create separate code blocks - def_text = strip_ansi(def_node.astext()) - for line in def_text.split("\n"): - line = line.strip() - if line: - code_block = nodes.literal_block( - text=f"{config.command_prefix}{line}", - classes=list(config.code_classes), - ) - code_block["language"] = config.code_language - section += code_block - - return section - - -def transform_definition_list( - dl_node: nodes.definition_list, - *, - page_prefix: str = "", - config: ExemplarConfig | None = None, -) -> list[nodes.Node]: - """Transform a definition list, converting examples items to code blocks. - - If there's a base "examples:" item followed by category-specific examples - (e.g., "Field-scoped: examples:"), the categories are nested under the - parent Examples section for cleaner ToC structure. - - Parameters - ---------- - dl_node : nodes.definition_list - A definition list node. - page_prefix : str - Optional prefix from the page name for unique section IDs. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - list[nodes.Node] - Transformed nodes - code blocks for examples, original for others. - - Note - ---- - **Intentional reordering behavior:** This function always emits non-example - items (preamble text, descriptions, etc.) before example sections, regardless - of their original position in the definition list. This "flush first" approach - groups conceptually related content: introductory material appears before - examples, even if the source document interleaves them. This produces cleaner - documentation structure where descriptions introduce their examples. - - If you need to preserve the original interleaved order, you would need to - modify this function to track item positions during the first pass. - """ - config = config or ExemplarConfig() - - # First pass: collect examples and non-examples items separately - example_items: list[tuple[str, nodes.definition]] = [] # (term_text, def_node) - non_example_items: list[nodes.Node] = [] - base_examples_index: int | None = None - - for item in dl_node.children: - if not isinstance(item, nodes.definition_list_item): - continue - - # Get the term and definition - term_node = None - def_node = None - for child in item.children: - if isinstance(child, nodes.term): - term_node = child - elif isinstance(child, nodes.definition): - def_node = child - - if term_node is None or def_node is None: - non_example_items.append(item) - continue - - term_text = strip_ansi(term_node.astext()) - - if is_examples_term(term_text, config=config): - if is_base_examples_term(term_text, config=config): - base_examples_index = len(example_items) - example_items.append((term_text, def_node)) - else: - non_example_items.append(item) - - # Build result nodes - result_nodes: list[nodes.Node] = [] - - # Emit non-example items first (see docstring Note on reordering behavior) - if non_example_items: - new_dl = nodes.definition_list() - new_dl.extend(non_example_items) - result_nodes.append(new_dl) - - # Determine nesting strategy - # Nest if: there's a base "examples:" AND at least one other example category - should_nest = base_examples_index is not None and len(example_items) > 1 - - if should_nest and base_examples_index is not None: - # Create parent "Examples" section - base_term, base_def = example_items[base_examples_index] - parent_section = _create_example_section( - base_term, - base_def, - is_subsection=False, - page_prefix=page_prefix, - config=config, - ) - - # Add other examples as nested subsections - for i, (term_text, def_node) in enumerate(example_items): - if i == base_examples_index: - continue # Skip the base (already used as parent) - subsection = _create_example_section( - term_text, - def_node, - is_subsection=True, - page_prefix=page_prefix, - config=config, - ) - parent_section += subsection - - result_nodes.append(parent_section) - else: - # No nesting - create flat sections (backwards compatible) - for term_text, def_node in example_items: - section = _create_example_section( - term_text, - def_node, - is_subsection=False, - page_prefix=page_prefix, - config=config, - ) - result_nodes.append(section) - - return result_nodes - - -def process_node( - node: nodes.Node, - *, - page_prefix: str = "", - config: ExemplarConfig | None = None, -) -> nodes.Node | list[nodes.Node]: - """Process a node: strip ANSI codes and transform examples. - - Parameters - ---------- - node : nodes.Node - A docutils node to process. - page_prefix : str - Optional prefix from the page name for unique section IDs. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - nodes.Node | list[nodes.Node] - The processed node(s). - """ - config = config or ExemplarConfig() - - # Handle text nodes - strip ANSI - if isinstance(node, nodes.Text): - cleaned = strip_ansi(node.astext()) - if cleaned != node.astext(): - return nodes.Text(cleaned) - return node - - # Handle definition lists - transform examples - if isinstance(node, nodes.definition_list): - # Check if any items are examples - has_examples = False - for item in node.children: - if isinstance(item, nodes.definition_list_item): - for child in item.children: - if isinstance(child, nodes.term) and is_examples_term( - strip_ansi(child.astext()), config=config - ): - has_examples = True - break - if has_examples: - break - - if has_examples: - return transform_definition_list( - node, page_prefix=page_prefix, config=config - ) - - # Handle literal_block nodes - strip ANSI and apply usage highlighting - if isinstance(node, nodes.literal_block): - text = strip_ansi(node.astext()) - needs_update = text != node.astext() - - # Check if this is a usage block (starts with configured pattern) - is_usage = text.lstrip().lower().startswith(config.usage_pattern.lower()) - - if needs_update or is_usage: - new_block = nodes.literal_block(text=text) - # Preserve attributes - for attr in ("language", "classes"): - if attr in node: - new_block[attr] = node[attr] - # Apply configured language to usage blocks - if is_usage: - new_block["language"] = config.usage_code_language - return new_block - return node - - # Handle paragraph nodes - strip ANSI and lift sections out - if isinstance(node, nodes.paragraph): - # Process children and check if any become sections - processed_children: list[nodes.Node] = [] - changed = False - has_sections = False - - for child in node.children: - if isinstance(child, nodes.Text): - cleaned = strip_ansi(child.astext()) - if cleaned != child.astext(): - processed_children.append(nodes.Text(cleaned)) - changed = True - else: - processed_children.append(child) - else: - result = process_node(child, page_prefix=page_prefix, config=config) - if isinstance(result, list): - processed_children.extend(result) - changed = True - # Check if any results are sections - if any(isinstance(r, nodes.section) for r in result): - has_sections = True - elif result is not child: - processed_children.append(result) - changed = True - if isinstance(result, nodes.section): - has_sections = True - else: - processed_children.append(child) - - if not changed: - return node - - # If no sections, return a normal paragraph - if not has_sections: - new_para = nodes.paragraph() - new_para.extend(processed_children) - return new_para - - # Sections found - lift them out of the paragraph - # Return a list: [para_before, section1, section2, ..., para_after] - result_nodes: list[nodes.Node] = [] - current_para_children: list[nodes.Node] = [] - - for child in processed_children: - if isinstance(child, nodes.section): - # Flush current paragraph content - if current_para_children: - para = nodes.paragraph() - para.extend(current_para_children) - result_nodes.append(para) - current_para_children = [] - # Add section as a sibling - result_nodes.append(child) - else: - current_para_children.append(child) - - # Flush remaining paragraph content - if current_para_children: - para = nodes.paragraph() - para.extend(current_para_children) - result_nodes.append(para) - - return result_nodes - - # Recursively process children for other node types - if hasattr(node, "children"): - new_children: list[nodes.Node] = [] - children_changed = False - for child in node.children: - result = process_node(child, page_prefix=page_prefix, config=config) - if isinstance(result, list): - new_children.extend(result) - children_changed = True - elif result is not child: - new_children.append(result) - children_changed = True - else: - new_children.append(child) - if children_changed: - node[:] = new_children # type: ignore[index] - - return node - - -def _is_usage_block(node: nodes.Node, *, config: ExemplarConfig | None = None) -> bool: - """Check if a node is a usage literal block. - - Parameters - ---------- - node : nodes.Node - A docutils node to check. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - bool - True if this is a usage block (literal_block starting with usage pattern). - - Examples - -------- - >>> from docutils import nodes - >>> _is_usage_block(nodes.literal_block(text="usage: cmd [-h]")) - True - >>> _is_usage_block(nodes.literal_block(text="Usage: myapp sync")) - True - >>> _is_usage_block(nodes.literal_block(text=" usage: cmd")) - True - >>> _is_usage_block(nodes.literal_block(text="some other text")) - False - >>> _is_usage_block(nodes.paragraph(text="usage: cmd")) - False - >>> _is_usage_block(nodes.section()) - False - - With custom configuration: - - >>> custom_config = ExemplarConfig(usage_pattern="synopsis:") - >>> _is_usage_block(nodes.literal_block(text="synopsis: cmd"), config=custom_config) - True - >>> _is_usage_block(nodes.literal_block(text="usage: cmd"), config=custom_config) - False - """ - config = config or ExemplarConfig() - if not isinstance(node, nodes.literal_block): - return False - text = node.astext() - return text.lstrip().lower().startswith(config.usage_pattern.lower()) - - -def _is_usage_section(node: nodes.Node) -> bool: - """Check if a node is a usage section. - - Parameters - ---------- - node : nodes.Node - A docutils node to check. - - Returns - ------- - bool - True if this is a section with "usage" in its ID. - - Examples - -------- - >>> from docutils import nodes - >>> section = nodes.section() - >>> section["ids"] = ["usage"] - >>> _is_usage_section(section) - True - >>> section2 = nodes.section() - >>> section2["ids"] = ["sync-usage"] - >>> _is_usage_section(section2) - True - >>> section3 = nodes.section() - >>> section3["ids"] = ["options"] - >>> _is_usage_section(section3) - False - >>> _is_usage_section(nodes.paragraph()) - False - """ - if not isinstance(node, nodes.section): - return False - ids: list[str] = node.get("ids", []) - return any(id_str == "usage" or id_str.endswith("-usage") for id_str in ids) - - -def _is_examples_section( - node: nodes.Node, *, config: ExemplarConfig | None = None -) -> bool: - """Check if a node is an examples section. - - Parameters - ---------- - node : nodes.Node - A docutils node to check. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - bool - True if this is an examples section (section with term suffix in its ID). - - Examples - -------- - >>> from docutils import nodes - >>> section = nodes.section() - >>> section["ids"] = ["examples"] - >>> _is_examples_section(section) - True - >>> section2 = nodes.section() - >>> section2["ids"] = ["machine-readable-output-examples"] - >>> _is_examples_section(section2) - True - >>> section3 = nodes.section() - >>> section3["ids"] = ["positional-arguments"] - >>> _is_examples_section(section3) - False - >>> _is_examples_section(nodes.paragraph()) - False - >>> _is_examples_section(nodes.literal_block(text="examples")) - False - - With custom configuration: - - >>> custom_config = ExemplarConfig(examples_term_suffix="demos") - >>> section = nodes.section() - >>> section["ids"] = ["demos"] - >>> _is_examples_section(section, config=custom_config) - True - >>> section2 = nodes.section() - >>> section2["ids"] = ["examples"] - >>> _is_examples_section(section2, config=custom_config) - False - """ - config = config or ExemplarConfig() - if not isinstance(node, nodes.section): - return False - ids: list[str] = node.get("ids", []) - return any(config.examples_term_suffix in id_str.lower() for id_str in ids) - - -def _reorder_nodes( - processed: list[nodes.Node], *, config: ExemplarConfig | None = None -) -> list[nodes.Node]: - """Reorder nodes so usage sections/blocks appear before examples sections. - - This ensures the CLI usage synopsis appears above examples in the - documentation, making it easier to understand command syntax before - seeing example invocations. - - The function handles both: - - Usage as literal_block (legacy format from older renderer) - - Usage as section#usage (new format with TOC support) - - Parameters - ---------- - processed : list[nodes.Node] - List of processed docutils nodes. - config : ExemplarConfig | None - Optional configuration. If None, uses default ExemplarConfig(). - - Returns - ------- - list[nodes.Node] - Reordered nodes with usage before examples (if enabled). - - Examples - -------- - >>> from docutils import nodes - - Create test nodes: - - >>> desc = nodes.paragraph(text="Description") - >>> examples = nodes.section() - >>> examples["ids"] = ["examples"] - >>> usage = nodes.literal_block(text="usage: cmd [-h]") - >>> args = nodes.section() - >>> args["ids"] = ["arguments"] - - When usage appears after examples, it gets moved before: - - >>> result = _reorder_nodes([desc, examples, usage, args]) - >>> [type(n).__name__ for n in result] - ['paragraph', 'literal_block', 'section', 'section'] - - When no examples exist, order is unchanged: - - >>> result = _reorder_nodes([desc, usage, args]) - >>> [type(n).__name__ for n in result] - ['paragraph', 'literal_block', 'section'] - - When usage already before examples, order is preserved: - - >>> result = _reorder_nodes([desc, usage, examples, args]) - >>> [type(n).__name__ for n in result] - ['paragraph', 'literal_block', 'section', 'section'] - - Empty list returns empty: - - >>> _reorder_nodes([]) - [] - - Usage sections (with TOC heading) are also handled: - - >>> usage_section = nodes.section() - >>> usage_section["ids"] = ["usage"] - >>> result = _reorder_nodes([desc, examples, usage_section, args]) - >>> [n.get("ids", []) for n in result if isinstance(n, nodes.section)] - [['usage'], ['examples'], ['arguments']] - - Reordering can be disabled via config: - - >>> no_reorder_config = ExemplarConfig(reorder_usage_before_examples=False) - >>> result = _reorder_nodes([desc, examples, usage, args], config=no_reorder_config) - >>> [type(n).__name__ for n in result] - ['paragraph', 'section', 'literal_block', 'section'] - """ - config = config or ExemplarConfig() - - # If reordering is disabled, return as-is - if not config.reorder_usage_before_examples: - return processed - - # First pass: check if there are any examples sections - has_examples = any(_is_examples_section(node, config=config) for node in processed) - if not has_examples: - # No examples, preserve original order - return processed - - usage_nodes: list[nodes.Node] = [] - examples_sections: list[nodes.Node] = [] - other_before_examples: list[nodes.Node] = [] - other_after_examples: list[nodes.Node] = [] - - seen_examples = False - for node in processed: - # Check for both usage block (literal_block) and usage section - if _is_usage_block(node, config=config) or _is_usage_section(node): - usage_nodes.append(node) - elif _is_examples_section(node, config=config): - examples_sections.append(node) - seen_examples = True - elif not seen_examples: - other_before_examples.append(node) - else: - other_after_examples.append(node) - - # Order: before_examples → usage → examples → after_examples - return ( - other_before_examples + usage_nodes + examples_sections + other_after_examples - ) - - -def _extract_sections_from_container( - container: nodes.Node, -) -> tuple[nodes.Node, list[nodes.section]]: - """Extract section nodes from a container, returning modified container. - - This function finds any section nodes that are children of the container - (typically argparse_program), removes them from the container, and returns - them separately so they can be made siblings. - - This is needed because Sphinx's TocTreeCollector only discovers sections - that are direct children of the document or properly nested in the section - hierarchy - sections inside arbitrary div containers are invisible to TOC. - - Parameters - ---------- - container : nodes.Node - A container node (typically argparse_program) that may contain sections. - - Returns - ------- - tuple[nodes.Node, list[nodes.section]] - A tuple of (modified_container, extracted_sections). - - Examples - -------- - >>> from docutils import nodes - >>> from sphinx_argparse_neo.nodes import argparse_program - >>> container = argparse_program() - >>> para = nodes.paragraph(text="Description") - >>> examples = nodes.section() - >>> examples["ids"] = ["examples"] - >>> container += para - >>> container += examples - >>> modified, extracted = _extract_sections_from_container(container) - >>> len(modified.children) - 1 - >>> len(extracted) - 1 - >>> extracted[0]["ids"] - ['examples'] - """ - if not hasattr(container, "children"): - return container, [] - - extracted_sections: list[nodes.section] = [] - remaining_children: list[nodes.Node] = [] - - for child in container.children: - if isinstance(child, nodes.section): - extracted_sections.append(child) - else: - remaining_children.append(child) - - # Update container with remaining children only - container[:] = remaining_children # type: ignore[index] - - return container, extracted_sections - - -class CleanArgParseDirective(ArgparseDirective): # type: ignore[misc] - """ArgParse directive that strips ANSI codes and formats examples.""" - - def run(self) -> list[nodes.Node]: - """Run the directive, clean output, format examples, and reorder. - - The processing pipeline: - 1. Run base directive to get initial nodes - 2. Load configuration from Sphinx config - 3. Process each node (strip ANSI, transform examples definition lists) - 4. Extract sections from inside argparse_program containers - 5. Reorder so usage appears before examples (if enabled) - """ - result = super().run() - - # Load configuration from Sphinx - config = ExemplarConfig.from_sphinx_config(self.env.config) - - # Extract page name for unique section IDs across different CLI pages - page_prefix = "" - if hasattr(self.state, "document"): - settings = self.state.document.settings - if hasattr(settings, "env") and hasattr(settings.env, "docname"): - # docname is like "cli/sync" - extract "sync" - docname = settings.env.docname - page_prefix = docname.split("/")[-1] - - processed: list[nodes.Node] = [] - for node in result: - processed_node = process_node(node, page_prefix=page_prefix, config=config) - if isinstance(processed_node, list): - processed.extend(processed_node) - else: - processed.append(processed_node) - - # Extract sections from inside argparse_program containers - # This is needed because sections inside divs are invisible to Sphinx TOC - flattened: list[nodes.Node] = [] - for node in processed: - # Check if this is an argparse_program (or similar container) - # that might have sections inside - node_class_name = type(node).__name__ - if node_class_name == "argparse_program": - modified, extracted = _extract_sections_from_container(node) - flattened.append(modified) - flattened.extend(extracted) - else: - flattened.append(node) - - # Reorder: usage sections/blocks before examples sections - return _reorder_nodes(flattened, config=config) - - -def setup(app: Sphinx) -> dict[str, t.Any]: - """Register the clean argparse directive, lexers, and CLI roles. - - Configuration Options - --------------------- - The following configuration options can be set in conf.py: - - ``argparse_examples_term_suffix`` : str (default: "examples") - Term must end with this string to be treated as examples header. - - ``argparse_examples_base_term`` : str (default: "examples") - Exact match for the base examples section. - - ``argparse_examples_section_title`` : str (default: "Examples") - Title used for the base examples section. - - ``argparse_usage_pattern`` : str (default: "usage:") - Text must start with this to be treated as a usage block. - - ``argparse_examples_command_prefix`` : str (default: "$ ") - Prefix added to each command line in examples code blocks. - - ``argparse_examples_code_language`` : str (default: "console") - Language identifier for examples code blocks. - - ``argparse_examples_code_classes`` : list[str] (default: ["highlight-console"]) - CSS classes added to examples code blocks. - - ``argparse_usage_code_language`` : str (default: "cli-usage") - Language identifier for usage blocks. - - ``argparse_reorder_usage_before_examples`` : bool (default: True) - Whether to reorder nodes so usage appears before examples. - - Parameters - ---------- - app : Sphinx - The Sphinx application object. - - Returns - ------- - dict - Extension metadata. - """ - # Load the base sphinx_argparse_neo extension first - app.setup_extension("sphinx_argparse_neo") - - # Register configuration options - app.add_config_value("argparse_examples_term_suffix", "examples", "html") - app.add_config_value("argparse_examples_base_term", "examples", "html") - app.add_config_value("argparse_examples_section_title", "Examples", "html") - app.add_config_value("argparse_usage_pattern", "usage:", "html") - app.add_config_value("argparse_examples_command_prefix", "$ ", "html") - app.add_config_value("argparse_examples_code_language", "console", "html") - app.add_config_value( - "argparse_examples_code_classes", ["highlight-console"], "html" - ) - app.add_config_value("argparse_usage_code_language", "cli-usage", "html") - app.add_config_value("argparse_reorder_usage_before_examples", True, "html") - - # Override the argparse directive with our enhanced version - app.add_directive("argparse", CleanArgParseDirective, override=True) - - # Register CLI usage lexer for usage block highlighting - from cli_usage_lexer import CLIUsageLexer - - app.add_lexer("cli-usage", CLIUsageLexer) - - # Register argparse lexers for help output highlighting - from argparse_lexer import ( - ArgparseHelpLexer, - ArgparseLexer, - ArgparseUsageLexer, - ) - - app.add_lexer("argparse", ArgparseLexer) - app.add_lexer("argparse-usage", ArgparseUsageLexer) - app.add_lexer("argparse-help", ArgparseHelpLexer) - - # Register CLI inline roles for documentation - from argparse_roles import register_roles - - register_roles() - - return {"version": "4.0", "parallel_read_safe": True} diff --git a/docs/_ext/argparse_lexer.py b/docs/_ext/argparse_lexer.py deleted file mode 100644 index 14aed55649..0000000000 --- a/docs/_ext/argparse_lexer.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Pygments lexers for argparse help output. - -This module provides custom Pygments lexers for highlighting argparse-generated -command-line help text, including usage lines, section headers, and full help output. - -Three lexer classes are provided: -- ArgparseUsageLexer: For usage lines only -- ArgparseHelpLexer: For full -h output (delegates usage to ArgparseUsageLexer) -- ArgparseLexer: Smart auto-detecting wrapper -""" - -from __future__ import annotations - -from pygments.lexer import RegexLexer, bygroups, include -from pygments.token import Generic, Name, Operator, Punctuation, Text, Whitespace - - -class ArgparseUsageLexer(RegexLexer): - """Lexer for argparse usage lines only. - - Handles patterns like: - - usage: PROG [-h] [--foo FOO] bar {a,b,c} - - Mutually exclusive: [-a | -b], (--foo | --bar) - - Choices: {json,yaml,table} - - Variadic: FILE ..., [FILE ...], [--foo [FOO]] - - Examples - -------- - >>> from pygments.token import Token - >>> lexer = ArgparseUsageLexer() - >>> tokens = list(lexer.get_tokens("usage: cmd [-h]")) - >>> tokens[0] - (Token.Generic.Heading, 'usage:') - >>> tokens[2] - (Token.Name.Label, 'cmd') - """ - - name = "Argparse Usage" - aliases = ["argparse-usage"] # noqa: RUF012 - filenames: list[str] = [] # noqa: RUF012 - mimetypes = ["text/x-argparse-usage"] # noqa: RUF012 - - tokens = { # noqa: RUF012 - "root": [ - # "usage:" at start of line - then look for program name - ( - r"^(usage:)(\s+)", - bygroups(Generic.Heading, Whitespace), # type: ignore[no-untyped-call] - "after_usage", - ), - # Continuation lines (leading whitespace for wrapped usage) - (r"^(\s+)(?=\S)", Whitespace), - include("inline"), - ], - "after_usage": [ - # Whitespace - (r"\s+", Whitespace), - # Program name (first lowercase word after usage:) - (r"\b[a-z][-a-z0-9_]*\b", Name.Label, "usage_body"), - # Fallback to inline if something unexpected - include("inline"), - ], - "usage_body": [ - # Whitespace - (r"\s+", Whitespace), - # Ellipsis for variadic args (before other patterns) - (r"\.\.\.", Punctuation), - # Long options with = value (e.g., --log-level=VALUE) - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with space-separated value (e.g., -S socket-path) - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # Opening brace - enter choices state - (r"\{", Punctuation, "choices"), - # Opening bracket - enter optional state - (r"\[", Punctuation, "optional"), - # Closing bracket (fallback for unmatched) - (r"\]", Punctuation), - # Opening paren - enter required mutex state - (r"\(", Punctuation, "required"), - # Closing paren (fallback for unmatched) - (r"\)", Punctuation), - # Choice separator (pipe) for mutex groups - (r"\|", Operator), - # UPPERCASE meta-variables (COMMAND, FILE, PATH) - (r"\b[A-Z][A-Z0-9_]*\b", Name.Variable), - # Subcommand/positional names (Name.Function for distinct styling) - (r"\b[a-z][-a-z0-9_]*\b", Name.Function), - # Catch-all for any other text - (r"[^\s\[\]|(){},]+", Text), - ], - "inline": [ - # Whitespace - (r"\s+", Whitespace), - # Ellipsis for variadic args (before other patterns) - (r"\.\.\.", Punctuation), - # Long options with = value (e.g., --log-level=VALUE) - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with space-separated value (e.g., -S socket-path) - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # Opening brace - enter choices state - (r"\{", Punctuation, "choices"), - # Opening bracket - enter optional state - (r"\[", Punctuation, "optional"), - # Closing bracket (fallback for unmatched) - (r"\]", Punctuation), - # Opening paren - enter required mutex state - (r"\(", Punctuation, "required"), - # Closing paren (fallback for unmatched) - (r"\)", Punctuation), - # Choice separator (pipe) for mutex groups - (r"\|", Operator), - # UPPERCASE meta-variables (COMMAND, FILE, PATH) - (r"\b[A-Z][A-Z0-9_]*\b", Name.Variable), - # Positional/command names (lowercase with dashes) - (r"\b[a-z][-a-z0-9_]*\b", Name.Label), - # Catch-all for any other text - (r"[^\s\[\]|(){},]+", Text), - ], - "optional": [ - # Nested optional bracket - (r"\[", Punctuation, "#push"), - # End optional - (r"\]", Punctuation, "#pop"), - # Contents use usage_body rules (subcommands are green) - include("usage_body"), - ], - "required": [ - # Nested required paren - (r"\(", Punctuation, "#push"), - # End required - (r"\)", Punctuation, "#pop"), - # Contents use usage_body rules (subcommands are green) - include("usage_body"), - ], - "choices": [ - # Choice values (comma-separated inside braces) - (r"[a-zA-Z0-9][-a-zA-Z0-9_]*", Name.Constant), - # Comma separator - (r",", Punctuation), - # End choices - (r"\}", Punctuation, "#pop"), - # Whitespace - (r"\s+", Whitespace), - ], - } - - -class ArgparseHelpLexer(RegexLexer): - """Lexer for full argparse -h help output. - - Handles: - - Usage lines (delegates to ArgparseUsageLexer patterns) - - Section headers (positional arguments:, options:, etc.) - - Option entries with help text - - Indented descriptions - - Examples - -------- - >>> from pygments.token import Token - >>> lexer = ArgparseHelpLexer() - >>> tokens = list(lexer.get_tokens("positional arguments:")) - >>> any(t[0] == Token.Generic.Subheading for t in tokens) - True - >>> tokens = list(lexer.get_tokens(" -h, --help show help")) - >>> any(t[0] == Token.Name.Attribute for t in tokens) - True - """ - - name = "Argparse Help" - aliases = ["argparse-help"] # noqa: RUF012 - filenames: list[str] = [] # noqa: RUF012 - mimetypes = ["text/x-argparse-help"] # noqa: RUF012 - - tokens = { # noqa: RUF012 - "root": [ - # "usage:" line - switch to after_usage to find program name - ( - r"^(usage:)(\s+)", - bygroups(Generic.Heading, Whitespace), # type: ignore[no-untyped-call] - "after_usage", - ), - # Section headers (e.g., "positional arguments:", "options:") - (r"^([a-zA-Z][-a-zA-Z0-9_ ]*:)\s*$", Generic.Subheading), - # Option entry lines (indented with spaces/tabs, not just newlines) - (r"^([ \t]+)", Whitespace, "option_line"), - # Continuation of usage (leading spaces/tabs followed by content) - (r"^([ \t]+)(?=\S)", Whitespace), - # Anything else (must match at least one char to avoid infinite loop) - (r".+\n?", Text), - # Standalone newlines - (r"\n", Whitespace), - ], - "after_usage": [ - # Whitespace - (r"\s+", Whitespace), - # Program name (first lowercase word after usage:) - (r"\b[a-z][-a-z0-9_]*\b", Name.Label, "usage"), - # Fallback to usage if something unexpected - include("usage_inline"), - ], - "usage": [ - # End of usage on blank line or section header - (r"\n(?=[a-zA-Z][-a-zA-Z0-9_ ]*:\s*$)", Text, "#pop:2"), - (r"\n(?=\n)", Text, "#pop:2"), - # Usage content - use usage_inline rules (subcommands are green) - include("usage_inline"), - # Line continuation - (r"\n", Text), - ], - "usage_inline": [ - # Whitespace - (r"\s+", Whitespace), - # Ellipsis for variadic args - (r"\.\.\.", Punctuation), - # Long options with = value - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with value - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # Choices in braces - (r"\{", Punctuation, "choices"), - # Optional brackets - (r"\[", Punctuation, "optional"), - (r"\]", Punctuation), - # Required parens (mutex) - (r"\(", Punctuation, "required"), - (r"\)", Punctuation), - # Pipe for mutex - (r"\|", Operator), - # UPPERCASE metavars - (r"\b[A-Z][A-Z0-9_]*\b", Name.Variable), - # Subcommand/positional names (Name.Function for distinct styling) - (r"\b[a-z][-a-z0-9_]*\b", Name.Function), - # Other text - (r"[^\s\[\]|(){},\n]+", Text), - ], - "option_line": [ - # Short option with comma (e.g., "-h, --help") - ( - r"(-[a-zA-Z0-9])(,)(\s*)(--[a-zA-Z0-9][-a-zA-Z0-9]*)", - bygroups(Name.Attribute, Punctuation, Whitespace, Name.Tag), # type: ignore[no-untyped-call] - ), - # Long options with = value - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9_]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options with space-separated metavar - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(\s+)([A-Z][A-Z0-9_]+)", - bygroups(Name.Tag, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with metavar - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]+)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # Choices in braces - (r"\{", Punctuation, "option_choices"), - # Help text (everything after double space or large gap) - (r"([ \t]{2,})(.+)$", bygroups(Whitespace, Text)), # type: ignore[no-untyped-call] - # End of line - MUST come before \s+ to properly pop on newlines - (r"\n", Text, "#pop"), - # Other whitespace (spaces/tabs only, not newlines) - (r"[ \t]+", Whitespace), - # UPPERCASE metavars - (r"\b[A-Z][A-Z0-9_]*\b", Name.Variable), - # Anything else on the line - (r"[^\s\n]+", Text), - ], - "optional": [ - (r"\[", Punctuation, "#push"), - (r"\]", Punctuation, "#pop"), - include("usage_inline"), - ], - "required": [ - (r"\(", Punctuation, "#push"), - (r"\)", Punctuation, "#pop"), - include("usage_inline"), - ], - "choices": [ - (r"[a-zA-Z0-9][-a-zA-Z0-9_]*", Name.Constant), - (r",", Punctuation), - (r"\}", Punctuation, "#pop"), - (r"\s+", Whitespace), - ], - "option_choices": [ - (r"[a-zA-Z0-9][-a-zA-Z0-9_]*", Name.Constant), - (r",", Punctuation), - (r"\}", Punctuation, "#pop"), - (r"\s+", Whitespace), - ], - } - - -class ArgparseLexer(ArgparseHelpLexer): - """Smart auto-detecting lexer for argparse output. - - Inherits from ArgparseHelpLexer to properly handle Pygments' metaclass - token processing. Using inheritance (not token dict copying) avoids - shared mutable state that causes memory corruption. - - This is the recommended lexer for general argparse highlighting. - - Examples - -------- - >>> from pygments.token import Token - >>> lexer = ArgparseLexer() - - Usage line detection: - - >>> tokens = list(lexer.get_tokens("usage: cmd [-h]")) - >>> tokens[0] - (Token.Generic.Heading, 'usage:') - - Section header detection (Pygments appends newline to input): - - >>> tokens = list(lexer.get_tokens("positional arguments:")) - >>> any(t[0] == Token.Generic.Subheading for t in tokens) - True - - Option highlighting in option line context: - - >>> tokens = list(lexer.get_tokens(" -h, --help show help")) - >>> any(t[0] == Token.Name.Attribute for t in tokens) - True - """ - - name = "Argparse" - aliases = ["argparse"] # noqa: RUF012 - filenames: list[str] = [] # noqa: RUF012 - mimetypes = ["text/x-argparse"] # noqa: RUF012 - - # Tokens inherited from ArgparseHelpLexer - do NOT redefine or copy - - -def tokenize_argparse(text: str) -> list[tuple[str, str]]: - """Tokenize argparse text and return list of (token_type, value) tuples. - - Parameters - ---------- - text : str - Argparse help or usage text to tokenize. - - Returns - ------- - list[tuple[str, str]] - List of (token_type_name, text_value) tuples. - - Examples - -------- - >>> result = tokenize_argparse("usage: cmd [-h]") - >>> result[0] - ('Token.Generic.Heading', 'usage:') - >>> result[2] - ('Token.Name.Label', 'cmd') - - >>> result = tokenize_argparse("positional arguments:") - >>> any('Token.Generic.Subheading' in t[0] for t in result) - True - """ - lexer = ArgparseLexer() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] - - -def tokenize_usage(text: str) -> list[tuple[str, str]]: - """Tokenize usage text and return list of (token_type, value) tuples. - - Parameters - ---------- - text : str - CLI usage text to tokenize. - - Returns - ------- - list[tuple[str, str]] - List of (token_type_name, text_value) tuples. - - Examples - -------- - >>> result = tokenize_usage("usage: cmd [-h]") - >>> result[0] - ('Token.Generic.Heading', 'usage:') - >>> result[2] - ('Token.Name.Label', 'cmd') - >>> result[4] - ('Token.Punctuation', '[') - >>> result[5] - ('Token.Name.Attribute', '-h') - """ - lexer = ArgparseUsageLexer() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] diff --git a/docs/_ext/argparse_roles.py b/docs/_ext/argparse_roles.py deleted file mode 100644 index 86e5459a28..0000000000 --- a/docs/_ext/argparse_roles.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Docutils inline roles for CLI/argparse highlighting. - -This module provides custom docutils roles for inline highlighting of CLI -elements in reStructuredText and MyST documentation. - -Available roles: -- :cli-option: - CLI options (--verbose, -h) -- :cli-metavar: - Metavar placeholders (FILE, PATH) -- :cli-command: - Command names (sync, add) -- :cli-default: - Default values (None, "default") -- :cli-choice: - Choice values (json, yaml) -""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes -from docutils.parsers.rst import roles - -if t.TYPE_CHECKING: - from docutils.parsers.rst.states import Inliner - - -def normalize_options(options: dict[str, t.Any] | None) -> dict[str, t.Any]: - """Normalize role options, converting None to empty dict. - - Parameters - ---------- - options : dict | None - Options passed to the role. - - Returns - ------- - dict - Normalized options dict (never None). - - Examples - -------- - >>> normalize_options(None) - {} - >>> normalize_options({"class": "custom"}) - {'class': 'custom'} - """ - return options if options is not None else {} - - -def cli_option_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI options like --foo or -h. - - Generates a literal node with appropriate CSS classes for styling. - Long options (--foo) get 'cli-option-long', short options (-h) get - 'cli-option-short'. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role (has .reporter, .document). - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_option_role( - ... "cli-option", ":cli-option:`--verbose`", "--verbose", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-option', 'cli-option-long'] - - >>> node_list, messages = cli_option_role( - ... "cli-option", ":cli-option:`-h`", "-h", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-option', 'cli-option-short'] - - >>> node_list, messages = cli_option_role( - ... "cli-option", ":cli-option:`--no-color`", "--no-color", - ... 1, None - ... ) - >>> node_list[0].astext() - '--no-color' - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-option"]) - - if text.startswith("--"): - node["classes"].append("cli-option-long") - elif text.startswith("-"): - node["classes"].append("cli-option-short") - - return [node], [] - - -def cli_metavar_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI metavar placeholders like FILE or PATH. - - Generates a literal node with 'cli-metavar' CSS class for styling. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role. - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_metavar_role( - ... "cli-metavar", ":cli-metavar:`FILE`", "FILE", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-metavar'] - >>> node_list[0].astext() - 'FILE' - - >>> node_list, messages = cli_metavar_role( - ... "cli-metavar", ":cli-metavar:`PATH`", "PATH", - ... 1, None - ... ) - >>> "cli-metavar" in node_list[0]["classes"] - True - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-metavar"]) - return [node], [] - - -def cli_command_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI command names like sync or add. - - Generates a literal node with 'cli-command' CSS class for styling. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role. - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_command_role( - ... "cli-command", ":cli-command:`sync`", "sync", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-command'] - >>> node_list[0].astext() - 'sync' - - >>> node_list, messages = cli_command_role( - ... "cli-command", ":cli-command:`myapp`", "myapp", - ... 1, None - ... ) - >>> "cli-command" in node_list[0]["classes"] - True - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-command"]) - return [node], [] - - -def cli_default_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI default values like None or "default". - - Generates a literal node with 'cli-default' CSS class for styling. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role. - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_default_role( - ... "cli-default", ":cli-default:`None`", "None", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-default'] - >>> node_list[0].astext() - 'None' - - >>> node_list, messages = cli_default_role( - ... "cli-default", ':cli-default:`"auto"`', '"auto"', - ... 1, None - ... ) - >>> "cli-default" in node_list[0]["classes"] - True - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-default"]) - return [node], [] - - -def cli_choice_role( - name: str, - rawtext: str, - text: str, - lineno: int, - inliner: Inliner | None, - options: dict[str, t.Any] | None = None, - content: list[str] | None = None, -) -> tuple[list[nodes.Node], list[nodes.system_message]]: - """Role for CLI choice values like json or yaml. - - Generates a literal node with 'cli-choice' CSS class for styling. - - Parameters - ---------- - name : str - Local name of the role used in document. - rawtext : str - Full interpreted text including role markup. - text : str - Content between backticks. - lineno : int - Line number. - inliner : Inliner | None - Object that called the role. - options : dict | None - Options from role directive. - content : list | None - Content from role directive. - - Returns - ------- - tuple[list[nodes.Node], list[nodes.system_message]] - Nodes to insert and any messages. - - Examples - -------- - >>> node_list, messages = cli_choice_role( - ... "cli-choice", ":cli-choice:`json`", "json", - ... 1, None - ... ) - >>> node_list[0]["classes"] - ['cli-choice'] - >>> node_list[0].astext() - 'json' - - >>> node_list, messages = cli_choice_role( - ... "cli-choice", ":cli-choice:`yaml`", "yaml", - ... 1, None - ... ) - >>> "cli-choice" in node_list[0]["classes"] - True - """ - options = normalize_options(options) - node = nodes.literal(rawtext, text, classes=["cli-choice"]) - return [node], [] - - -def register_roles() -> None: - """Register all CLI roles with docutils. - - This function registers the following roles: - - cli-option: For CLI options (--verbose, -h) - - cli-metavar: For metavar placeholders (FILE, PATH) - - cli-command: For command names (sync, add) - - cli-default: For default values (None, "default") - - cli-choice: For choice values (json, yaml) - - Examples - -------- - >>> register_roles() - >>> # Roles are now available in docutils RST parsing - """ - roles.register_local_role("cli-option", cli_option_role) # type: ignore[arg-type] - roles.register_local_role("cli-metavar", cli_metavar_role) # type: ignore[arg-type] - roles.register_local_role("cli-command", cli_command_role) # type: ignore[arg-type] - roles.register_local_role("cli-default", cli_default_role) # type: ignore[arg-type] - roles.register_local_role("cli-choice", cli_choice_role) # type: ignore[arg-type] diff --git a/docs/_ext/cli_usage_lexer.py b/docs/_ext/cli_usage_lexer.py deleted file mode 100644 index 40170e3178..0000000000 --- a/docs/_ext/cli_usage_lexer.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Pygments lexer for CLI usage/help output. - -This module provides a custom Pygments lexer for highlighting command-line -usage text typically generated by argparse, getopt, or similar libraries. -""" - -from __future__ import annotations - -from pygments.lexer import RegexLexer, bygroups, include -from pygments.token import Generic, Name, Operator, Punctuation, Text, Whitespace - - -class CLIUsageLexer(RegexLexer): - """Lexer for CLI usage/help text (argparse, etc.). - - Highlights usage patterns including options, arguments, and meta-variables. - - Examples - -------- - >>> from pygments.token import Token - >>> lexer = CLIUsageLexer() - >>> tokens = list(lexer.get_tokens("usage: cmd [-h]")) - >>> tokens[0] - (Token.Generic.Heading, 'usage:') - >>> tokens[2] - (Token.Name.Label, 'cmd') - """ - - name = "CLI Usage" - aliases = ["cli-usage", "usage"] # noqa: RUF012 - filenames: list[str] = [] # noqa: RUF012 - mimetypes = ["text/x-cli-usage"] # noqa: RUF012 - - tokens = { # noqa: RUF012 - "root": [ - # "usage:" at start of line - (r"^(usage:)(\s+)", bygroups(Generic.Heading, Whitespace)), # type: ignore[no-untyped-call] - # Continuation lines (leading whitespace for wrapped usage) - (r"^(\s+)(?=\S)", Whitespace), - include("inline"), - ], - "inline": [ - # Whitespace - (r"\s+", Whitespace), - # Long options with = value (e.g., --log-level=VALUE) - ( - r"(--[a-zA-Z0-9][-a-zA-Z0-9]*)(=)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)", - bygroups(Name.Tag, Operator, Name.Variable), # type: ignore[no-untyped-call] - ), - # Long options standalone - (r"--[a-zA-Z0-9][-a-zA-Z0-9]*", Name.Tag), - # Short options with space-separated value (e.g., -S socket-path) - ( - r"(-[a-zA-Z0-9])(\s+)([A-Z][A-Z0-9_]*|[a-z][-a-z0-9]*)", - bygroups(Name.Attribute, Whitespace, Name.Variable), # type: ignore[no-untyped-call] - ), - # Short options standalone - (r"-[a-zA-Z0-9]", Name.Attribute), - # UPPERCASE meta-variables (COMMAND, FILE, PATH) - (r"\b[A-Z][A-Z0-9_]+\b", Name.Constant), - # Opening bracket - enter optional state - (r"\[", Punctuation, "optional"), - # Closing bracket (fallback for unmatched) - (r"\]", Punctuation), - # Choice separator (pipe) - (r"\|", Operator), - # Parentheses for grouping - (r"[()]", Punctuation), - # Positional/command names (lowercase with dashes) - (r"\b[a-z][-a-z0-9]*\b", Name.Label), - # Catch-all for any other text - (r"[^\s\[\]|()]+", Text), - ], - "optional": [ - # Nested optional bracket - (r"\[", Punctuation, "#push"), - # End optional - (r"\]", Punctuation, "#pop"), - # Contents use inline rules - include("inline"), - ], - } - - -def tokenize_usage(text: str) -> list[tuple[str, str]]: - """Tokenize usage text and return list of (token_type, value) tuples. - - Parameters - ---------- - text : str - CLI usage text to tokenize. - - Returns - ------- - list[tuple[str, str]] - List of (token_type_name, text_value) tuples. - - Examples - -------- - >>> result = tokenize_usage("usage: cmd [-h]") - >>> result[0] - ('Token.Generic.Heading', 'usage:') - >>> result[2] - ('Token.Name.Label', 'cmd') - >>> result[4] - ('Token.Punctuation', '[') - >>> result[5] - ('Token.Name.Attribute', '-h') - >>> result[6] - ('Token.Punctuation', ']') - """ - lexer = CLIUsageLexer() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] diff --git a/docs/_ext/sphinx_argparse_neo/__init__.py b/docs/_ext/sphinx_argparse_neo/__init__.py deleted file mode 100644 index 5fa8dd94fe..0000000000 --- a/docs/_ext/sphinx_argparse_neo/__init__.py +++ /dev/null @@ -1,101 +0,0 @@ -"""sphinx_argparse_neo - Modern sphinx-argparse replacement. - -A Sphinx extension for documenting argparse-based CLI tools that: -- Works with Sphinx 8.x AND 9.x (no autodoc.mock dependency) -- Fixes long-standing sphinx-argparse issues (TOC pollution, heading levels) -- Provides configurable output (rubrics vs sections, flattened subcommands) -- Supports extensibility via renderer classes -- Text processing utilities (ANSI stripping) -""" - -from __future__ import annotations - -import typing as t - -from sphinx_argparse_neo.directive import ArgparseDirective -from sphinx_argparse_neo.nodes import ( - argparse_argument, - argparse_group, - argparse_program, - argparse_subcommand, - argparse_subcommands, - argparse_usage, - depart_argparse_argument_html, - depart_argparse_group_html, - depart_argparse_program_html, - depart_argparse_subcommand_html, - depart_argparse_subcommands_html, - depart_argparse_usage_html, - visit_argparse_argument_html, - visit_argparse_group_html, - visit_argparse_program_html, - visit_argparse_subcommand_html, - visit_argparse_subcommands_html, - visit_argparse_usage_html, -) -from sphinx_argparse_neo.utils import strip_ansi - -__all__ = [ - "ArgparseDirective", - "strip_ansi", -] - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - -__version__ = "1.0.0" - - -def setup(app: Sphinx) -> dict[str, t.Any]: - """Register the argparse directive and configuration options. - - Parameters - ---------- - app : Sphinx - The Sphinx application object. - - Returns - ------- - dict[str, t.Any] - Extension metadata. - """ - # Configuration options - app.add_config_value("argparse_group_title_prefix", "", "html") - app.add_config_value("argparse_show_defaults", True, "html") - app.add_config_value("argparse_show_choices", True, "html") - app.add_config_value("argparse_show_types", True, "html") - - # Register custom nodes - app.add_node( - argparse_program, - html=(visit_argparse_program_html, depart_argparse_program_html), - ) - app.add_node( - argparse_usage, - html=(visit_argparse_usage_html, depart_argparse_usage_html), - ) - app.add_node( - argparse_group, - html=(visit_argparse_group_html, depart_argparse_group_html), - ) - app.add_node( - argparse_argument, - html=(visit_argparse_argument_html, depart_argparse_argument_html), - ) - app.add_node( - argparse_subcommands, - html=(visit_argparse_subcommands_html, depart_argparse_subcommands_html), - ) - app.add_node( - argparse_subcommand, - html=(visit_argparse_subcommand_html, depart_argparse_subcommand_html), - ) - - # Register directive - app.add_directive("argparse", ArgparseDirective) - - return { - "version": __version__, - "parallel_read_safe": True, - "parallel_write_safe": True, - } diff --git a/docs/_ext/sphinx_argparse_neo/compat.py b/docs/_ext/sphinx_argparse_neo/compat.py deleted file mode 100644 index 15816d574c..0000000000 --- a/docs/_ext/sphinx_argparse_neo/compat.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Compatibility utilities for module loading. - -This module provides utilities for loading Python modules safely, -including mock handling for imports that may fail during documentation -builds. - -Unlike sphinx-argparse, this module does NOT depend on autodoc's mock -functionality, which moved in Sphinx 9.x. -""" - -from __future__ import annotations - -import contextlib -import importlib -import sys -import typing as t - -if t.TYPE_CHECKING: - import argparse - from collections.abc import Iterator - - -class MockModule: - """Simple mock for unavailable imports. - - This class provides a minimal mock that can be used as a placeholder - for modules that aren't available during documentation builds. - - Parameters - ---------- - name : str - The module name being mocked. - - Examples - -------- - >>> mock = MockModule("mypackage.submodule") - >>> mock.__name__ - 'mypackage.submodule' - >>> child = mock.child_attr - >>> child.__name__ - 'mypackage.submodule.child_attr' - >>> callable(mock.some_function) - True - >>> mock.some_function() - - """ - - def __init__(self, name: str) -> None: - """Initialize the mock module.""" - self.__name__ = name - self._name = name - - def __repr__(self) -> str: - """Return string representation.""" - return f"" - - def __getattr__(self, name: str) -> MockModule: - """Return a child mock for any attribute access. - - Parameters - ---------- - name : str - The attribute name. - - Returns - ------- - MockModule - A new mock for the child attribute. - """ - return MockModule(f"{self._name}.{name}") - - def __call__(self, *args: t.Any, **kwargs: t.Any) -> MockModule: - """Return self when called as a function. - - Parameters - ---------- - *args : t.Any - Positional arguments (ignored). - **kwargs : t.Any - Keyword arguments (ignored). - - Returns - ------- - MockModule - Self. - """ - return self - - -@contextlib.contextmanager -def mock_imports(modules: list[str]) -> Iterator[None]: - """Context manager to mock missing imports. - - This provides a simple way to temporarily add mock modules to - sys.modules, allowing imports to succeed during documentation builds - even when the actual modules aren't available. - - Parameters - ---------- - modules : list[str] - List of module names to mock. - - Yields - ------ - None - Context manager yields nothing. - - Examples - -------- - >>> import sys - >>> "fake_module" in sys.modules - False - >>> with mock_imports(["fake_module", "fake_module.sub"]): - ... import fake_module - ... fake_module.__name__ - 'fake_module' - >>> "fake_module" in sys.modules - False - """ - mocked: dict[str, MockModule] = {} - - for name in modules: - if name not in sys.modules: - mocked[name] = MockModule(name) - sys.modules[name] = mocked[name] # type: ignore[assignment] - - try: - yield - finally: - for name in mocked: - del sys.modules[name] - - -def import_module(module_name: str) -> t.Any: - """Import a module by name. - - Parameters - ---------- - module_name : str - The fully qualified module name. - - Returns - ------- - t.Any - The imported module. - - Raises - ------ - ImportError - If the module cannot be imported. - - Examples - -------- - >>> mod = import_module("argparse") - >>> hasattr(mod, "ArgumentParser") - True - """ - return importlib.import_module(module_name) - - -def get_parser_from_module( - module_name: str, - func_name: str, - mock_modules: list[str] | None = None, -) -> argparse.ArgumentParser: - """Import a module and call a function to get an ArgumentParser. - - Parameters - ---------- - module_name : str - The module containing the parser factory function. - func_name : str - The name of the function that returns an ArgumentParser. - Can be a dotted path like "Class.method". - mock_modules : list[str] | None - Optional list of module names to mock during import. - - Returns - ------- - argparse.ArgumentParser - The argument parser returned by the function. - - Raises - ------ - ImportError - If the module cannot be imported. - AttributeError - If the function is not found. - TypeError - If the function doesn't return an ArgumentParser. - - Examples - -------- - Load tmuxp's parser factory: - - >>> parser = get_parser_from_module("tmuxp.cli", "create_parser") - >>> parser.prog - 'tmuxp' - >>> hasattr(parser, 'parse_args') - True - """ - ctx = mock_imports(mock_modules) if mock_modules else contextlib.nullcontext() - - with ctx: - module = import_module(module_name) - - # Handle dotted paths like "Class.method" - obj = module - for part in func_name.split("."): - obj = getattr(obj, part) - - # Call the function if it's callable - parser = obj() if callable(obj) else obj - - # Validate the return type at runtime - import argparse as argparse_module - - if not isinstance(parser, argparse_module.ArgumentParser): - msg = ( - f"{module_name}:{func_name} returned {type(parser).__name__}, " - f"expected ArgumentParser" - ) - raise TypeError(msg) - - return parser - - -def get_parser_from_entry_point( - entry_point: str, - mock_modules: list[str] | None = None, -) -> argparse.ArgumentParser: - """Get an ArgumentParser from a setuptools-style entry point string. - - Parameters - ---------- - entry_point : str - Entry point in the format "module:function" or "module:Class.method". - mock_modules : list[str] | None - Optional list of module names to mock during import. - - Returns - ------- - argparse.ArgumentParser - The argument parser. - - Raises - ------ - ValueError - If the entry point format is invalid. - - Examples - -------- - Load tmuxp's parser using entry point syntax: - - >>> parser = get_parser_from_entry_point("tmuxp.cli:create_parser") - >>> parser.prog - 'tmuxp' - - Invalid format raises ValueError: - - >>> get_parser_from_entry_point("no_colon") - Traceback (most recent call last): - ... - ValueError: Invalid entry point format: 'no_colon'. Expected 'module:function' - """ - if ":" not in entry_point: - msg = f"Invalid entry point format: {entry_point!r}. Expected 'module:function'" - raise ValueError(msg) - - module_name, func_name = entry_point.split(":", 1) - return get_parser_from_module(module_name, func_name, mock_modules) diff --git a/docs/_ext/sphinx_argparse_neo/directive.py b/docs/_ext/sphinx_argparse_neo/directive.py deleted file mode 100644 index 80d6d155ab..0000000000 --- a/docs/_ext/sphinx_argparse_neo/directive.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Sphinx directive for argparse documentation. - -This module provides the ArgparseDirective class that integrates -with Sphinx to generate documentation from ArgumentParser instances. -""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes -from docutils.parsers.rst import directives -from sphinx.util.docutils import SphinxDirective -from sphinx_argparse_neo.compat import get_parser_from_module -from sphinx_argparse_neo.parser import extract_parser -from sphinx_argparse_neo.renderer import ArgparseRenderer, RenderConfig - -if t.TYPE_CHECKING: - import argparse - - -class ArgparseDirective(SphinxDirective): - """Sphinx directive for documenting argparse-based CLI tools. - - Usage - ----- - .. argparse:: - :module: myapp.cli - :func: create_parser - :prog: myapp - - Options - ------- - :module: - The Python module containing the parser factory function. - :func: - The function name that returns an ArgumentParser. - Can be a dotted path like "Class.method". - :prog: - Override the program name (optional). - :path: - Navigate to a specific subparser by path (e.g., "sync pull"). - :no-defaults: - Don't show default values (flag). - :no-description: - Don't show parser description (flag). - :no-epilog: - Don't show parser epilog (flag). - :mock-modules: - Comma-separated list of modules to mock during import. - - Examples - -------- - In RST documentation:: - - .. argparse:: - :module: myapp.cli - :func: create_parser - :prog: myapp - - :path: subcommand - """ - - has_content = True - required_arguments = 0 - optional_arguments = 0 - - option_spec: t.ClassVar[dict[str, t.Any]] = { - "module": directives.unchanged_required, - "func": directives.unchanged_required, - "prog": directives.unchanged, - "path": directives.unchanged, - "no-defaults": directives.flag, - "no-description": directives.flag, - "no-epilog": directives.flag, - "no-choices": directives.flag, - "no-types": directives.flag, - "mock-modules": directives.unchanged, - # sphinx-argparse compatibility options - "nosubcommands": directives.flag, - "nodefault": directives.flag, - "noepilog": directives.flag, - "nodescription": directives.flag, - } - - def run(self) -> list[nodes.Node]: - """Execute the directive and return docutils nodes. - - Returns - ------- - list[nodes.Node] - List of docutils nodes representing the CLI documentation. - """ - # Get required options - module_name = self.options.get("module") - func_name = self.options.get("func") - - if not module_name or not func_name: - error = self.state_machine.reporter.error( - "argparse directive requires :module: and :func: options", - line=self.lineno, - ) - return [error] - - # Parse mock modules - mock_modules: list[str] | None = None - if "mock-modules" in self.options: - mock_modules = [m.strip() for m in self.options["mock-modules"].split(",")] - - # Load the parser - try: - parser = get_parser_from_module(module_name, func_name, mock_modules) - except Exception as e: - error = self.state_machine.reporter.error( - f"Failed to load parser from {module_name}:{func_name}: {e}", - line=self.lineno, - ) - return [error] - - # Override prog if specified - if "prog" in self.options: - parser.prog = self.options["prog"] - - # Navigate to subparser if path specified - if "path" in self.options: - parser = self._navigate_to_subparser(parser, self.options["path"]) - if parser is None: - error = self.state_machine.reporter.error( - f"Subparser path not found: {self.options['path']}", - line=self.lineno, - ) - return [error] - - # Build render config from directive options and Sphinx config - config = self._build_render_config() - - # Extract parser info - parser_info = extract_parser(parser) - - # Apply directive-level overrides - # Handle both new-style and sphinx-argparse compatibility options - if "no-description" in self.options or "nodescription" in self.options: - parser_info = parser_info.__class__( - prog=parser_info.prog, - usage=parser_info.usage, - bare_usage=parser_info.bare_usage, - description=None, - epilog=parser_info.epilog, - argument_groups=parser_info.argument_groups, - subcommands=parser_info.subcommands, - subcommand_dest=parser_info.subcommand_dest, - ) - if "no-epilog" in self.options or "noepilog" in self.options: - parser_info = parser_info.__class__( - prog=parser_info.prog, - usage=parser_info.usage, - bare_usage=parser_info.bare_usage, - description=parser_info.description, - epilog=None, - argument_groups=parser_info.argument_groups, - subcommands=parser_info.subcommands, - subcommand_dest=parser_info.subcommand_dest, - ) - if "nosubcommands" in self.options: - parser_info = parser_info.__class__( - prog=parser_info.prog, - usage=parser_info.usage, - bare_usage=parser_info.bare_usage, - description=parser_info.description, - epilog=parser_info.epilog, - argument_groups=parser_info.argument_groups, - subcommands=None, - subcommand_dest=None, - ) - - # Render to nodes - renderer = ArgparseRenderer(config=config, state=self.state) - return t.cast(list[nodes.Node], renderer.render(parser_info)) - - def _build_render_config(self) -> RenderConfig: - """Build RenderConfig from directive and Sphinx config options. - - Returns - ------- - RenderConfig - Configuration for the renderer. - """ - # Start with Sphinx config defaults - config = RenderConfig.from_sphinx_config(self.config) - - # Override with directive options - # Handle both new-style and sphinx-argparse compatibility options - if "no-defaults" in self.options or "nodefault" in self.options: - config.show_defaults = False - if "no-choices" in self.options: - config.show_choices = False - if "no-types" in self.options: - config.show_types = False - - return config - - def _navigate_to_subparser( - self, parser: argparse.ArgumentParser, path: str - ) -> argparse.ArgumentParser | None: - """Navigate to a nested subparser by path. - - Parameters - ---------- - parser : argparse.ArgumentParser - The root parser. - path : str - Space-separated path to the subparser (e.g., "sync pull"). - - Returns - ------- - argparse.ArgumentParser | None - The subparser, or None if not found. - """ - import argparse as argparse_module - - current = parser - for name in path.split(): - # Find subparsers action - subparser_action = None - for action in current._actions: - if isinstance(action, argparse_module._SubParsersAction): - subparser_action = action - break - - if subparser_action is None: - return None - - # Find the named subparser - choices = subparser_action.choices or {} - if name not in choices: - return None - - current = choices[name] - - return current diff --git a/docs/_ext/sphinx_argparse_neo/nodes.py b/docs/_ext/sphinx_argparse_neo/nodes.py deleted file mode 100644 index 468b5876a5..0000000000 --- a/docs/_ext/sphinx_argparse_neo/nodes.py +++ /dev/null @@ -1,647 +0,0 @@ -"""Custom docutils node types for argparse documentation. - -This module defines custom node types that represent the structure of -CLI documentation, along with HTML visitor functions for rendering. -""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes - -if t.TYPE_CHECKING: - from sphinx.writers.html5 import HTML5Translator - -# Import the lexer - use absolute import from parent package -import pathlib -import sys - -# Add parent directory to path for lexer import -_ext_dir = pathlib.Path(__file__).parent.parent -if str(_ext_dir) not in sys.path: - sys.path.insert(0, str(_ext_dir)) - -from argparse_lexer import ArgparseUsageLexer # noqa: E402 -from sphinx_argparse_neo.utils import strip_ansi # noqa: E402 - - -def _generate_argument_id(names: list[str], id_prefix: str = "") -> str: - """Generate unique ID for an argument based on its names. - - Creates a slug-style ID suitable for HTML anchors by: - 1. Stripping leading dashes from option names - 2. Joining multiple names with hyphens - 3. Prepending optional prefix for namespace isolation - - Parameters - ---------- - names : list[str] - List of argument names (e.g., ["-L", "--socket-name"]). - id_prefix : str - Optional prefix for uniqueness (e.g., "shell" -> "shell-L-socket-name"). - - Returns - ------- - str - A slug-style ID suitable for HTML anchors. - - Examples - -------- - >>> _generate_argument_id(["-L"]) - 'L' - >>> _generate_argument_id(["--help"]) - 'help' - >>> _generate_argument_id(["-v", "--verbose"]) - 'v-verbose' - >>> _generate_argument_id(["-L"], "shell") - 'shell-L' - >>> _generate_argument_id(["filename"]) - 'filename' - >>> _generate_argument_id([]) - '' - """ - clean_names = [name.lstrip("-") for name in names if name.lstrip("-")] - if not clean_names: - return "" - name_part = "-".join(clean_names) - return f"{id_prefix}-{name_part}" if id_prefix else name_part - - -def _token_to_css_class(token_type: t.Any) -> str: - """Map a Pygments token type to its CSS class abbreviation. - - Pygments uses hierarchical token names like Token.Name.Attribute. - These map to CSS classes using abbreviations of the last two parts: - - Token.Name.Attribute → 'na' (Name.Attribute) - - Token.Generic.Heading → 'gh' (Generic.Heading) - - Token.Punctuation → 'p' (just Punctuation) - - Parameters - ---------- - token_type : Any - A Pygments token type (from pygments.token). - - Returns - ------- - str - CSS class abbreviation, or empty string if not mappable. - - Examples - -------- - >>> from pygments.token import Token - >>> _token_to_css_class(Token.Name.Attribute) - 'na' - >>> _token_to_css_class(Token.Generic.Heading) - 'gh' - >>> _token_to_css_class(Token.Punctuation) - 'p' - >>> _token_to_css_class(Token.Text.Whitespace) - 'tw' - """ - type_str = str(token_type) - # Token string looks like "Token.Name.Attribute" or "Token.Punctuation" - parts = type_str.split(".") - - if len(parts) >= 3: - # Token.Name.Attribute -> "na" (first char of each of last two parts) - return parts[-2][0].lower() + parts[-1][0].lower() - elif len(parts) == 2: - # Token.Punctuation -> "p" (first char of last part) - return parts[-1][0].lower() - return "" - - -def _highlight_usage(usage_text: str, encode: t.Callable[[str], str]) -> str: - """Tokenize usage text and wrap tokens in highlighted span elements. - - Uses ArgparseUsageLexer to tokenize the usage string, then wraps each - token in a with the appropriate CSS class for styling. - - Parameters - ---------- - usage_text : str - The usage string to highlight (should include "usage: " prefix). - encode : Callable[[str], str] - HTML encoding function (typically translator.encode). - - Returns - ------- - str - HTML string with tokens wrapped in styled elements. - - Examples - -------- - >>> def mock_encode(s: str) -> str: - ... return s.replace("&", "&").replace("<", "<") - >>> html = _highlight_usage("usage: cmd [-h]", mock_encode) - >>> 'usage:' in html - True - >>> 'cmd' in html - True - >>> '-h' in html - True - """ - lexer = ArgparseUsageLexer() - parts: list[str] = [] - - for tok_type, tok_value in lexer.get_tokens(usage_text): - if not tok_value: - continue - - css_class = _token_to_css_class(tok_type) - escaped = encode(tok_value) - type_str = str(tok_type).lower() - - # Skip wrapping for whitespace and plain text tokens - if css_class and "whitespace" not in type_str and "text" not in type_str: - parts.append(f'{escaped}') - else: - parts.append(escaped) - - return "".join(parts) - - -def _highlight_argument_names( - names: list[str], metavar: str | None, encode: t.Callable[[str], str] -) -> str: - """Highlight argument names and metavar with appropriate CSS classes. - - Short options (-h) get class 'na' (Name.Attribute). - Long options (--help) get class 'nt' (Name.Tag). - Positional arguments get class 'nl' (Name.Label). - Metavars get class 'nv' (Name.Variable). - - Parameters - ---------- - names : list[str] - List of argument names (e.g., ["-v", "--verbose"]). - metavar : str | None - Optional metavar (e.g., "FILE", "PATH"). - encode : Callable[[str], str] - HTML encoding function. - - Returns - ------- - str - HTML string with highlighted argument signature. - - Examples - -------- - >>> def mock_encode(s: str) -> str: - ... return s - >>> html = _highlight_argument_names(["-h", "--help"], None, mock_encode) - >>> '-h' in html - True - >>> '--help' in html - True - >>> html = _highlight_argument_names(["--output"], "FILE", mock_encode) - >>> 'FILE' in html - True - >>> html = _highlight_argument_names(["sync"], None, mock_encode) - >>> 'sync' in html - True - """ - sig_parts: list[str] = [] - - for name in names: - escaped = encode(name) - if name.startswith("--"): - sig_parts.append(f'{escaped}') - elif name.startswith("-"): - sig_parts.append(f'{escaped}') - else: - # Positional argument or subcommand - sig_parts.append(f'{escaped}') - - result = ", ".join(sig_parts) - - if metavar: - escaped_metavar = encode(metavar) - result = f'{result} {escaped_metavar}' - - return result - - -class argparse_program(nodes.General, nodes.Element): - """Root node for an argparse program documentation block. - - Attributes - ---------- - prog : str - The program name. - - Examples - -------- - >>> node = argparse_program() - >>> node["prog"] = "myapp" - >>> node["prog"] - 'myapp' - """ - - pass - - -class argparse_usage(nodes.General, nodes.Element): - """Node for displaying program usage. - - Contains the usage string as a literal block. - - Examples - -------- - >>> node = argparse_usage() - >>> node["usage"] = "myapp [-h] [--verbose] command" - >>> node["usage"] - 'myapp [-h] [--verbose] command' - """ - - pass - - -class argparse_group(nodes.General, nodes.Element): - """Node for an argument group (positional, optional, or custom). - - Attributes - ---------- - title : str - The group title. - description : str | None - Optional group description. - - Examples - -------- - >>> node = argparse_group() - >>> node["title"] = "Output Options" - >>> node["title"] - 'Output Options' - """ - - pass - - -class argparse_argument(nodes.Part, nodes.Element): - """Node for a single CLI argument. - - Attributes - ---------- - names : list[str] - Argument names/flags. - help : str | None - Help text. - default : str | None - Default value string. - choices : list[str] | None - Available choices. - required : bool - Whether the argument is required. - metavar : str | None - Metavar for display. - - Examples - -------- - >>> node = argparse_argument() - >>> node["names"] = ["-v", "--verbose"] - >>> node["names"] - ['-v', '--verbose'] - """ - - pass - - -class argparse_subcommands(nodes.General, nodes.Element): - """Container node for subcommands section. - - Examples - -------- - >>> node = argparse_subcommands() - >>> node["title"] = "Commands" - >>> node["title"] - 'Commands' - """ - - pass - - -class argparse_subcommand(nodes.General, nodes.Element): - """Node for a single subcommand. - - Attributes - ---------- - name : str - Subcommand name. - aliases : list[str] - Subcommand aliases. - help : str | None - Subcommand help text. - - Examples - -------- - >>> node = argparse_subcommand() - >>> node["name"] = "sync" - >>> node["aliases"] = ["s"] - >>> node["name"] - 'sync' - """ - - pass - - -# HTML Visitor Functions - - -def visit_argparse_program_html(self: HTML5Translator, node: argparse_program) -> None: - """Visit argparse_program node - start program container. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_program - The program node being visited. - """ - prog = node.get("prog", "") - self.body.append(f'

\n') - - -def depart_argparse_program_html(self: HTML5Translator, node: argparse_program) -> None: - """Depart argparse_program node - close program container. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_program - The program node being departed. - """ - self.body.append("
\n") - - -def visit_argparse_usage_html(self: HTML5Translator, node: argparse_usage) -> None: - """Visit argparse_usage node - render usage block with syntax highlighting. - - The usage text is tokenized using ArgparseUsageLexer and wrapped in - styled elements for semantic highlighting of options, metavars, - commands, and punctuation. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_usage - The usage node being visited. - """ - usage = strip_ansi(node.get("usage", "")) - # Add both argparse-usage class and highlight class for CSS targeting - self.body.append('
')
-    # Prepend "usage: " and highlight the full usage string
-    highlighted = _highlight_usage(f"usage: {usage}", self.encode)
-    self.body.append(highlighted)
-
-
-def depart_argparse_usage_html(self: HTML5Translator, node: argparse_usage) -> None:
-    """Depart argparse_usage node - close usage block.
-
-    Parameters
-    ----------
-    self : HTML5Translator
-        The Sphinx HTML translator.
-    node : argparse_usage
-        The usage node being departed.
-    """
-    self.body.append("
\n") - - -def visit_argparse_group_html(self: HTML5Translator, node: argparse_group) -> None: - """Visit argparse_group node - start argument group. - - The title is now rendered by the parent section node, so this visitor - only handles the group container and description. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_group - The group node being visited. - """ - title = node.get("title", "") - group_id = title.lower().replace(" ", "-") if title else "arguments" - self.body.append(f'
\n') - # Title rendering removed - parent section now provides the heading - description = node.get("description") - if description: - self.body.append( - f'

{self.encode(description)}

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

{self.encode(help_text)}

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

{self.encode(title)}

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

{self.encode(header)}

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

{self.encode(help_text)}

\n' - ) - - -def depart_argparse_subcommand_html( - self: HTML5Translator, node: argparse_subcommand -) -> None: - """Depart argparse_subcommand node - close subcommand entry. - - Parameters - ---------- - self : HTML5Translator - The Sphinx HTML translator. - node : argparse_subcommand - The subcommand node being departed. - """ - self.body.append("
\n") diff --git a/docs/_ext/sphinx_argparse_neo/parser.py b/docs/_ext/sphinx_argparse_neo/parser.py deleted file mode 100644 index f3a6db44af..0000000000 --- a/docs/_ext/sphinx_argparse_neo/parser.py +++ /dev/null @@ -1,659 +0,0 @@ -"""Argparse introspection - extract structured data from ArgumentParser. - -This module provides dataclasses and functions to introspect argparse -ArgumentParser instances and convert them into structured data suitable -for documentation rendering. -""" - -from __future__ import annotations - -import argparse -import dataclasses -import typing as t - -from sphinx_argparse_neo.utils import strip_ansi - -# Sentinel for "no default" (distinct from None which is a valid default) -NO_DEFAULT = object() - - -@dataclasses.dataclass -class ArgumentInfo: - """Represents a single CLI argument. - - Examples - -------- - >>> info = ArgumentInfo( - ... names=["-v", "--verbose"], - ... help="Enable verbose output", - ... default=False, - ... default_string="False", - ... choices=None, - ... required=False, - ... metavar=None, - ... nargs=None, - ... action="store_true", - ... type_name=None, - ... const=True, - ... dest="verbose", - ... ) - >>> info.names - ['-v', '--verbose'] - >>> info.is_positional - False - """ - - names: list[str] - help: str | None - default: t.Any - default_string: str | None - choices: list[t.Any] | None - required: bool - metavar: str | None - nargs: str | int | None - action: str - type_name: str | None - const: t.Any - dest: str - - @property - def is_positional(self) -> bool: - """Return True if this is a positional argument. - - Examples - -------- - >>> ArgumentInfo( - ... names=["filename"], - ... help=None, - ... default=None, - ... default_string=None, - ... choices=None, - ... required=True, - ... metavar=None, - ... nargs=None, - ... action="store", - ... type_name=None, - ... const=None, - ... dest="filename", - ... ).is_positional - True - >>> ArgumentInfo( - ... names=["-f", "--file"], - ... help=None, - ... default=None, - ... default_string=None, - ... choices=None, - ... required=False, - ... metavar=None, - ... nargs=None, - ... action="store", - ... type_name=None, - ... const=None, - ... dest="file", - ... ).is_positional - False - """ - return bool(self.names) and not self.names[0].startswith("-") - - -@dataclasses.dataclass -class MutuallyExclusiveGroup: - """Arguments that cannot be used together. - - Examples - -------- - >>> group = MutuallyExclusiveGroup(arguments=[], required=True) - >>> group.required - True - """ - - arguments: list[ArgumentInfo] - required: bool - - -@dataclasses.dataclass -class ArgumentGroup: - """Named group of arguments. - - Examples - -------- - >>> group = ArgumentGroup( - ... title="Output Options", - ... description="Control output format", - ... arguments=[], - ... mutually_exclusive=[], - ... ) - >>> group.title - 'Output Options' - """ - - title: str - description: str | None - arguments: list[ArgumentInfo] - mutually_exclusive: list[MutuallyExclusiveGroup] - - -@dataclasses.dataclass -class SubcommandInfo: - """A subparser/subcommand. - - Examples - -------- - >>> sub = SubcommandInfo( - ... name="sync", - ... aliases=["s"], - ... help="Synchronize repositories", - ... parser=None, # type: ignore[arg-type] - ... ) - >>> sub.aliases - ['s'] - """ - - name: str - aliases: list[str] - help: str | None - parser: ParserInfo # Recursive reference - - -@dataclasses.dataclass -class ParserInfo: - """Complete parsed ArgumentParser. - - Examples - -------- - >>> info = ParserInfo( - ... prog="myapp", - ... usage=None, - ... bare_usage="myapp [-h] command", - ... description="My application", - ... epilog=None, - ... argument_groups=[], - ... subcommands=None, - ... subcommand_dest=None, - ... ) - >>> info.prog - 'myapp' - """ - - prog: str - usage: str | None - bare_usage: str - description: str | None - epilog: str | None - argument_groups: list[ArgumentGroup] - subcommands: list[SubcommandInfo] | None - subcommand_dest: str | None - - -def _format_default(default: t.Any) -> str | None: - """Format a default value for display. - - Parameters - ---------- - default : t.Any - The default value to format. - - Returns - ------- - str | None - Formatted string representation, or None if suppressed/unset. - - Examples - -------- - >>> _format_default(None) - 'None' - >>> _format_default("hello") - 'hello' - >>> _format_default(42) - '42' - >>> _format_default(argparse.SUPPRESS) is None - True - >>> _format_default([1, 2, 3]) - '[1, 2, 3]' - """ - if default is argparse.SUPPRESS: - return None - if default is None: - return "None" - if isinstance(default, str): - return default - return repr(default) - - -def _get_type_name(action: argparse.Action) -> str | None: - """Extract the type name from an action. - - Parameters - ---------- - action : argparse.Action - The argparse action to inspect. - - Returns - ------- - str | None - The type name, or None if no type is specified. - - Examples - -------- - >>> parser = argparse.ArgumentParser() - >>> action = parser.add_argument("--count", type=int) - >>> _get_type_name(action) - 'int' - >>> action2 = parser.add_argument("--name") - >>> _get_type_name(action2) is None - True - """ - if action.type is None: - return None - if hasattr(action.type, "__name__"): - return action.type.__name__ - return str(action.type) - - -def _get_action_name(action: argparse.Action) -> str: - """Get the action type name. - - Parameters - ---------- - action : argparse.Action - The argparse action to inspect. - - Returns - ------- - str - The action type name. - - Examples - -------- - >>> parser = argparse.ArgumentParser() - >>> action = parser.add_argument("--verbose", action="store_true") - >>> _get_action_name(action) - 'store_true' - >>> action2 = parser.add_argument("--file") - >>> _get_action_name(action2) - 'store' - """ - # Map action classes to their string names - action_class = type(action).__name__ - action_map = { - "_StoreAction": "store", - "_StoreTrueAction": "store_true", - "_StoreFalseAction": "store_false", - "_StoreConstAction": "store_const", - "_AppendAction": "append", - "_AppendConstAction": "append_const", - "_CountAction": "count", - "_HelpAction": "help", - "_VersionAction": "version", - "_ExtendAction": "extend", - "BooleanOptionalAction": "boolean_optional", - } - return action_map.get(action_class, action_class.lower()) - - -def _extract_argument(action: argparse.Action) -> ArgumentInfo: - """Extract ArgumentInfo from an argparse Action. - - Parameters - ---------- - action : argparse.Action - The argparse action to extract information from. - - Returns - ------- - ArgumentInfo - Structured argument information. - - Examples - -------- - >>> parser = argparse.ArgumentParser() - >>> action = parser.add_argument( - ... "-v", "--verbose", - ... action="store_true", - ... help="Enable verbose mode", - ... ) - >>> info = _extract_argument(action) - >>> info.names - ['-v', '--verbose'] - >>> info.action - 'store_true' - """ - # Determine names - option_strings for optionals, dest for positionals - names = list(action.option_strings) if action.option_strings else [action.dest] - - # Determine if required - required = action.required if hasattr(action, "required") else False - # Positional arguments are required by default (unless nargs makes them optional) - if not action.option_strings: - required = action.nargs not in ("?", "*", argparse.REMAINDER) - - # Format metavar - metavar = action.metavar - if isinstance(metavar, tuple): - metavar = " ".join(metavar) - - # Handle default - default = action.default - default_string = _format_default(default) - - return ArgumentInfo( - names=names, - help=action.help if action.help != argparse.SUPPRESS else None, - default=default if default is not argparse.SUPPRESS else NO_DEFAULT, - default_string=default_string, - choices=list(action.choices) if action.choices else None, - required=required, - metavar=metavar, - nargs=action.nargs, - action=_get_action_name(action), - type_name=_get_type_name(action), - const=action.const, - dest=action.dest, - ) - - -def _extract_mutex_groups( - parser: argparse.ArgumentParser, -) -> dict[int, MutuallyExclusiveGroup]: - """Extract mutually exclusive groups from a parser. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to extract from. - - Returns - ------- - dict[int, MutuallyExclusiveGroup] - Mapping from action id to the MutuallyExclusiveGroup it belongs to. - - Examples - -------- - Extract mutually exclusive groups from a parser with one group: - - >>> parser = argparse.ArgumentParser() - >>> group = parser.add_mutually_exclusive_group() - >>> _ = group.add_argument("--foo", help="Use foo") - >>> _ = group.add_argument("--bar", help="Use bar") - >>> mutex_map = _extract_mutex_groups(parser) - >>> len(mutex_map) - 2 - - Each action in the group maps to the same MutuallyExclusiveGroup: - - >>> values = list(mutex_map.values()) - >>> values[0] is values[1] - True - >>> len(values[0].arguments) - 2 - >>> [arg.names[0] for arg in values[0].arguments] - ['--foo', '--bar'] - - A parser without mutex groups returns an empty mapping: - - >>> parser2 = argparse.ArgumentParser() - >>> _ = parser2.add_argument("--verbose") - >>> _extract_mutex_groups(parser2) - {} - """ - mutex_map: dict[int, MutuallyExclusiveGroup] = {} - - for mutex_group in parser._mutually_exclusive_groups: - group_info = MutuallyExclusiveGroup( - arguments=[ - _extract_argument(action) - for action in mutex_group._group_actions - if action.help != argparse.SUPPRESS - ], - required=mutex_group.required, - ) - for action in mutex_group._group_actions: - mutex_map[id(action)] = group_info - - return mutex_map - - -def _extract_argument_groups( - parser: argparse.ArgumentParser, - hide_suppressed: bool = True, -) -> list[ArgumentGroup]: - """Extract argument groups from a parser. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to extract from. - hide_suppressed : bool - Whether to hide arguments with SUPPRESS help. - - Returns - ------- - list[ArgumentGroup] - List of argument groups. - - Examples - -------- - >>> parser = argparse.ArgumentParser(description="Test") - >>> _ = parser.add_argument("filename", help="Input file") - >>> _ = parser.add_argument("-v", "--verbose", action="store_true") - >>> groups = _extract_argument_groups(parser) - >>> len(groups) >= 2 # positional and optional groups - True - """ - mutex_map = _extract_mutex_groups(parser) - seen_mutex: set[int] = set() - groups: list[ArgumentGroup] = [] - - for group in parser._action_groups: - arguments: list[ArgumentInfo] = [] - mutex_groups: list[MutuallyExclusiveGroup] = [] - - for action in group._group_actions: - # Skip help action and suppressed actions - if isinstance(action, argparse._HelpAction): - continue - if hide_suppressed and action.help == argparse.SUPPRESS: - continue - # Skip subparser actions - handled separately - if isinstance(action, argparse._SubParsersAction): - continue - - # Check if this action is in a mutex group - if id(action) in mutex_map: - mutex_info = mutex_map[id(action)] - mutex_id = id(mutex_info) - if mutex_id not in seen_mutex: - seen_mutex.add(mutex_id) - mutex_groups.append(mutex_info) - else: - arguments.append(_extract_argument(action)) - - # Skip empty groups - if not arguments and not mutex_groups: - continue - - groups.append( - ArgumentGroup( - title=group.title or "", - description=group.description, - arguments=arguments, - mutually_exclusive=mutex_groups, - ) - ) - - return groups - - -def _extract_subcommands( - parser: argparse.ArgumentParser, - hide_suppressed: bool = True, -) -> tuple[list[SubcommandInfo] | None, str | None]: - """Extract subcommands from a parser. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to extract from. - hide_suppressed : bool - Whether to hide subcommands with SUPPRESS help. - - Returns - ------- - tuple[list[SubcommandInfo] | None, str | None] - Tuple of (subcommands list, destination variable name). - - Examples - -------- - >>> parser = argparse.ArgumentParser() - >>> subparsers = parser.add_subparsers(dest="command") - >>> _ = subparsers.add_parser("sync", help="Sync repos") - >>> _ = subparsers.add_parser("add", help="Add repo") - >>> subs, dest = _extract_subcommands(parser) - >>> dest - 'command' - >>> len(subs) - 2 - """ - for action in parser._actions: - if isinstance(action, argparse._SubParsersAction): - subcommands: list[SubcommandInfo] = [] - - # Get the choices (subparsers) - choices = action.choices or {} - - # Build reverse mapping of aliases - # action._parser_class might have name_parser_map with aliases - alias_map: dict[str, list[str]] = {} - seen_parsers: dict[int, str] = {} - - for name, subparser in choices.items(): - parser_id = id(subparser) - if parser_id in seen_parsers: - # This is an alias - primary = seen_parsers[parser_id] - if primary not in alias_map: - alias_map[primary] = [] - alias_map[primary].append(name) - else: - seen_parsers[parser_id] = name - - # Now extract subcommand info - processed: set[int] = set() - for name, subparser in choices.items(): - parser_id = id(subparser) - if parser_id in processed: - continue - processed.add(parser_id) - - # Get help text - help_text: str | None = None - if hasattr(action, "_choices_actions"): - for choice_action in action._choices_actions: - if choice_action.dest == name: - help_text = choice_action.help - break - - if hide_suppressed and help_text == argparse.SUPPRESS: - continue - - # Recursively extract parser info - sub_info = extract_parser(subparser, hide_suppressed=hide_suppressed) - - subcommands.append( - SubcommandInfo( - name=name, - aliases=alias_map.get(name, []), - help=help_text, - parser=sub_info, - ) - ) - - return subcommands, action.dest - - return None, None - - -def _generate_usage(parser: argparse.ArgumentParser) -> str: - """Generate the usage string for a parser. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to generate usage for. - - Returns - ------- - str - The bare usage string (without "usage: " prefix). - - Examples - -------- - >>> parser = argparse.ArgumentParser(prog="myapp") - >>> _ = parser.add_argument("-v", "--verbose", action="store_true") - >>> usage = _generate_usage(parser) - >>> "myapp" in usage - True - """ - # Use argparse's built-in formatter to generate usage - formatter = parser._get_formatter() - formatter.add_usage( - parser.usage, parser._actions, parser._mutually_exclusive_groups - ) - usage: str = formatter.format_help().strip() - - # Strip ANSI codes before checking prefix (handles FORCE_COLOR edge case) - usage = strip_ansi(usage) - - # Remove "usage: " prefix if present - if usage.lower().startswith("usage:"): - usage = usage[6:].strip() - - return usage - - -def extract_parser( - parser: argparse.ArgumentParser, - hide_suppressed: bool = True, -) -> ParserInfo: - """Extract complete parser information. - - Parameters - ---------- - parser : argparse.ArgumentParser - The parser to extract information from. - hide_suppressed : bool - Whether to hide arguments/subcommands with SUPPRESS help. - - Returns - ------- - ParserInfo - Complete structured parser information. - - Examples - -------- - >>> parser = argparse.ArgumentParser( - ... prog="myapp", - ... description="My application", - ... ) - >>> _ = parser.add_argument("filename", help="Input file") - >>> _ = parser.add_argument("-v", "--verbose", action="store_true") - >>> info = extract_parser(parser) - >>> info.prog - 'myapp' - >>> info.description - 'My application' - >>> len(info.argument_groups) >= 1 - True - """ - subcommands, subcommand_dest = _extract_subcommands(parser, hide_suppressed) - - return ParserInfo( - prog=parser.prog, - usage=parser.usage, - bare_usage=_generate_usage(parser), - description=parser.description, - epilog=parser.epilog, - argument_groups=_extract_argument_groups(parser, hide_suppressed), - subcommands=subcommands, - subcommand_dest=subcommand_dest, - ) diff --git a/docs/_ext/sphinx_argparse_neo/renderer.py b/docs/_ext/sphinx_argparse_neo/renderer.py deleted file mode 100644 index f6c313f9f1..0000000000 --- a/docs/_ext/sphinx_argparse_neo/renderer.py +++ /dev/null @@ -1,604 +0,0 @@ -"""Renderer - convert ParserInfo to docutils nodes. - -This module provides the ArgparseRenderer class that transforms -structured parser information into docutils nodes for documentation. -""" - -from __future__ import annotations - -import dataclasses -import typing as t - -from docutils import nodes -from docutils.statemachine import StringList -from sphinx_argparse_neo.nodes import ( - argparse_argument, - argparse_group, - argparse_program, - argparse_subcommand, - argparse_subcommands, - argparse_usage, -) -from sphinx_argparse_neo.parser import ( - ArgumentGroup, - ArgumentInfo, - MutuallyExclusiveGroup, - ParserInfo, - SubcommandInfo, -) -from sphinx_argparse_neo.utils import escape_rst_emphasis - -if t.TYPE_CHECKING: - from docutils.parsers.rst.states import RSTState - from sphinx.config import Config - - -@dataclasses.dataclass -class RenderConfig: - """Configuration for the renderer. - - Examples - -------- - >>> config = RenderConfig() - >>> config.show_defaults - True - >>> config.group_title_prefix - '' - """ - - group_title_prefix: str = "" - show_defaults: bool = True - show_choices: bool = True - show_types: bool = True - - @classmethod - def from_sphinx_config(cls, config: Config) -> RenderConfig: - """Create RenderConfig from Sphinx configuration. - - Parameters - ---------- - config : Config - Sphinx configuration object. - - Returns - ------- - RenderConfig - Render configuration based on Sphinx config values. - """ - return cls( - group_title_prefix=getattr(config, "argparse_group_title_prefix", ""), - show_defaults=getattr(config, "argparse_show_defaults", True), - show_choices=getattr(config, "argparse_show_choices", True), - show_types=getattr(config, "argparse_show_types", True), - ) - - -class ArgparseRenderer: - """Render ParserInfo to docutils nodes. - - This class can be subclassed to customize rendering behavior. - Override individual methods to change how specific elements are rendered. - - Parameters - ---------- - config : RenderConfig - Rendering configuration. - state : RSTState | None - RST state for parsing nested RST content. - - Examples - -------- - >>> from sphinx_argparse_neo.parser import ParserInfo - >>> config = RenderConfig() - >>> renderer = ArgparseRenderer(config) - >>> info = ParserInfo( - ... prog="myapp", - ... usage=None, - ... bare_usage="myapp [-h]", - ... description="My app", - ... epilog=None, - ... argument_groups=[], - ... subcommands=None, - ... subcommand_dest=None, - ... ) - >>> result = renderer.render(info) - >>> isinstance(result, list) - True - """ - - def __init__( - self, - config: RenderConfig | None = None, - state: RSTState | None = None, - ) -> None: - """Initialize the renderer.""" - self.config = config or RenderConfig() - self.state = state - - @staticmethod - def _extract_id_prefix(prog: str) -> str: - """Extract subcommand from prog for unique section IDs. - - Parameters - ---------- - prog : str - The program name, potentially with subcommand (e.g., "tmuxp load"). - - Returns - ------- - str - The subcommand part for use as ID prefix, or empty string if none. - - Examples - -------- - >>> ArgparseRenderer._extract_id_prefix("tmuxp load") - 'load' - >>> ArgparseRenderer._extract_id_prefix("tmuxp") - '' - >>> ArgparseRenderer._extract_id_prefix("vcspull sync") - 'sync' - >>> ArgparseRenderer._extract_id_prefix("myapp sub cmd") - 'sub-cmd' - """ - parts = prog.split() - if len(parts) <= 1: - return "" - # Join remaining parts with hyphen for multi-level subcommands - return "-".join(parts[1:]) - - def render(self, parser_info: ParserInfo) -> list[nodes.Node]: - """Render a complete parser to docutils nodes. - - Parameters - ---------- - parser_info : ParserInfo - The parsed parser information. - - Returns - ------- - list[nodes.Node] - List of docutils nodes representing the documentation. - - Note - ---- - Sections for Usage and argument groups are emitted as siblings of - argparse_program rather than children. This allows Sphinx's - TocTreeCollector to discover them for inclusion in the table of - contents. - - The rendered structure is: - - - argparse_program (description only, no "examples:" part) - - section#usage (h3 "Usage" with usage block) - - section#positional-arguments (h3) - - section#options (h3) - - The "examples:" definition list in descriptions is left for - argparse_exemplar.py to transform into a proper Examples section. - """ - result: list[nodes.Node] = [] - - # Create program container for description only - program_node = argparse_program() - program_node["prog"] = parser_info.prog - - # Add description (may contain "examples:" definition list for later - # transformation by argparse_exemplar.py) - if parser_info.description: - desc_nodes = self._parse_text(parser_info.description) - program_node.extend(desc_nodes) - - result.append(program_node) - - # Extract ID prefix from prog for unique section IDs - # e.g., "tmuxp load" -> "load", "myapp" -> "" - id_prefix = self._extract_id_prefix(parser_info.prog) - - # Add Usage section as sibling (for TOC visibility) - usage_section = self.render_usage_section(parser_info, id_prefix=id_prefix) - result.append(usage_section) - - # Add argument groups as sibling sections (for TOC visibility) - for group in parser_info.argument_groups: - group_section = self.render_group_section(group, id_prefix=id_prefix) - result.append(group_section) - - # Add subcommands - if parser_info.subcommands: - subcommands_node = self.render_subcommands(parser_info.subcommands) - result.append(subcommands_node) - - # Add epilog - if parser_info.epilog: - epilog_nodes = self._parse_text(parser_info.epilog) - result.extend(epilog_nodes) - - return self.post_process(result) - - def render_usage(self, parser_info: ParserInfo) -> argparse_usage: - """Render the usage block. - - Parameters - ---------- - parser_info : ParserInfo - The parser information. - - Returns - ------- - argparse_usage - Usage node. - """ - usage_node = argparse_usage() - usage_node["usage"] = parser_info.bare_usage - return usage_node - - def render_usage_section( - self, parser_info: ParserInfo, *, id_prefix: str = "" - ) -> nodes.section: - """Render usage as a section with heading for TOC visibility. - - Creates a proper section node with "Usage" heading containing the - usage block. This structure allows Sphinx's TocTreeCollector to - discover it for the table of contents. - - Parameters - ---------- - parser_info : ParserInfo - The parser information. - id_prefix : str - Optional prefix for the section ID (e.g., "load" -> "load-usage"). - Used to ensure unique IDs when multiple argparse directives exist - on the same page. - - Returns - ------- - nodes.section - Section node containing the usage block with a "Usage" heading. - - Examples - -------- - >>> from sphinx_argparse_neo.parser import ParserInfo - >>> renderer = ArgparseRenderer() - >>> info = ParserInfo( - ... prog="myapp", - ... usage=None, - ... bare_usage="myapp [-h] command", - ... description=None, - ... epilog=None, - ... argument_groups=[], - ... subcommands=None, - ... subcommand_dest=None, - ... ) - >>> section = renderer.render_usage_section(info) - >>> section["ids"] - ['usage'] - - With prefix for subcommand pages: - - >>> section = renderer.render_usage_section(info, id_prefix="load") - >>> section["ids"] - ['load-usage'] - >>> section.children[0].astext() - 'Usage' - """ - section_id = f"{id_prefix}-usage" if id_prefix else "usage" - section = nodes.section() - section["ids"] = [section_id] - section["names"] = [nodes.fully_normalize_name("Usage")] - section += nodes.title("Usage", "Usage") - - usage_node = argparse_usage() - usage_node["usage"] = parser_info.bare_usage - section += usage_node - - return section - - def render_group_section( - self, group: ArgumentGroup, *, id_prefix: str = "" - ) -> nodes.section: - """Render an argument group wrapped in a section for TOC visibility. - - Creates a proper section node with the group title as heading, - containing the argparse_group node. This structure allows Sphinx's - TocTreeCollector to discover it for the table of contents. - - Parameters - ---------- - group : ArgumentGroup - The argument group to render. - id_prefix : str - Optional prefix for the section ID (e.g., "load" -> "load-options"). - Used to ensure unique IDs when multiple argparse directives exist - on the same page. - - Returns - ------- - nodes.section - Section node containing the group for TOC discovery. - - Examples - -------- - >>> from sphinx_argparse_neo.parser import ArgumentGroup - >>> renderer = ArgparseRenderer() - >>> group = ArgumentGroup( - ... title="positional arguments", - ... description=None, - ... arguments=[], - ... mutually_exclusive=[], - ... ) - >>> section = renderer.render_group_section(group) - >>> section["ids"] - ['positional-arguments'] - - With prefix for subcommand pages: - - >>> section = renderer.render_group_section(group, id_prefix="load") - >>> section["ids"] - ['load-positional-arguments'] - >>> section.children[0].astext() - 'Positional Arguments' - """ - # Title case the group title for proper display - raw_title = group.title or "Arguments" - title = raw_title.title() # "positional arguments" -> "Positional Arguments" - - if self.config.group_title_prefix: - title = f"{self.config.group_title_prefix}{title}" - - # Generate section ID from title (with optional prefix for uniqueness) - base_id = title.lower().replace(" ", "-") - section_id = f"{id_prefix}-{base_id}" if id_prefix else base_id - - # Create section wrapper for TOC discovery - section = nodes.section() - section["ids"] = [section_id] - section["names"] = [nodes.fully_normalize_name(title)] - - # Add title for TOC - Sphinx's TocTreeCollector looks for this - section += nodes.title(title, title) - - # Create the styled group container (with empty title - section provides it) - # Pass id_prefix to render_group so arguments get unique IDs - group_node = self.render_group(group, include_title=False, id_prefix=id_prefix) - section += group_node - - return section - - def render_group( - self, - group: ArgumentGroup, - include_title: bool = True, - *, - id_prefix: str = "", - ) -> argparse_group: - """Render an argument group. - - Parameters - ---------- - group : ArgumentGroup - The argument group to render. - include_title : bool - Whether to include the title in the group node. When False, - the title is assumed to come from a parent section node. - Default is True for backwards compatibility. - id_prefix : str - Optional prefix for argument IDs (e.g., "shell" -> "shell-h"). - Used to ensure unique IDs when multiple argparse directives exist - on the same page. - - Returns - ------- - argparse_group - Group node containing argument nodes. - """ - group_node = argparse_group() - - if include_title: - title = group.title - if self.config.group_title_prefix: - title = f"{self.config.group_title_prefix}{title}" - group_node["title"] = title - else: - # Title provided by parent section - group_node["title"] = "" - - group_node["description"] = group.description - - # Add individual arguments - for arg in group.arguments: - arg_node = self.render_argument(arg, id_prefix=id_prefix) - group_node.append(arg_node) - - # Add mutually exclusive groups - for mutex in group.mutually_exclusive: - mutex_nodes = self.render_mutex_group(mutex, id_prefix=id_prefix) - group_node.extend(mutex_nodes) - - return group_node - - def render_argument( - self, arg: ArgumentInfo, *, id_prefix: str = "" - ) -> argparse_argument: - """Render a single argument. - - Parameters - ---------- - arg : ArgumentInfo - The argument to render. - id_prefix : str - Optional prefix for the argument ID (e.g., "shell" -> "shell-L"). - Used to ensure unique IDs when multiple argparse directives exist - on the same page. - - Returns - ------- - argparse_argument - Argument node. - """ - arg_node = argparse_argument() - arg_node["names"] = arg.names - arg_node["help"] = arg.help - arg_node["metavar"] = arg.metavar - arg_node["required"] = arg.required - arg_node["id_prefix"] = id_prefix - - if self.config.show_defaults: - arg_node["default_string"] = arg.default_string - - if self.config.show_choices: - arg_node["choices"] = arg.choices - - if self.config.show_types: - arg_node["type_name"] = arg.type_name - - return arg_node - - def render_mutex_group( - self, mutex: MutuallyExclusiveGroup, *, id_prefix: str = "" - ) -> list[argparse_argument]: - """Render a mutually exclusive group. - - Parameters - ---------- - mutex : MutuallyExclusiveGroup - The mutually exclusive group. - id_prefix : str - Optional prefix for argument IDs (e.g., "shell" -> "shell-h"). - - Returns - ------- - list[argparse_argument] - List of argument nodes with mutex indicator. - """ - result: list[argparse_argument] = [] - for arg in mutex.arguments: - arg_node = self.render_argument(arg, id_prefix=id_prefix) - # Mark as part of mutex group - arg_node["mutex"] = True - arg_node["mutex_required"] = mutex.required - result.append(arg_node) - return result - - def render_subcommands( - self, subcommands: list[SubcommandInfo] - ) -> argparse_subcommands: - """Render subcommands section. - - Parameters - ---------- - subcommands : list[SubcommandInfo] - List of subcommand information. - - Returns - ------- - argparse_subcommands - Subcommands container node. - """ - container = argparse_subcommands() - container["title"] = "Sub-commands" - - for subcmd in subcommands: - subcmd_node = self.render_subcommand(subcmd) - container.append(subcmd_node) - - return container - - def render_subcommand(self, subcmd: SubcommandInfo) -> argparse_subcommand: - """Render a single subcommand. - - Parameters - ---------- - subcmd : SubcommandInfo - The subcommand information. - - Returns - ------- - argparse_subcommand - Subcommand node, potentially containing nested parser content. - """ - subcmd_node = argparse_subcommand() - subcmd_node["name"] = subcmd.name - subcmd_node["aliases"] = subcmd.aliases - subcmd_node["help"] = subcmd.help - - # Recursively render the subcommand's parser - if subcmd.parser: - nested_nodes = self.render(subcmd.parser) - subcmd_node.extend(nested_nodes) - - return subcmd_node - - def post_process(self, result_nodes: list[nodes.Node]) -> list[nodes.Node]: - """Post-process the rendered nodes. - - Override this method to apply transformations after rendering. - - Parameters - ---------- - result_nodes : list[nodes.Node] - The rendered nodes. - - Returns - ------- - list[nodes.Node] - Post-processed nodes. - """ - return result_nodes - - def _parse_text(self, text: str) -> list[nodes.Node]: - """Parse text as RST or MyST content. - - Parameters - ---------- - text : str - Text to parse. - - Returns - ------- - list[nodes.Node] - Parsed docutils nodes. - """ - if not text: - return [] - - # Escape RST emphasis patterns before parsing (e.g., "django-*" -> "django-\*") - text = escape_rst_emphasis(text) - - if self.state is None: - # No state machine available, return as paragraph - para = nodes.paragraph(text=text) - return [para] - - # Use the state machine to parse RST - container = nodes.container() - self.state.nested_parse( - StringList(text.split("\n")), - 0, - container, - ) - return list(container.children) - - -def create_renderer( - config: RenderConfig | None = None, - state: RSTState | None = None, - renderer_class: type[ArgparseRenderer] | None = None, -) -> ArgparseRenderer: - """Create a renderer instance. - - Parameters - ---------- - config : RenderConfig | None - Rendering configuration. - state : RSTState | None - RST state for parsing. - renderer_class : type[ArgparseRenderer] | None - Custom renderer class to use. - - Returns - ------- - ArgparseRenderer - Configured renderer instance. - """ - cls = renderer_class or ArgparseRenderer - return cls(config=config, state=state) diff --git a/docs/_ext/sphinx_argparse_neo/utils.py b/docs/_ext/sphinx_argparse_neo/utils.py deleted file mode 100644 index 468b1961fa..0000000000 --- a/docs/_ext/sphinx_argparse_neo/utils.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Text processing utilities for sphinx_argparse_neo. - -This module provides utilities for cleaning argparse output before rendering: -- strip_ansi: Remove ANSI escape codes (for when FORCE_COLOR is set) -""" - -from __future__ import annotations - -import re - -# ANSI escape code pattern - matches CSI sequences like \033[32m, \033[1;34m, etc. -_ANSI_RE = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") - - -def strip_ansi(text: str) -> str: - r"""Remove ANSI escape codes from text. - - When FORCE_COLOR is set in the environment, argparse may include ANSI - escape codes in its output. This function removes them so the output - renders correctly in Sphinx documentation. - - Parameters - ---------- - text : str - Text potentially containing ANSI codes. - - Returns - ------- - str - Text with ANSI codes removed. - - Examples - -------- - >>> strip_ansi("plain text") - 'plain text' - >>> strip_ansi("\033[32mgreen\033[0m") - 'green' - >>> strip_ansi("\033[1;34mbold blue\033[0m") - 'bold blue' - """ - return _ANSI_RE.sub("", text) - - -# RST emphasis pattern: matches -* that would trigger inline emphasis errors. -# Pattern matches: non-whitespace/non-backslash char, followed by -*, NOT followed by -# another * (which would be strong emphasis **). -_RST_EMPHASIS_RE = re.compile(r"(?<=[^\s\\])-\*(?!\*)") - - -def escape_rst_emphasis(text: str) -> str: - r"""Escape asterisks that would trigger RST inline emphasis. - - RST interprets ``*text*`` as emphasis. When argparse help text contains - glob patterns like ``django-*``, the ``-*`` sequence triggers RST - "Inline emphasis start-string without end-string" warnings. - - This function escapes such asterisks to prevent RST parsing errors. - - Parameters - ---------- - text : str - Text potentially containing problematic asterisks. - - Returns - ------- - str - Text with asterisks escaped where needed. - - Examples - -------- - >>> escape_rst_emphasis('tmuxp load "my-*"') - 'tmuxp load "my-\\*"' - >>> escape_rst_emphasis("plain text") - 'plain text' - >>> escape_rst_emphasis("*emphasis* is ok") - '*emphasis* is ok' - """ - return _RST_EMPHASIS_RE.sub(r"-\*", text) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py deleted file mode 100644 index 7a7e1a2780..0000000000 --- a/docs/_ext/sphinx_fonts.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Sphinx extension for self-hosted fonts via Fontsource CDN. - -Downloads font files at build time, caches them locally, and passes -structured font data to the template context for inline @font-face CSS. -""" - -from __future__ import annotations - -import logging -import pathlib -import shutil -import typing as t -import urllib.error -import urllib.request - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - -logger = logging.getLogger(__name__) - -CDN_TEMPLATE = ( - "https://cdn.jsdelivr.net/npm/{package}@{version}" - "/files/{font_id}-{subset}-{weight}-{style}.woff2" -) - - -class SetupDict(t.TypedDict): - """Return type for Sphinx extension setup().""" - - version: str - parallel_read_safe: bool - parallel_write_safe: bool - - -def _cache_dir() -> pathlib.Path: - return pathlib.Path.home() / ".cache" / "sphinx-fonts" - - -def _cdn_url( - package: str, - version: str, - font_id: str, - subset: str, - weight: int, - style: str, -) -> str: - return CDN_TEMPLATE.format( - package=package, - version=version, - font_id=font_id, - subset=subset, - weight=weight, - style=style, - ) - - -# Unicode range descriptors per subset — tells the browser to only download -# the file when characters from this range appear on the page. Ranges are -# from Fontsource / Google Fonts CSS (CSS unicode-range values). -_UNICODE_RANGES: dict[str, str] = { - "latin": ( - "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6," - " U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F," - " U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215," - " U+FEFF, U+FFFD" - ), - "latin-ext": ( - "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7," - " U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF," - " U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB," - " U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF" - ), - "cyrillic": ("U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116"), - "cyrillic-ext": ( - "U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F" - ), - "greek": ( - "U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF" - ), - "vietnamese": ( - "U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169," - " U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304," - " U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB" - ), -} - - -def _unicode_range(subset: str) -> str: - """Return the CSS ``unicode-range`` descriptor for *subset*. - - Falls back to an empty string for unknown subsets (omitting the - descriptor causes the browser to treat the face as covering all - codepoints, which is the correct fallback). - - Parameters - ---------- - subset : str - Fontsource subset name (e.g. ``"latin"``, ``"latin-ext"``). - - Returns - ------- - str - CSS ``unicode-range`` value, or ``""`` if unknown. - """ - return _UNICODE_RANGES.get(subset, "") - - -def _download_font(url: str, dest: pathlib.Path) -> bool: - if dest.exists(): - logger.debug("font cached: %s", dest.name) - return True - dest.parent.mkdir(parents=True, exist_ok=True) - try: - urllib.request.urlretrieve(url, dest) - logger.info("downloaded font: %s", dest.name) - except (urllib.error.URLError, OSError): - if dest.exists(): - dest.unlink() - logger.warning("failed to download font: %s", url) - return False - return True - - -def _on_builder_inited(app: Sphinx) -> None: - if app.builder.format != "html": - return - - fonts: list[dict[str, t.Any]] = app.config.sphinx_fonts - variables: dict[str, str] = app.config.sphinx_font_css_variables - if not fonts: - return - - cache = _cache_dir() - static_dir = pathlib.Path(app.outdir) / "_static" - fonts_dir = static_dir / "fonts" - fonts_dir.mkdir(parents=True, exist_ok=True) - - font_faces: list[dict[str, str]] = [] - for font in fonts: - font_id = font["package"].split("/")[-1] - version = font["version"] - package = font["package"] - # Accept "subsets" (list) or legacy "subset" (str). - subsets: list[str] = font.get("subsets", [font.get("subset", "latin")]) - for subset in subsets: - for weight in font["weights"]: - for style in font["styles"]: - filename = f"{font_id}-{subset}-{weight}-{style}.woff2" - cached = cache / filename - url = _cdn_url(package, version, font_id, subset, weight, style) - if _download_font(url, cached): - shutil.copy2(cached, fonts_dir / filename) - font_faces.append( - { - "family": font["family"], - "style": style, - "weight": str(weight), - "filename": filename, - "unicode_range": _unicode_range(subset), - } - ) - - preload_hrefs: list[str] = [] - preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload - for family_name, weight, style in preload_specs: - for font in fonts: - if font["family"] == family_name: - font_id = font["package"].split("/")[-1] - # Preload the first (primary) subset only — typically "latin". - subsets = font.get("subsets", [font.get("subset", "latin")]) - primary = subsets[0] if subsets else "latin" - filename = f"{font_id}-{primary}-{weight}-{style}.woff2" - preload_hrefs.append(filename) - break - - fallbacks: list[dict[str, str]] = app.config.sphinx_font_fallbacks - - app._font_preload_hrefs = preload_hrefs # type: ignore[attr-defined] - app._font_faces = font_faces # type: ignore[attr-defined] - app._font_fallbacks = fallbacks # type: ignore[attr-defined] - app._font_css_variables = variables # type: ignore[attr-defined] - - -def _on_html_page_context( - app: Sphinx, - pagename: str, - templatename: str, - context: dict[str, t.Any], - doctree: t.Any, -) -> None: - context["font_preload_hrefs"] = getattr(app, "_font_preload_hrefs", []) - context["font_faces"] = getattr(app, "_font_faces", []) - context["font_fallbacks"] = getattr(app, "_font_fallbacks", []) - context["font_css_variables"] = getattr(app, "_font_css_variables", {}) - - -def setup(app: Sphinx) -> SetupDict: - """Register config values, events, and return extension metadata.""" - app.add_config_value("sphinx_fonts", [], "html") - app.add_config_value("sphinx_font_fallbacks", [], "html") - app.add_config_value("sphinx_font_css_variables", {}, "html") - app.add_config_value("sphinx_font_preload", [], "html") - app.connect("builder-inited", _on_builder_inited) - app.connect("html-page-context", _on_html_page_context) - return { - "version": "1.0", - "parallel_read_safe": True, - "parallel_write_safe": True, - } diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js deleted file mode 100644 index cd99233fb7..0000000000 --- a/docs/_static/js/spa-nav.js +++ /dev/null @@ -1,254 +0,0 @@ -/** - * SPA-like navigation for Sphinx/Furo docs. - * - * Intercepts internal link clicks and swaps only the content that changes - * (article, sidebar nav tree, TOC drawer), preserving sidebar scroll - * position, theme state, and avoiding full-page reloads. - * - * Progressive enhancement: no-op when fetch/DOMParser/pushState unavailable. - */ -(function () { - "use strict"; - - if (!window.fetch || !window.DOMParser || !window.history?.pushState) return; - - // --- Theme toggle (replicates Furo's cycleThemeOnce) --- - - function cycleTheme() { - var current = localStorage.getItem("theme") || "auto"; - var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - var next; - if (current === "auto") next = prefersDark ? "light" : "dark"; - else if (current === "dark") next = prefersDark ? "auto" : "light"; - else next = prefersDark ? "dark" : "auto"; - document.body.dataset.theme = next; - localStorage.setItem("theme", next); - } - - // --- Copy button injection --- - - var copyBtnTemplate = null; - - function captureCopyIcon() { - var btn = document.querySelector(".copybtn"); - if (btn) copyBtnTemplate = btn.cloneNode(true); - } - - function addCopyButtons() { - if (!copyBtnTemplate) captureCopyIcon(); - if (!copyBtnTemplate) return; - var cells = document.querySelectorAll("div.highlight pre"); - cells.forEach(function (cell, i) { - cell.id = "codecell" + i; - var next = cell.nextElementSibling; - if (next && next.classList.contains("copybtn")) { - next.setAttribute("data-clipboard-target", "#codecell" + i); - } else { - var btn = copyBtnTemplate.cloneNode(true); - btn.setAttribute("data-clipboard-target", "#codecell" + i); - cell.insertAdjacentElement("afterend", btn); - } - }); - } - - // --- Minimal scrollspy --- - - var scrollCleanup = null; - - function initScrollSpy() { - if (scrollCleanup) scrollCleanup(); - scrollCleanup = null; - - var links = document.querySelectorAll(".toc-tree a"); - if (!links.length) return; - - var entries = []; - links.forEach(function (a) { - var id = (a.getAttribute("href") || "").split("#")[1]; - var el = id && document.getElementById(id); - var li = a.closest("li"); - if (el && li) entries.push({ el: el, li: li }); - }); - if (!entries.length) return; - - function update() { - var offset = - parseFloat(getComputedStyle(document.documentElement).fontSize) * 4; - var active = null; - for (var i = entries.length - 1; i >= 0; i--) { - if (entries[i].el.getBoundingClientRect().top <= offset) { - active = entries[i]; - break; - } - } - entries.forEach(function (e) { - e.li.classList.remove("scroll-current"); - }); - if (active) active.li.classList.add("scroll-current"); - } - - window.addEventListener("scroll", update, { passive: true }); - update(); - scrollCleanup = function () { - window.removeEventListener("scroll", update); - }; - } - - // --- Link interception --- - - function shouldIntercept(link, e) { - if (e.defaultPrevented || e.button !== 0) return false; - if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false; - if (link.origin !== location.origin) return false; - if (link.target && link.target !== "_self") return false; - if (link.hasAttribute("download")) return false; - - var path = link.pathname; - if (!path.endsWith(".html") && !path.endsWith("/")) return false; - - var base = path.split("/").pop() || ""; - if ( - base === "search.html" || - base === "genindex.html" || - base === "py-modindex.html" - ) - return false; - - if (link.closest("#sidebar-projects")) return false; - if (link.pathname === location.pathname && link.hash) return false; - - return true; - } - - // --- DOM swap --- - - function swap(doc) { - [".article-container", ".sidebar-tree", ".toc-drawer"].forEach( - function (sel) { - var fresh = doc.querySelector(sel); - var stale = document.querySelector(sel); - if (fresh && stale) stale.replaceWith(fresh); - }, - ); - var title = doc.querySelector("title"); - if (title) document.title = title.textContent || ""; - - // Brand links and logo images live outside swapped regions. - // Their relative hrefs/srcs go stale after cross-depth navigation. - // Copy the correct values from the fetched document. - [".sidebar-brand", ".header-center a"].forEach(function (sel) { - var fresh = doc.querySelector(sel); - if (!fresh) return; - document.querySelectorAll(sel).forEach(function (el) { - el.setAttribute("href", fresh.getAttribute("href")); - }); - }); - var freshLogos = doc.querySelectorAll(".sidebar-logo"); - var staleLogos = document.querySelectorAll(".sidebar-logo"); - freshLogos.forEach(function (fresh, i) { - if (staleLogos[i]) { - staleLogos[i].setAttribute("src", fresh.getAttribute("src")); - } - }); - } - - function reinit() { - addCopyButtons(); - initScrollSpy(); - var btn = document.querySelector(".content-icon-container .theme-toggle"); - if (btn) btn.addEventListener("click", cycleTheme); - } - - // --- Navigation --- - - var currentCtrl = null; - - async function navigate(url, isPop) { - if (currentCtrl) currentCtrl.abort(); - var ctrl = new AbortController(); - currentCtrl = ctrl; - - try { - var resp = await fetch(url, { signal: ctrl.signal }); - if (!resp.ok) throw new Error(resp.status); - - var html = await resp.text(); - var doc = new DOMParser().parseFromString(html, "text/html"); - - if (!doc.querySelector(".article-container")) - throw new Error("no article"); - - var applySwap = function () { - swap(doc); - - if (!isPop) history.pushState({ spa: true }, "", url); - - if (!isPop) { - var hash = new URL(url, location.href).hash; - if (hash) { - var el = document.querySelector(hash); - if (el) el.scrollIntoView(); - } else { - window.scrollTo(0, 0); - } - } - - reinit(); - }; - - if (document.startViewTransition) { - document.startViewTransition(applySwap); - } else { - applySwap(); - } - } catch (err) { - if (err.name === "AbortError") return; - window.location.href = url; - } finally { - if (currentCtrl === ctrl) currentCtrl = null; - } - } - - // --- Events --- - - document.addEventListener("click", function (e) { - var link = e.target.closest("a[href]"); - if (link && shouldIntercept(link, e)) { - e.preventDefault(); - navigate(link.href, false); - } - }); - - history.replaceState({ spa: true }, ""); - - window.addEventListener("popstate", function () { - navigate(location.href, true); - }); - - // --- Hover prefetch --- - - var prefetchTimer = null; - - document.addEventListener("mouseover", function (e) { - var link = e.target.closest("a[href]"); - if (!link || link.origin !== location.origin) return; - if (!link.pathname.endsWith(".html") && !link.pathname.endsWith("/")) - return; - - clearTimeout(prefetchTimer); - prefetchTimer = setTimeout(function () { - fetch(link.href, { priority: "low" }).catch(function () {}); - }, 65); - }); - - document.addEventListener("mouseout", function (e) { - if (e.target.closest("a[href]")) clearTimeout(prefetchTimer); - }); - - // --- Init --- - - // Copy buttons are injected by copybutton.js on DOMContentLoaded. - // This defer script runs before DOMContentLoaded, so our handler - // fires after copybutton's handler (registration order preserved). - document.addEventListener("DOMContentLoaded", captureCopyIcon); -})(); diff --git a/docs/conf.py b/docs/conf.py index 0e393d32e3..208f183724 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,19 +1,9 @@ -# flake8: NOQA: E501 """Sphinx documentation configuration for tmuxp.""" from __future__ import annotations -import contextlib -import inspect import pathlib import sys -import typing as t -from os.path import relpath - -import tmuxp - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx # Get the project root dir, which is the parent dir of this cwd = pathlib.Path(__file__).parent @@ -21,272 +11,38 @@ src_root = project_root / "src" sys.path.insert(0, str(src_root)) -sys.path.insert(0, str(cwd / "_ext")) +sys.path.insert(0, str(cwd / "_ext")) # for local aafig extension # package data about: dict[str, str] = {} with (src_root / "tmuxp" / "__about__.py").open() as fp: exec(fp.read(), about) -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.intersphinx", - "sphinx_autodoc_typehints", - "sphinx.ext.todo", - "sphinx.ext.napoleon", - "sphinx.ext.linkcode", - "aafig", - "sphinx_fonts", - "argparse_exemplar", # Custom sphinx-argparse replacement - "sphinx_inline_tabs", - "sphinx_copybutton", - "sphinxext.opengraph", - "sphinxext.rediraffe", - "myst_parser", - "linkify_issues", - "sphinx_design", -] - -myst_enable_extensions = [ - "colon_fence", - "substitution", - "replacements", - "strikethrough", - "linkify", -] - -myst_heading_anchors = 4 - -templates_path = ["_templates"] - -source_suffix = {".rst": "restructuredtext", ".md": "markdown"} - -master_doc = "index" - -project = about["__title__"] -project_copyright = about["__copyright__"] - -version = "{}".format(".".join(about["__version__"].split("."))[:2]) -release = "{}".format(about["__version__"]) - -exclude_patterns = ["_build"] - -pygments_style = "monokai" -pygments_dark_style = "monokai" - -html_css_files = ["css/custom.css", "css/argparse-highlight.css"] -html_extra_path = ["manifest.json"] -html_static_path = ["_static"] -html_favicon = "_static/favicon.ico" -html_theme = "furo" -html_theme_path: list[str] = [] -html_theme_options: dict[str, str | list[dict[str, str]]] = { - "light_logo": "img/tmuxp.svg", - "dark_logo": "img/tmuxp.svg", - "footer_icons": [ - { - "name": "GitHub", - "url": about["__github__"], - "html": """ - - - - """, - "class": "", - }, - ], - "source_repository": f"{about['__github__']}/", - "source_branch": "master", - "source_directory": "docs/", -} -html_sidebars = { - "**": [ - "sidebar/scroll-start.html", - "sidebar/brand.html", - "sidebar/search.html", - "sidebar/navigation.html", - "sidebar/projects.html", - "sidebar/scroll-end.html", - ], -} - -# linkify_issues -issue_url_tpl = about["__github__"] + "/issues/{issue_id}" - -# sphinx.ext.autodoc -toc_object_entries_show_parents = "hide" -autodoc_default_options = { - "undoc-members": True, - "members": True, - "private-members": True, - "show-inheritance": True, - "member-order": "bysource", -} - -# sphinx-autodoc-typehints -# Suppress warnings for forward references that can't be resolved -# (types in TYPE_CHECKING blocks used for circular import avoidance) -suppress_warnings = [ - "sphinx_autodoc_typehints.forward_reference", -] - -# sphinxext.opengraph -ogp_site_url = about["__docs__"] -ogp_image = "_static/img/icons/icon-192x192.png" -ogp_site_name = about["__title__"] - -# sphinx-copybutton -copybutton_prompt_text = ( - r">>> |\.\.\. |> |\$ |\# | In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " -) -copybutton_prompt_is_regexp = True -copybutton_remove_prompts = True -copybutton_line_continuation_character = "\\" - -# sphinxext-rediraffe -rediraffe_redirects = "redirects.txt" -rediraffe_branch = "master~1" - -# aafig format, try to get working with pdf -aafig_format = {"latex": "pdf", "html": "gif"} -aafig_default_options = {"scale": 0.75, "aspect": 0.5, "proportional": True} - -# sphinx_fonts — self-hosted IBM Plex via Fontsource CDN -sphinx_fonts = [ - { - "family": "IBM Plex Sans", - "package": "@fontsource/ibm-plex-sans", - "version": "5.2.8", - "weights": [400, 500, 600, 700], - "styles": ["normal", "italic"], - "subsets": ["latin", "latin-ext"], +from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config # noqa: E402 + +import tmuxp # noqa: E402 + +conf = merge_sphinx_config( + project=about["__title__"], + version=about["__version__"], + copyright=about["__copyright__"], + source_repository=f"{about['__github__']}/", + docs_url=about["__docs__"], + source_branch="master", + light_logo="img/tmuxp.svg", + dark_logo="img/tmuxp.svg", + extra_extensions=["aafig", "sphinx_argparse_neo.exemplar"], + intersphinx_mapping={ + "python": ("https://docs.python.org/", None), + "libtmux": ("https://libtmux.git-pull.com/", None), }, - { - "family": "IBM Plex Mono", - "package": "@fontsource/ibm-plex-mono", - "version": "5.2.7", - "weights": [400, 500, 600, 700], - "styles": ["normal", "italic"], - "subsets": ["latin", "latin-ext"], - }, -] - -sphinx_font_preload = [ - ("IBM Plex Sans", 400, "normal"), # body text - ("IBM Plex Sans", 700, "normal"), # headings - ("IBM Plex Mono", 400, "normal"), # code blocks -] - -sphinx_font_fallbacks = [ - { - "family": "IBM Plex Sans Fallback", - "src": 'local("Arial"), local("Helvetica Neue"), local("Helvetica")', - "size_adjust": "110.6%", - "ascent_override": "92.7%", - "descent_override": "24.9%", - "line_gap_override": "0%", - }, - { - "family": "IBM Plex Mono Fallback", - "src": 'local("Courier New"), local("Courier")', - "size_adjust": "100%", - "ascent_override": "102.5%", - "descent_override": "27.5%", - "line_gap_override": "0%", - }, -] - -sphinx_font_css_variables = { - "--font-stack": '"IBM Plex Sans", "IBM Plex Sans Fallback", -apple-system, BlinkMacSystemFont, sans-serif', - "--font-stack--monospace": '"IBM Plex Mono", "IBM Plex Mono Fallback", SFMono-Regular, Menlo, Consolas, monospace', - "--font-stack--headings": "var(--font-stack)", -} - -intersphinx_mapping = { - "python": ("https://docs.python.org/", None), - "libtmux": ("https://libtmux.git-pull.com/", None), -} - - -def linkcode_resolve(domain: str, info: dict[str, str]) -> None | str: - """ - Determine the URL corresponding to Python object. - - Notes - ----- - From https://github.com/numpy/numpy/blob/v1.15.1/doc/source/conf.py, 7c49cfa - on Jul 31. License BSD-3. https://github.com/numpy/numpy/blob/v1.15.1/LICENSE.txt - """ - if domain != "py": - return None - - modname = info["module"] - fullname = info["fullname"] - - submod = sys.modules.get(modname) - if submod is None: - return None - - obj = submod - for part in fullname.split("."): - try: - obj = getattr(obj, part) - except Exception: # NOQA: PERF203 - return None - - # strip decorators, which would resolve to the source of the decorator - # possibly an upstream bug in getsourcefile, bpo-1764286 - try: - unwrap = inspect.unwrap - except AttributeError: - pass - else: - if callable(obj): - obj = unwrap(obj) - - try: - fn = inspect.getsourcefile(obj) - except Exception: - fn = None - if not fn: - return None - - try: - source, lineno = inspect.getsourcelines(obj) - except Exception: - lineno = None - - linespec = f"#L{lineno}-L{lineno + len(source) - 1}" if lineno else "" - - fn = relpath(fn, start=pathlib.Path(tmuxp.__file__).parent) - - if "dev" in about["__version__"]: - return "{}/blob/master/{}/{}/{}{}".format( - about["__github__"], - "src", - about["__package_name__"], - fn, - linespec, - ) - return "{}/blob/v{}/{}/{}/{}{}".format( - about["__github__"], - about["__version__"], - "src", - about["__package_name__"], - fn, - linespec, - ) - - -def remove_tabs_js(app: Sphinx, exc: Exception) -> None: - """Fix for sphinx-inline-tabs#18.""" - if app.builder.format == "html" and not exc: - tabs_js = pathlib.Path(app.builder.outdir) / "_static" / "tabs.js" - with contextlib.suppress(FileNotFoundError): - tabs_js.unlink() # When python 3.7 deprecated, use missing_ok=True - - -def setup(app: Sphinx) -> None: - """Sphinx setup hook.""" - app.add_js_file("js/spa-nav.js", loading_method="defer") - app.connect("build-finished", remove_tabs_js) + linkcode_resolve=make_linkcode_resolve(tmuxp, about["__github__"]), + # tmuxp-specific overrides + html_css_files=["css/custom.css"], + html_extra_path=["manifest.json"], + html_favicon="_static/favicon.ico", + aafig_format={"latex": "pdf", "html": "gif"}, + aafig_default_options={"scale": 0.75, "aspect": 0.5, "proportional": True}, + rediraffe_redirects="redirects.txt", +) +globals().update(conf) diff --git a/tests/docs/_ext/conftest.py b/tests/docs/_ext/conftest.py index fa2919bdcf..3124d5b7b7 100644 --- a/tests/docs/_ext/conftest.py +++ b/tests/docs/_ext/conftest.py @@ -1,11 +1,3 @@ """Fixtures and configuration for docs extension tests.""" from __future__ import annotations - -import pathlib -import sys - -# Add docs/_ext to path so we can import the extension module -docs_ext_path = pathlib.Path(__file__).parent.parent.parent.parent / "docs" / "_ext" -if str(docs_ext_path) not in sys.path: - sys.path.insert(0, str(docs_ext_path)) diff --git a/tests/docs/_ext/test_argparse_exemplar.py b/tests/docs/_ext/test_argparse_exemplar.py index 18679827d5..c25b48f9da 100644 --- a/tests/docs/_ext/test_argparse_exemplar.py +++ b/tests/docs/_ext/test_argparse_exemplar.py @@ -13,7 +13,8 @@ import typing as t import pytest -from argparse_exemplar import ( # type: ignore[import-not-found] +from docutils import nodes +from sphinx_argparse_neo.exemplar import ( ExemplarConfig, _extract_sections_from_container, _is_examples_section, @@ -26,7 +27,6 @@ process_node, transform_definition_list, ) -from docutils import nodes # --- is_examples_term tests --- diff --git a/tests/docs/_ext/test_argparse_lexer.py b/tests/docs/_ext/test_argparse_lexer.py index 7a621f1093..d5966007a8 100644 --- a/tests/docs/_ext/test_argparse_lexer.py +++ b/tests/docs/_ext/test_argparse_lexer.py @@ -5,7 +5,7 @@ import typing as t import pytest -from argparse_lexer import ( +from sphinx_argparse_neo.lexer import ( ArgparseHelpLexer, ArgparseLexer, ArgparseUsageLexer, diff --git a/tests/docs/_ext/test_argparse_roles.py b/tests/docs/_ext/test_argparse_roles.py index c31e12691a..08093c2481 100644 --- a/tests/docs/_ext/test_argparse_roles.py +++ b/tests/docs/_ext/test_argparse_roles.py @@ -5,7 +5,8 @@ import typing as t import pytest -from argparse_roles import ( +from docutils import nodes +from sphinx_argparse_neo.roles import ( cli_choice_role, cli_command_role, cli_default_role, @@ -14,7 +15,6 @@ normalize_options, register_roles, ) -from docutils import nodes # --- normalize_options tests --- diff --git a/tests/docs/_ext/test_cli_usage_lexer.py b/tests/docs/_ext/test_cli_usage_lexer.py index 3c32ebac6d..fab92a3d14 100644 --- a/tests/docs/_ext/test_cli_usage_lexer.py +++ b/tests/docs/_ext/test_cli_usage_lexer.py @@ -5,7 +5,7 @@ import typing as t import pytest -from cli_usage_lexer import ( +from sphinx_argparse_neo.cli_usage_lexer import ( CLIUsageLexer, tokenize_usage, ) diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py index e96a879be3..67c2f31216 100644 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -623,7 +623,7 @@ def test_setup_return_value() -> None: result = sphinx_fonts.setup(app) assert result == { - "version": "1.0", + "version": sphinx_fonts.__version__, "parallel_read_safe": True, "parallel_write_safe": True, } From b5ecfb4fdac34908cc10f7b88c4062e3828900bb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 10:15:47 -0500 Subject: [PATCH 83/89] docs(chore): Remove files now provided by sphinx-gptheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: All removed files are now bundled in sphinx-gptheme and loaded automatically, making local copies pure maintenance debt. what: - Delete argparse-highlight.css — identical to theme bundled version - Delete custom.css — replaced by tmuxp.css (12-line project-specific aspect-ratio overrides only; all generic CSS now from theme) - Delete _templates/page.html — identical to theme version; mask-icon moved to theme_options.mask_icon instead of hardcoded in template - Delete _templates/sidebar/brand.html — identical to theme version - Delete _templates/sidebar/projects.html — identical to theme version - Add docs/_static/css/tmuxp.css with only tmuxp-specific image aspect-ratio rules (tmuxp-demo, tmuxp-shell, tmuxp-dev-screenshot) - Update conf.py: css/custom.css → css/tmuxp.css, add theme_options={"mask_icon": "/_static/img/tmuxp.svg"} - Update uv.lock to gp-sphinx init-2 commit c2fe249 (theme: move mask-icon outside show_meta_app_icon_tags guard) --- docs/_static/css/argparse-highlight.css | 437 ------------------------ docs/_static/css/custom.css | 246 ------------- docs/_static/css/tmuxp.css | 12 + docs/_templates/page.html | 79 ----- docs/_templates/sidebar/brand.html | 18 - docs/_templates/sidebar/projects.html | 84 ----- docs/conf.py | 3 +- 7 files changed, 14 insertions(+), 865 deletions(-) delete mode 100644 docs/_static/css/argparse-highlight.css delete mode 100644 docs/_static/css/custom.css create mode 100644 docs/_static/css/tmuxp.css delete mode 100644 docs/_templates/page.html delete mode 100644 docs/_templates/sidebar/brand.html delete mode 100644 docs/_templates/sidebar/projects.html diff --git a/docs/_static/css/argparse-highlight.css b/docs/_static/css/argparse-highlight.css deleted file mode 100644 index f232c71c8c..0000000000 --- a/docs/_static/css/argparse-highlight.css +++ /dev/null @@ -1,437 +0,0 @@ -/* - * Argparse/CLI Highlighting Styles - * - * Styles for CLI inline roles and argparse help output highlighting. - * Uses "One Dark" inspired color palette (Python 3.14 argparse style). - * - * Color Palette: - * Background: #282C34 - * Default text: #CCCED4 - * Usage label: #61AFEF (blue, bold) - * Program name: #C678DD (purple, bold) - * Subcommands: #98C379 (green) - * Options: #56B6C2 (teal) - * Metavars: #E5C07B (yellow, italic) - * Choices: #98C379 (green) - * Headings: #E5E5E5 (bright, bold) - * Punctuation: #CCCED4 - */ - -/* ========================================================================== - Inline Role Styles - ========================================================================== */ - -/* - * Shared monospace font and code font-size for all CLI inline roles - */ -.cli-option, -.cli-metavar, -.cli-command, -.cli-default, -.cli-choice { - font-family: var(--font-stack--monospace); - font-size: var(--code-font-size); -} - -/* - * CLI Options - * - * Long options (--verbose) and short options (-h) both use teal color. - */ -.cli-option-long, -.cli-option-short { - color: #56b6c2; -} - -/* - * CLI Metavars - * - * Placeholder values like FILE, PATH, DIRECTORY. - * Yellow/amber to indicate "replace me" - distinct from flags (teal). - */ -.cli-metavar { - color: #e5c07b; - font-style: italic; -} - -/* - * CLI Commands and Choices - * - * Both use green to indicate valid enumerated values. - * Commands: subcommand names like sync, add, list - * Choices: enumeration values like json, yaml, table - */ -.cli-command, -.cli-choice { - color: #98c379; -} - -.cli-command { - font-weight: bold; -} - -/* - * CLI Default Values - * - * Default values shown in help text like None, "auto". - * Subtle styling to not distract from options. - */ -.cli-default { - color: #ccced4; - font-style: italic; -} - -/* ========================================================================== - Argparse Code Block Highlighting - ========================================================================== */ - -/* - * These styles apply within Pygments-highlighted code blocks using the - * argparse, argparse-usage, or argparse-help lexers. - */ - -/* Usage heading "usage:" - bold blue */ -.highlight-argparse .gh, -.highlight-argparse-usage .gh, -.highlight-argparse-help .gh { - color: #61afef; - font-weight: bold; -} - -/* Section headers like "positional arguments:", "options:" - neutral bright */ -.highlight-argparse .gs, -.highlight-argparse-help .gs { - color: #e5e5e5; - font-weight: bold; -} - -/* Long options --foo - teal */ -.highlight-argparse .nt, -.highlight-argparse-usage .nt, -.highlight-argparse-help .nt { - color: #56b6c2; - font-weight: normal; -} - -/* Short options -h - teal (same as long) */ -.highlight-argparse .na, -.highlight-argparse-usage .na, -.highlight-argparse-help .na { - color: #56b6c2; - font-weight: normal; -} - -/* Metavar placeholders FILE, PATH - yellow/amber italic */ -.highlight-argparse .nv, -.highlight-argparse-usage .nv, -.highlight-argparse-help .nv { - color: #e5c07b; - font-style: italic; -} - -/* Command/program names - purple bold */ -.highlight-argparse .nl, -.highlight-argparse-usage .nl, -.highlight-argparse-help .nl { - color: #c678dd; - font-weight: bold; -} - -/* Subcommands - bold green */ -.highlight-argparse .nf, -.highlight-argparse-usage .nf, -.highlight-argparse-help .nf { - color: #98c379; - font-weight: bold; -} - -/* Choice values - green */ -.highlight-argparse .no, -.highlight-argparse-usage .no, -.highlight-argparse-help .no, -.highlight-argparse .nc, -.highlight-argparse-usage .nc, -.highlight-argparse-help .nc { - color: #98c379; -} - -/* Punctuation [], {}, () - neutral gray */ -.highlight-argparse .p, -.highlight-argparse-usage .p, -.highlight-argparse-help .p { - color: #ccced4; -} - -/* Operators like | - neutral gray */ -.highlight-argparse .o, -.highlight-argparse-usage .o, -.highlight-argparse-help .o { - color: #ccced4; - font-weight: normal; -} - -/* ========================================================================== - Argparse Directive Highlighting (.. argparse:: output) - ========================================================================== */ - -/* - * These styles apply to the argparse directive output which uses custom - * nodes rendered by sphinx_argparse_neo. The directive adds highlight spans - * directly to the HTML output. - */ - -/* - * Usage Block (.argparse-usage) - * - * The usage block now has both .argparse-usage and .highlight-argparse-usage - * classes. The .highlight-argparse-usage selectors above already handle the - * token highlighting. These selectors provide fallback and ensure consistent - * styling. - */ - -/* Usage block container - match Pygments monokai background and code block styling */ -pre.argparse-usage { - background: var(--argparse-code-background); - font-size: var(--code-font-size); - padding: 0.625rem 0.875rem; - line-height: 1.5; - border-radius: 0.2rem; - scrollbar-color: var(--color-foreground-border) transparent; - scrollbar-width: thin; -} - -.argparse-usage .gh { - color: #61afef; - font-weight: bold; -} - -.argparse-usage .nt { - color: #56b6c2; - font-weight: normal; -} - -.argparse-usage .na { - color: #56b6c2; - font-weight: normal; -} - -.argparse-usage .nv { - color: #e5c07b; - font-style: italic; -} - -.argparse-usage .nl { - color: #c678dd; - font-weight: bold; -} - -.argparse-usage .nf { - color: #98c379; - font-weight: bold; -} - -.argparse-usage .no, -.argparse-usage .nc { - color: #98c379; -} - -.argparse-usage .o { - color: #ccced4; - font-weight: normal; -} - -.argparse-usage .p { - color: #ccced4; -} - -/* - * Argument Name (
) - * - * The argument names in the dl/dt structure are highlighted with - * semantic spans for options and metavars. - */ -.argparse-argument-name .nt { - color: #56b6c2; - font-weight: normal; -} - -.argparse-argument-name .na { - color: #56b6c2; - font-weight: normal; -} - -.argparse-argument-name .nv { - color: #e5c07b; - font-style: italic; -} - -.argparse-argument-name .nl { - color: #c678dd; - font-weight: bold; -} - -/* ========================================================================== - Argument Wrapper and Linking Styles - ========================================================================== */ - -/* - * Argparse-specific code background (monokai #272822) - * Uses a custom variable to avoid affecting Furo's --color-inline-code-background. - */ -:root { - --argparse-code-background: #272822; -} - -/* - * Background styling for argument names - subtle background like code.literal - * This provides visual weight and hierarchy for argument definitions. - */ -.argparse-argument-name { - background: var(--argparse-code-background); - border-radius: 0.2rem; - padding: 0.485rem 0.875rem; - font-family: var(--font-stack--monospace); - font-size: var(--code-font-size); - width: fit-content; - position: relative; -} - -/* - * Wrapper for linking - enables scroll-margin for fixed header navigation - * and :target pseudo-class for highlighting when linked. - */ -.argparse-argument-wrapper { - scroll-margin-top: 2.5rem; -} - -/* - * Headerlink anchor (¶) - hidden until hover - * Positioned outside the dt element to the right. - * Follows Sphinx documentation convention for linkable headings. - */ -.argparse-argument-name .headerlink { - visibility: hidden; - position: absolute; - right: -1.5rem; - top: 50%; - transform: translateY(-50%); - color: var(--color-foreground-secondary); - text-decoration: none; -} - -/* - * Show headerlink on hover or when targeted via URL fragment - */ -.argparse-argument-wrapper:hover .headerlink, -.argparse-argument-wrapper:target .headerlink { - visibility: visible; -} - -.argparse-argument-name .headerlink:hover:not(:visited) { - color: var(--color-foreground-primary); -} - -/* - * Light mode headerlink color overrides - * Needed because code block has dark background regardless of theme - */ -body[data-theme="light"] .argparse-argument-name .headerlink { - color: #9ca0a5; - - &:hover:not(:visited) { - color: #cfd0d0; - } -} - -@media (prefers-color-scheme: light) { - body:not([data-theme="dark"]) .argparse-argument-name .headerlink { - color: #9ca0a5; - - &:hover:not(:visited) { - color: #cfd0d0; - } - } -} - -/* - * Highlight when targeted via URL fragment - * Uses Furo's highlight-on-target color for consistency. - */ -.argparse-argument-wrapper:target .argparse-argument-name { - background-color: var(--color-highlight-on-target); -} - -/* - * Argument metadata definition list - * - * Renders metadata (Default, Type, Choices, Required) as a horizontal - * flexbox of key-value pairs and standalone tags. - */ -.argparse-argument-meta { - margin: 0.5rem 0 0 0; - padding: 0; - display: flex; - flex-wrap: wrap; - gap: 0.5rem 1rem; - align-items: center; -} - -.argparse-meta-item { - display: flex; - align-items: center; - gap: 0.25rem; -} - -.argparse-meta-key { - color: var(--color-foreground-secondary, #6c757d); - font-size: var(--code-font-size); -} - -.argparse-meta-key::after { - content: ":"; -} - -.argparse-meta-value .nv { - background: var(--argparse-code-background); - border-radius: 0.2rem; - padding: 0.1rem 0.3rem; - font-family: var(--font-stack--monospace); - font-size: var(--code-font-size); - color: #e5c07b; -} - -/* - * Meta tag (e.g., "Required") - follows Furo's guilabel pattern - * Uses semi-transparent amber background with border for visibility - * without the harshness of solid fills. Amber conveys "needs attention". - */ -.argparse-meta-tag { - background-color: #fef3c780; - border: 1px solid #fcd34d80; - color: var(--color-foreground-primary); - font-size: var(--code-font-size); - padding: 0.1rem 0.4rem; - border-radius: 0.2rem; - font-weight: 500; -} - -/* Dark mode: darker amber with adjusted border */ -body[data-theme="dark"] .argparse-meta-tag { - background-color: #78350f60; - border-color: #b4530980; -} - -@media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) .argparse-meta-tag { - background-color: #78350f60; - border-color: #b4530980; - } -} - -/* - * Help text description - * Adds spacing above for visual separation from argument name. - */ -.argparse-argument-help { - padding-block-start: 0.5rem; -} diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css deleted file mode 100644 index ed6640c746..0000000000 --- a/docs/_static/css/custom.css +++ /dev/null @@ -1,246 +0,0 @@ -.sidebar-tree p.indented-block { - padding: var(--sidebar-item-spacing-vertical) - var(--sidebar-item-spacing-horizontal) 0 - var(--sidebar-item-spacing-horizontal); - margin-bottom: 0; -} - -.sidebar-tree p.indented-block span.indent { - margin-left: var(--sidebar-item-spacing-horizontal); - display: block; -} - -.sidebar-tree p.indented-block .project-name { - font-size: var(--sidebar-item-font-size); - font-weight: bold; - margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); -} - -#sidebar-projects:not(.ready) { - visibility: hidden; -} - -.sidebar-tree .active { - font-weight: bold; -} - -/* ── Global heading refinements ───────────────────────────── - * Biome-inspired scale: medium weight (500) throughout — size - * and spacing carry hierarchy, not boldness. H4-H6 add eyebrow - * treatment (uppercase, muted). `article` prefix overrides - * Furo's bare h1-h6 selectors. - * ────────────────────────────────────────────────────────── */ -article h1 { - font-size: 1.8em; - font-weight: 500; - margin-top: 1.5rem; - margin-bottom: 0.75rem; -} - -article h2 { - font-size: 1.6em; - font-weight: 500; - margin-top: 2.5rem; - margin-bottom: 0.5rem; -} - -article h3 { - font-size: 1.15em; - font-weight: 500; - margin-top: 1.5rem; - margin-bottom: 0.375rem; -} - -article h4 { - font-size: 0.85em; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-foreground-secondary); - margin-top: 1rem; - margin-bottom: 0.25rem; -} - -article h5 { - font-size: 0.8em; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-foreground-secondary); -} - -article h6 { - font-size: 0.75em; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-foreground-secondary); -} - -/* ── Changelog heading extras ─────────────────────────────── - * Vertical spacing separates consecutive version entries. - * Category headings (h3) are muted. Item headings (h4) are - * subtle. Targets #history section from CHANGES markdown. - * ────────────────────────────────────────────────────────── */ - -/* Spacing between consecutive version entries */ -#history > section + section { - margin-top: 2.5rem; -} - -/* Category headings — muted secondary color */ -#history h3 { - color: var(--color-foreground-secondary); - margin-top: 1.25rem; -} - -/* Item headings — subtle, same size as body */ -#history h4 { - font-size: 1em; - margin-top: 1rem; - text-transform: none; - letter-spacing: normal; - color: inherit; -} - -/* ── Right-panel TOC refinements ──────────────────────────── - * Adjust Furo's table-of-contents proportions for better - * readability. Inspired by Starlight defaults (Biome docs). - * Uses Furo CSS variable overrides where possible. - * ────────────────────────────────────────────────────────── */ - -/* TOC font sizes: override Furo defaults (75% → 87.5%) */ -:root { - --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ - --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ -} - -/* More generous line-height for wrapped TOC entries */ -.toc-tree { - line-height: 1.4; -} - -/* ── Flexible right-panel TOC (inner-panel padding) ───────── - * Furo hardcodes .toc-drawer to width: 15em (SASS, compiled). - * min-width: 18em overrides it; long TOC entries wrap inside - * the box instead of blowing past the viewport. - * - * Padding lives on .toc-sticky (the inner panel), not on - * .toc-drawer (the outer aside). This matches Biome/Starlight - * where the aside defines dimensions and an inner wrapper - * (.right-sidebar-panel) controls content insets. The - * scrollbar sits naturally between content and viewport edge. - * - * Content area gets flex: 1 to absorb extra space on wide - * screens. At ≤82em Furo collapses the TOC to position: fixed; - * override right offset so the drawer fully hides off-screen. - * ────────────────────────────────────────────────────────── */ -.toc-drawer { - min-width: 18em; - flex-shrink: 0; - padding-right: 0; -} - -.toc-sticky { - padding-right: 1.5em; -} - -.content { - width: auto; - max-width: 46em; - flex: 1 1 46em; - padding: 0 2em; -} - -@media (max-width: 82em) { - .toc-drawer { - right: -18em; - } -} - -/* ── Body typography refinements ──────────────────────────── - * Improve paragraph readability with wider line-height and - * sharper text rendering. Furo already sets font-smoothing. - * - * IBM Plex tracks slightly wide at default spacing; -0.01em - * tightens it to feel more natural (matches tony.sh/tony.nl). - * Kerning + ligatures polish AV/To pairs and fi/fl combos. - * ────────────────────────────────────────────────────────── */ -body { - text-rendering: optimizeLegibility; - font-kerning: normal; - font-variant-ligatures: common-ligatures; - letter-spacing: -0.01em; -} - -/* ── Code block text rendering ──────────────────────────── - * Monospace needs fixed-width columns: disable kerning, - * ligatures, and letter-spacing that body sets for prose. - * optimizeSpeed skips heuristics that can shift the grid. - * ────────────────────────────────────────────────────────── */ -pre, -code, -kbd, -samp { - text-rendering: optimizeSpeed; - font-kerning: none; - font-variant-ligatures: none; - letter-spacing: normal; -} - -article { - line-height: 1.6; -} - -/* ── Image layout shift prevention ──────────────────────── - * Reserve space for images before they load. Furo already - * sets max-width: 100%; height: auto on img. We add - * content-visibility and badge-specific height to prevent CLS. - * ────────────────────────────────────────────────────────── */ - -img { - content-visibility: auto; -} - -/* Docutils emits :width:/:height: as inline CSS (style="width: Xpx; - * height: Ypx;") rather than HTML attributes. When Furo's - * max-width: 100% constrains width below the declared value, - * the fixed height causes distortion. height: auto + aspect-ratio - * lets the browser compute the correct height from the intrinsic - * ratio once loaded; before load, aspect-ratio reserves space - * at the intended proportion — preventing both CLS and distortion. */ -article img[loading="lazy"] { - height: auto !important; -} - -/* Per-image aspect ratios for CLS reservation before load */ -img[src*="tmuxp-demo"] { - aspect-ratio: 888 / 589; -} - -img[src*="tmuxp-shell"] { - aspect-ratio: 878 / 109; -} - -img[src*="tmuxp-dev-screenshot"] { - aspect-ratio: 1030 / 605; -} - -img[src*="shields.io"], -img[src*="badge.svg"], -img[src*="codecov.io"] { - height: 20px; - width: auto; - min-width: 60px; - border-radius: 3px; - background: var(--color-background-secondary); -} - -/* ── View Transitions (SPA navigation) ──────────────────── - * Crossfade between pages during SPA navigation. - * Browsers without View Transitions API get instant swap. - * ────────────────────────────────────────────────────────── */ -::view-transition-old(root), -::view-transition-new(root) { - animation-duration: 150ms; -} diff --git a/docs/_static/css/tmuxp.css b/docs/_static/css/tmuxp.css new file mode 100644 index 0000000000..326a8e765b --- /dev/null +++ b/docs/_static/css/tmuxp.css @@ -0,0 +1,12 @@ +/* Per-image aspect ratios for CLS reservation before load */ +img[src*="tmuxp-demo"] { + aspect-ratio: 888 / 589; +} + +img[src*="tmuxp-shell"] { + aspect-ratio: 878 / 109; +} + +img[src*="tmuxp-dev-screenshot"] { + aspect-ratio: 1030 / 605; +} diff --git a/docs/_templates/page.html b/docs/_templates/page.html deleted file mode 100644 index 0c1ad27ea1..0000000000 --- a/docs/_templates/page.html +++ /dev/null @@ -1,79 +0,0 @@ -{% extends "!page.html" %} -{%- block extrahead %} - {{ super() }} - {%- for href in font_preload_hrefs|default([]) %} - - {%- endfor %} - {%- if font_faces is defined and font_faces %} - - {%- endif %} - {%- if theme_show_meta_manifest_tag == true %} - - {% endif -%} - {%- if theme_show_meta_og_tags == true %} - - - - - - - - - - - - - - - - {% endif -%} - {%- if theme_show_meta_app_icon_tags == true %} - - - - - - - - - - - - - - - - - - - - {% endif -%} -{% endblock %} diff --git a/docs/_templates/sidebar/brand.html b/docs/_templates/sidebar/brand.html deleted file mode 100644 index 7fe241c009..0000000000 --- a/docs/_templates/sidebar/brand.html +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html deleted file mode 100644 index f70e6fe032..0000000000 --- a/docs/_templates/sidebar/projects.html +++ /dev/null @@ -1,84 +0,0 @@ - - diff --git a/docs/conf.py b/docs/conf.py index 208f183724..4451c405fb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -38,7 +38,8 @@ }, linkcode_resolve=make_linkcode_resolve(tmuxp, about["__github__"]), # tmuxp-specific overrides - html_css_files=["css/custom.css"], + theme_options={"mask_icon": "/_static/img/tmuxp.svg"}, + html_css_files=["css/tmuxp.css"], html_extra_path=["manifest.json"], html_favicon="_static/favicon.ico", aafig_format={"latex": "pdf", "html": "gif"}, From 7b8b142c887c2117fd19d84a10aaedb7f466eb9b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 10:24:57 -0500 Subject: [PATCH 84/89] =?UTF-8?q?docs(chore):=20Drop=20tmuxp.css=20?= =?UTF-8?q?=E2=80=94=20project-specific=20CLS=20rules=20not=20needed=20for?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/_static/css/tmuxp.css | 12 ------------ docs/conf.py | 1 - 2 files changed, 13 deletions(-) delete mode 100644 docs/_static/css/tmuxp.css diff --git a/docs/_static/css/tmuxp.css b/docs/_static/css/tmuxp.css deleted file mode 100644 index 326a8e765b..0000000000 --- a/docs/_static/css/tmuxp.css +++ /dev/null @@ -1,12 +0,0 @@ -/* Per-image aspect ratios for CLS reservation before load */ -img[src*="tmuxp-demo"] { - aspect-ratio: 888 / 589; -} - -img[src*="tmuxp-shell"] { - aspect-ratio: 878 / 109; -} - -img[src*="tmuxp-dev-screenshot"] { - aspect-ratio: 1030 / 605; -} diff --git a/docs/conf.py b/docs/conf.py index 4451c405fb..c2afba8c45 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,7 +39,6 @@ linkcode_resolve=make_linkcode_resolve(tmuxp, about["__github__"]), # tmuxp-specific overrides theme_options={"mask_icon": "/_static/img/tmuxp.svg"}, - html_css_files=["css/tmuxp.css"], html_extra_path=["manifest.json"], html_favicon="_static/favicon.ico", aafig_format={"latex": "pdf", "html": "gif"}, From ff9580d4956f537a7fe7e296d2b2389496efe518 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 06:24:14 -0500 Subject: [PATCH 85/89] test(sphinx_fonts): Remove tests for APIs not in packaged sphinx_fonts why: After rebasing onto master, trunk's unicode-range/multi-subset tests reference _unicode_range, _UNICODE_RANGES, and subset-aware _on_builder_inited which existed in the local docs/_ext/sphinx_fonts.py but are not yet in the packaged sphinx_fonts from gp-sphinx. what: - Remove 4 _unicode_range / _UNICODE_RANGES unit tests - Remove 2 _on_builder_inited tests for multi-subset and unicode_range support --- tests/docs/_ext/test_sphinx_fonts.py | 98 ---------------------------- 1 file changed, 98 deletions(-) diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py index 67c2f31216..df59c5208a 100644 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -96,36 +96,6 @@ def test_cdn_url_matches_template() -> None: assert url.endswith(".woff2") -# --- _unicode_range tests --- - - -def test_unicode_range_latin() -> None: - """_unicode_range returns a non-empty range for 'latin'.""" - result = sphinx_fonts._unicode_range("latin") - assert result.startswith("U+") - assert "U+0000" in result - - -def test_unicode_range_latin_ext() -> None: - """_unicode_range returns a non-empty range for 'latin-ext'.""" - result = sphinx_fonts._unicode_range("latin-ext") - assert result.startswith("U+") - assert result != sphinx_fonts._unicode_range("latin") - - -def test_unicode_range_unknown_subset() -> None: - """_unicode_range returns empty string for unknown subsets.""" - result = sphinx_fonts._unicode_range("klingon") - assert result == "" - - -def test_unicode_range_all_known_subsets_non_empty() -> None: - """Every subset in _UNICODE_RANGES produces a non-empty range.""" - for subset, urange in sphinx_fonts._UNICODE_RANGES.items(): - assert urange.startswith("U+"), f"subset {subset!r} has invalid range" - assert sphinx_fonts._unicode_range(subset) == urange - - # --- _download_font tests --- @@ -367,74 +337,6 @@ def test_on_builder_inited_explicit_subset( assert app._font_faces[0]["filename"] == "noto-sans-latin-ext-400-normal.woff2" -def test_on_builder_inited_multiple_subsets( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited downloads files for each subset and includes unicode_range.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/ibm-plex-sans", - "version": "5.2.8", - "family": "IBM Plex Sans", - "subsets": ["latin", "latin-ext"], - "weights": [400], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "ibm-plex-sans-latin-400-normal.woff2").write_bytes(b"data") - (cache / "ibm-plex-sans-latin-ext-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert len(app._font_faces) == 2 - filenames = [f["filename"] for f in app._font_faces] - assert "ibm-plex-sans-latin-400-normal.woff2" in filenames - assert "ibm-plex-sans-latin-ext-400-normal.woff2" in filenames - - # unicode_range should be populated for known subsets - latin_face = next(f for f in app._font_faces if "latin-400" in f["filename"]) - assert latin_face["unicode_range"].startswith("U+") - latin_ext_face = next(f for f in app._font_faces if "latin-ext" in f["filename"]) - assert latin_ext_face["unicode_range"].startswith("U+") - assert latin_face["unicode_range"] != latin_ext_face["unicode_range"] - - -def test_on_builder_inited_legacy_subset_gets_unicode_range( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Legacy single 'subset' config still produces unicode_range in font_faces.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/noto-sans", - "version": "5.0.0", - "family": "Noto Sans", - "subset": "latin", - "weights": [400], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "noto-sans-latin-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert len(app._font_faces) == 1 - assert app._font_faces[0]["unicode_range"].startswith("U+") - - def test_on_builder_inited_preload_uses_primary_subset( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From 52b16afd274c5ebaed5aefa43e86b28bd16b01c7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 06:31:17 -0500 Subject: [PATCH 86/89] chore(mypy): Remove unused ignore_missing_imports overrides why: After gp-sphinx migration, these modules are only imported in docs/ and tests/docs/ which are excluded from mypy scanning. what: - Remove aafigure, sphinx_argparse_neo, sphinx_fonts, docutils, pygments from mypy overrides --- pyproject.toml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 50327c2125..9920ead065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,18 +152,10 @@ enable_incomplete_feature = [] [[tool.mypy.overrides]] module = [ "shtab", - "aafigure", "IPython.*", "ptpython.*", "prompt_toolkit.*", "bpython", - "sphinx_argparse_neo", - "sphinx_argparse_neo.*", - "sphinx_fonts", - "docutils", - "docutils.*", - "pygments", - "pygments.*", ] ignore_missing_imports = true From 202af50760544c6a8862078c5cf98f671870e0da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 05:39:11 -0500 Subject: [PATCH 87/89] fix(mypy): Add aafigure to ignore_missing_imports overrides --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 9920ead065..a2ac7eb53e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,6 +156,7 @@ module = [ "ptpython.*", "prompt_toolkit.*", "bpython", + "aafigure", ] ignore_missing_imports = true From 7a2d63bd7113ee95e4453b4c15c17d70c63925fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 06:00:16 -0500 Subject: [PATCH 88/89] test(docs/_ext): Remove upstream extension tests now covered by PyPI packages why: sphinx-fonts and sphinx-argparse-neo are now standalone PyPI packages (0.0.1a0). Their test suites live upstream; tmuxp should not duplicate or maintain them. what: - Remove tests/docs/_ext/test_sphinx_fonts.py - Remove tests/docs/_ext/sphinx_argparse_neo/ (7 test files) - Remove tests/docs/_ext/test_argparse_{exemplar,lexer,roles}.py - Remove tests/docs/_ext/test_cli_usage_lexer.py - Remove tests/docs/__init__.py and supporting conftest/init files --- tests/docs/__init__.py | 3 - tests/docs/_ext/__init__.py | 3 - tests/docs/_ext/conftest.py | 3 - .../docs/_ext/sphinx_argparse_neo/__init__.py | 3 - .../docs/_ext/sphinx_argparse_neo/conftest.py | 237 ---- .../_ext/sphinx_argparse_neo/test_compat.py | 330 ----- .../_ext/sphinx_argparse_neo/test_nodes.py | 614 ---------- .../_ext/sphinx_argparse_neo/test_parser.py | 524 -------- .../_ext/sphinx_argparse_neo/test_renderer.py | 498 -------- .../_ext/sphinx_argparse_neo/test_utils.py | 162 --- tests/docs/_ext/test_argparse_exemplar.py | 1073 ----------------- tests/docs/_ext/test_argparse_lexer.py | 825 ------------- tests/docs/_ext/test_argparse_roles.py | 439 ------- tests/docs/_ext/test_cli_usage_lexer.py | 358 ------ tests/docs/_ext/test_sphinx_fonts.py | 576 --------- 15 files changed, 5648 deletions(-) delete mode 100644 tests/docs/__init__.py delete mode 100644 tests/docs/_ext/__init__.py delete mode 100644 tests/docs/_ext/conftest.py delete mode 100644 tests/docs/_ext/sphinx_argparse_neo/__init__.py delete mode 100644 tests/docs/_ext/sphinx_argparse_neo/conftest.py delete mode 100644 tests/docs/_ext/sphinx_argparse_neo/test_compat.py delete mode 100644 tests/docs/_ext/sphinx_argparse_neo/test_nodes.py delete mode 100644 tests/docs/_ext/sphinx_argparse_neo/test_parser.py delete mode 100644 tests/docs/_ext/sphinx_argparse_neo/test_renderer.py delete mode 100644 tests/docs/_ext/sphinx_argparse_neo/test_utils.py delete mode 100644 tests/docs/_ext/test_argparse_exemplar.py delete mode 100644 tests/docs/_ext/test_argparse_lexer.py delete mode 100644 tests/docs/_ext/test_argparse_roles.py delete mode 100644 tests/docs/_ext/test_cli_usage_lexer.py delete mode 100644 tests/docs/_ext/test_sphinx_fonts.py diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py deleted file mode 100644 index b6723bfd09..0000000000 --- a/tests/docs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for documentation extensions.""" - -from __future__ import annotations diff --git a/tests/docs/_ext/__init__.py b/tests/docs/_ext/__init__.py deleted file mode 100644 index 56548488ec..0000000000 --- a/tests/docs/_ext/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for docs/_ext Sphinx extensions.""" - -from __future__ import annotations diff --git a/tests/docs/_ext/conftest.py b/tests/docs/_ext/conftest.py deleted file mode 100644 index 3124d5b7b7..0000000000 --- a/tests/docs/_ext/conftest.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Fixtures and configuration for docs extension tests.""" - -from __future__ import annotations diff --git a/tests/docs/_ext/sphinx_argparse_neo/__init__.py b/tests/docs/_ext/sphinx_argparse_neo/__init__.py deleted file mode 100644 index 259f37c931..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for sphinx_argparse_neo extension.""" - -from __future__ import annotations diff --git a/tests/docs/_ext/sphinx_argparse_neo/conftest.py b/tests/docs/_ext/sphinx_argparse_neo/conftest.py deleted file mode 100644 index c805df1f30..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/conftest.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Fixtures and configuration for sphinx_argparse_neo tests.""" - -from __future__ import annotations - -import argparse -import pathlib -import sys - -import pytest - -# Add docs/_ext to path so we can import the extension module -docs_ext_path = ( - pathlib.Path(__file__).parent.parent.parent.parent.parent / "docs" / "_ext" -) -if str(docs_ext_path) not in sys.path: - sys.path.insert(0, str(docs_ext_path)) - - -@pytest.fixture -def simple_parser() -> argparse.ArgumentParser: - """Create a simple parser with basic arguments. - - Returns - ------- - argparse.ArgumentParser - Parser with a positional argument and a couple of options. - """ - parser = argparse.ArgumentParser( - prog="myapp", - description="A simple test application", - ) - parser.add_argument("filename", help="Input file to process") - parser.add_argument( - "-v", "--verbose", action="store_true", help="Enable verbose mode" - ) - parser.add_argument("-o", "--output", metavar="FILE", help="Output file") - return parser - - -@pytest.fixture -def parser_with_groups() -> argparse.ArgumentParser: - """Create a parser with custom argument groups. - - Returns - ------- - argparse.ArgumentParser - Parser with multiple argument groups. - """ - parser = argparse.ArgumentParser(prog="grouped", description="Parser with groups") - - input_group = parser.add_argument_group("Input Options", "Options for input") - input_group.add_argument("--input", "-i", required=True, help="Input file") - input_group.add_argument("--format", choices=["json", "yaml"], help="Input format") - - output_group = parser.add_argument_group("Output Options", "Options for output") - output_group.add_argument("--output", "-o", help="Output file") - output_group.add_argument("--pretty", action="store_true", help="Pretty print") - - return parser - - -@pytest.fixture -def parser_with_subcommands() -> argparse.ArgumentParser: - """Create a parser with subcommands. - - Returns - ------- - argparse.ArgumentParser - Parser with subparsers. - """ - parser = argparse.ArgumentParser(prog="cli", description="CLI with subcommands") - parser.add_argument("-v", "--verbose", action="store_true", help="Verbose mode") - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # Sync subcommand - sync_parser = subparsers.add_parser("sync", help="Synchronize repositories") - sync_parser.add_argument("repo", nargs="?", help="Repository to sync") - sync_parser.add_argument("-f", "--force", action="store_true", help="Force sync") - - # Add subcommand - add_parser = subparsers.add_parser("add", aliases=["a"], help="Add a repository") - add_parser.add_argument("url", help="Repository URL") - add_parser.add_argument("-n", "--name", help="Repository name") - - return parser - - -@pytest.fixture -def parser_with_mutex() -> argparse.ArgumentParser: - """Create a parser with mutually exclusive arguments. - - Returns - ------- - argparse.ArgumentParser - Parser with mutually exclusive group. - """ - parser = argparse.ArgumentParser(prog="mutex", description="Parser with mutex") - - mutex = parser.add_mutually_exclusive_group(required=True) - mutex.add_argument("-v", "--verbose", action="store_true", help="Verbose output") - mutex.add_argument("-q", "--quiet", action="store_true", help="Quiet output") - - return parser - - -@pytest.fixture -def parser_with_all_actions() -> argparse.ArgumentParser: - """Create a parser with all action types. - - Returns - ------- - argparse.ArgumentParser - Parser demonstrating all action types. - """ - parser = argparse.ArgumentParser(prog="actions", description="All action types") - - # store (default) - parser.add_argument("--name", help="Store action") - - # store_const - parser.add_argument( - "--enable", action="store_const", const="enabled", help="Store const" - ) - - # store_true / store_false - parser.add_argument("--flag", action="store_true", help="Store true") - parser.add_argument("--no-flag", action="store_false", help="Store false") - - # append - parser.add_argument("--item", action="append", help="Append action") - - # append_const - parser.add_argument( - "--debug", - action="append_const", - const="debug", - dest="features", - help="Append const", - ) - - # count - parser.add_argument("-v", "--verbose", action="count", default=0, help="Count") - - # BooleanOptionalAction (Python 3.9+) - parser.add_argument( - "--option", action=argparse.BooleanOptionalAction, help="Boolean optional" - ) - - return parser - - -@pytest.fixture -def parser_with_types() -> argparse.ArgumentParser: - """Create a parser with typed arguments. - - Returns - ------- - argparse.ArgumentParser - Parser with various type specifications. - """ - parser = argparse.ArgumentParser(prog="types", description="Typed arguments") - - parser.add_argument("--count", type=int, help="Integer argument") - parser.add_argument("--ratio", type=float, help="Float argument") - parser.add_argument("--path", type=pathlib.Path, help="Path argument") - parser.add_argument("--choice", type=str, choices=["a", "b", "c"], help="Choices") - - return parser - - -@pytest.fixture -def parser_with_nargs() -> argparse.ArgumentParser: - """Create a parser demonstrating nargs variants. - - Returns - ------- - argparse.ArgumentParser - Parser with various nargs specifications. - """ - parser = argparse.ArgumentParser(prog="nargs", description="Nargs variants") - - parser.add_argument("single", help="Single positional") - parser.add_argument("optional", nargs="?", default="default", help="Optional") - parser.add_argument("zero_or_more", nargs="*", help="Zero or more") - parser.add_argument("--one-or-more", nargs="+", help="One or more") - parser.add_argument("--exactly-two", nargs=2, metavar=("A", "B"), help="Exactly 2") - parser.add_argument("remainder", nargs=argparse.REMAINDER, help="Remainder") - - return parser - - -@pytest.fixture -def parser_with_defaults() -> argparse.ArgumentParser: - """Create a parser with various default values. - - Returns - ------- - argparse.ArgumentParser - Parser demonstrating default handling. - """ - parser = argparse.ArgumentParser(prog="defaults") - - parser.add_argument("--none-default", default=None, help="None default") - parser.add_argument("--string-default", default="hello", help="String default") - parser.add_argument("--int-default", default=42, type=int, help="Int default") - parser.add_argument("--list-default", default=[1, 2], help="List default") - parser.add_argument("--suppress", default=argparse.SUPPRESS, help=argparse.SUPPRESS) - - return parser - - -@pytest.fixture -def nested_subcommands_parser() -> argparse.ArgumentParser: - """Create a parser with nested subcommands. - - Returns - ------- - argparse.ArgumentParser - Parser with multiple levels of subparsers. - """ - parser = argparse.ArgumentParser(prog="nested", description="Nested subcommands") - - level1 = parser.add_subparsers(dest="level1") - - # First level: repo - repo = level1.add_parser("repo", help="Repository commands") - repo_subs = repo.add_subparsers(dest="level2") - - # Second level: repo clone - clone = repo_subs.add_parser("clone", help="Clone a repository") - clone.add_argument("url", help="Repository URL") - - # Second level: repo list - repo_subs.add_parser("list", help="List repositories") - - return parser diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_compat.py b/tests/docs/_ext/sphinx_argparse_neo/test_compat.py deleted file mode 100644 index 417e29a6e1..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_compat.py +++ /dev/null @@ -1,330 +0,0 @@ -"""Tests for sphinx_argparse_neo.compat module.""" - -from __future__ import annotations - -import sys -import typing as t - -import pytest -from sphinx_argparse_neo.compat import ( - MockModule, - get_parser_from_entry_point, - get_parser_from_module, - import_module, - mock_imports, -) - -# --- MockModule tests --- - - -def test_mock_module_name() -> None: - """Test MockModule name attribute.""" - mock = MockModule("mypackage.submodule") - assert mock.__name__ == "mypackage.submodule" - - -def test_mock_module_repr() -> None: - """Test MockModule string representation.""" - mock = MockModule("mypackage") - assert repr(mock) == "" - - -def test_mock_module_getattr() -> None: - """Test MockModule attribute access.""" - mock = MockModule("mypackage") - child = mock.submodule - - assert isinstance(child, MockModule) - assert child.__name__ == "mypackage.submodule" - - -def test_mock_module_nested_getattr() -> None: - """Test MockModule nested attribute access.""" - mock = MockModule("pkg") - deep = mock.level1.level2.level3 - - assert deep.__name__ == "pkg.level1.level2.level3" - - -def test_mock_module_callable() -> None: - """Test MockModule is callable.""" - mock = MockModule("mypackage") - result = mock() - - assert result is mock - - -def test_mock_module_callable_with_args() -> None: - """Test MockModule callable with arguments.""" - mock = MockModule("mypackage") - result = mock(1, 2, 3, key="value") - - assert result is mock - - -def test_mock_module_chained_call() -> None: - """Test MockModule chained attribute access and call.""" - mock = MockModule("pkg") - result = mock.SomeClass() - - assert isinstance(result, MockModule) - - -# --- mock_imports context manager tests --- - - -def test_mock_imports_adds_to_sys_modules() -> None: - """Test that mock_imports adds modules to sys.modules.""" - module_name = "test_fake_module_xyz" - - assert module_name not in sys.modules - - with mock_imports([module_name]): - assert module_name in sys.modules - assert isinstance(sys.modules[module_name], MockModule) - - assert module_name not in sys.modules - - -def test_mock_imports_multiple_modules() -> None: - """Test mocking multiple modules.""" - modules = ["fake_a", "fake_b", "fake_c"] - - with mock_imports(modules): - for name in modules: - assert name in sys.modules - - for name in modules: - assert name not in sys.modules - - -def test_mock_imports_nested_modules() -> None: - """Test mocking nested module paths.""" - modules = ["fake_pkg", "fake_pkg.sub", "fake_pkg.sub.deep"] - - with mock_imports(modules): - for name in modules: - assert name in sys.modules - - for name in modules: - assert name not in sys.modules - - -def test_mock_imports_does_not_override_existing() -> None: - """Test that mock_imports doesn't override existing modules.""" - # argparse is already imported - original = sys.modules["argparse"] - - with mock_imports(["argparse"]): - # Should not be replaced - assert sys.modules["argparse"] is original - - assert sys.modules["argparse"] is original - - -def test_mock_imports_cleanup_on_exception() -> None: - """Test that mock_imports cleans up even on exception.""" - module_name = "fake_exception_test" - exc_msg = "Test exception" - - with pytest.raises(ValueError), mock_imports([module_name]): - assert module_name in sys.modules - raise ValueError(exc_msg) - - assert module_name not in sys.modules - - -def test_mock_imports_allows_import() -> None: - """Test that mocked modules can be imported.""" - module_name = "fake_importable" - - with mock_imports([module_name]): - # This should work without ImportError - import fake_importable # type: ignore[import-not-found] - - assert fake_importable.__name__ == "fake_importable" - - -# --- import_module tests --- - - -def test_import_module_builtin() -> None: - """Test importing a built-in module.""" - mod = import_module("argparse") - assert hasattr(mod, "ArgumentParser") - - -def test_import_module_stdlib() -> None: - """Test importing a stdlib module.""" - mod = import_module("os.path") - assert hasattr(mod, "join") - - -def test_import_module_not_found() -> None: - """Test importing a non-existent module.""" - with pytest.raises(ModuleNotFoundError): - import_module("nonexistent_module_xyz") - - -# --- get_parser_from_module tests --- - - -def test_get_parser_from_module_argparse() -> None: - """Test getting parser from argparse module itself.""" - # Create a test module with a parser factory - import types - - test_module = types.ModuleType("test_parser_module") - - def create_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="test") - - test_module.create_parser = create_parser # type: ignore[attr-defined] - sys.modules["test_parser_module"] = test_module - - try: - parser = get_parser_from_module("test_parser_module", "create_parser") - assert parser.prog == "test" - finally: - del sys.modules["test_parser_module"] - - -def test_get_parser_from_module_with_mock() -> None: - """Test getting parser with mocked dependencies.""" - import types - - test_module = types.ModuleType("test_mock_parser") - - def create_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="mocked") - - test_module.create_parser = create_parser # type: ignore[attr-defined] - sys.modules["test_mock_parser"] = test_module - - try: - parser = get_parser_from_module( - "test_mock_parser", - "create_parser", - mock_modules=["fake_dependency"], - ) - assert parser.prog == "mocked" - finally: - del sys.modules["test_mock_parser"] - - -def test_get_parser_from_module_dotted_path() -> None: - """Test getting parser from class method.""" - import types - - test_module = types.ModuleType("test_class_parser") - - class CLI: - @staticmethod - def create_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="from_class") - - test_module.CLI = CLI # type: ignore[attr-defined] - sys.modules["test_class_parser"] = test_module - - try: - parser = get_parser_from_module("test_class_parser", "CLI.create_parser") - assert parser.prog == "from_class" - finally: - del sys.modules["test_class_parser"] - - -def test_get_parser_from_module_not_found() -> None: - """Test error when module not found.""" - with pytest.raises(ModuleNotFoundError): - get_parser_from_module("nonexistent_xyz", "func") - - -def test_get_parser_from_module_func_not_found() -> None: - """Test error when function not found.""" - with pytest.raises(AttributeError): - get_parser_from_module("argparse", "nonexistent_func") - - -# --- get_parser_from_entry_point tests --- - - -def test_get_parser_from_entry_point_valid() -> None: - """Test parsing valid entry point format.""" - import types - - test_module = types.ModuleType("test_entry_point") - - def get_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="entry") - - test_module.get_parser = get_parser # type: ignore[attr-defined] - sys.modules["test_entry_point"] = test_module - - try: - parser = get_parser_from_entry_point("test_entry_point:get_parser") - assert parser.prog == "entry" - finally: - del sys.modules["test_entry_point"] - - -def test_get_parser_from_entry_point_invalid_format() -> None: - """Test error on invalid entry point format.""" - with pytest.raises(ValueError) as exc_info: - get_parser_from_entry_point("no_colon_separator") - - assert "Invalid entry point format" in str(exc_info.value) - - -def test_get_parser_from_entry_point_with_class() -> None: - """Test entry point with class method.""" - import types - - test_module = types.ModuleType("test_entry_class") - - class Factory: - @staticmethod - def parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="factory") - - test_module.Factory = Factory # type: ignore[attr-defined] - sys.modules["test_entry_class"] = test_module - - try: - parser = get_parser_from_entry_point("test_entry_class:Factory.parser") - assert parser.prog == "factory" - finally: - del sys.modules["test_entry_class"] - - -def test_get_parser_from_entry_point_with_mock() -> None: - """Test entry point with mocked modules.""" - import types - - test_module = types.ModuleType("test_entry_mock") - - def make_parser() -> t.Any: - import argparse - - return argparse.ArgumentParser(prog="with_mock") - - test_module.make_parser = make_parser # type: ignore[attr-defined] - sys.modules["test_entry_mock"] = test_module - - try: - parser = get_parser_from_entry_point( - "test_entry_mock:make_parser", - mock_modules=["some_optional_dep"], - ) - assert parser.prog == "with_mock" - finally: - del sys.modules["test_entry_mock"] diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py b/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py deleted file mode 100644 index f00594c1dd..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_nodes.py +++ /dev/null @@ -1,614 +0,0 @@ -"""Tests for sphinx_argparse_neo.nodes module.""" - -from __future__ import annotations - -import re -import typing as t - -import pytest -from docutils import nodes -from sphinx_argparse_neo.nodes import ( - _generate_argument_id, - argparse_argument, - argparse_group, - argparse_program, - argparse_subcommand, - argparse_subcommands, - argparse_usage, -) - -# --- Node creation tests --- - - -def test_argparse_program_creation() -> None: - """Test creating argparse_program node.""" - node = argparse_program() - node["prog"] = "myapp" - - assert node["prog"] == "myapp" - assert isinstance(node, nodes.General) - assert isinstance(node, nodes.Element) - - -def test_argparse_usage_creation() -> None: - """Test creating argparse_usage node.""" - node = argparse_usage() - node["usage"] = "myapp [-h] [--verbose] command" - - assert node["usage"] == "myapp [-h] [--verbose] command" - - -def test_argparse_group_creation() -> None: - """Test creating argparse_group node.""" - node = argparse_group() - node["title"] = "Output Options" - node["description"] = "Control output format" - - assert node["title"] == "Output Options" - assert node["description"] == "Control output format" - - -def test_argparse_argument_creation() -> None: - """Test creating argparse_argument node.""" - node = argparse_argument() - node["names"] = ["-v", "--verbose"] - node["help"] = "Enable verbose mode" - node["metavar"] = None - node["required"] = False - - assert node["names"] == ["-v", "--verbose"] - assert node["help"] == "Enable verbose mode" - - -def test_argparse_subcommands_creation() -> None: - """Test creating argparse_subcommands node.""" - node = argparse_subcommands() - node["title"] = "Commands" - - assert node["title"] == "Commands" - - -def test_argparse_subcommand_creation() -> None: - """Test creating argparse_subcommand node.""" - node = argparse_subcommand() - node["name"] = "sync" - node["aliases"] = ["s"] - node["help"] = "Synchronize repositories" - - assert node["name"] == "sync" - assert node["aliases"] == ["s"] - - -# --- Node nesting tests --- - - -def test_program_can_contain_usage() -> None: - """Test that program node can contain usage node.""" - program = argparse_program() - program["prog"] = "myapp" - - usage = argparse_usage() - usage["usage"] = "myapp [-h]" - - program.append(usage) - - assert len(program.children) == 1 - assert isinstance(program.children[0], argparse_usage) - - -def test_program_can_contain_groups() -> None: - """Test that program node can contain group nodes.""" - program = argparse_program() - - group1 = argparse_group() - group1["title"] = "Positional Arguments" - - group2 = argparse_group() - group2["title"] = "Optional Arguments" - - program.append(group1) - program.append(group2) - - assert len(program.children) == 2 - - -def test_group_can_contain_arguments() -> None: - """Test that group node can contain argument nodes.""" - group = argparse_group() - group["title"] = "Options" - - arg1 = argparse_argument() - arg1["names"] = ["-v"] - - arg2 = argparse_argument() - arg2["names"] = ["-o"] - - group.append(arg1) - group.append(arg2) - - assert len(group.children) == 2 - - -def test_subcommands_can_contain_subcommand() -> None: - """Test that subcommands container can contain subcommand nodes.""" - container = argparse_subcommands() - container["title"] = "Commands" - - sub1 = argparse_subcommand() - sub1["name"] = "sync" - - sub2 = argparse_subcommand() - sub2["name"] = "add" - - container.append(sub1) - container.append(sub2) - - assert len(container.children) == 2 - - -def test_subcommand_can_contain_program() -> None: - """Test that subcommand can contain nested program (for recursion).""" - subcommand = argparse_subcommand() - subcommand["name"] = "sync" - - nested_program = argparse_program() - nested_program["prog"] = "myapp sync" - - subcommand.append(nested_program) - - assert len(subcommand.children) == 1 - assert isinstance(subcommand.children[0], argparse_program) - - -# --- Attribute handling tests --- - - -def test_argument_with_all_attributes() -> None: - """Test argument node with all possible attributes.""" - node = argparse_argument() - node["names"] = ["-f", "--file"] - node["help"] = "Input file" - node["metavar"] = "FILE" - node["required"] = True - node["default_string"] = "input.txt" - node["choices"] = ["a", "b", "c"] - node["type_name"] = "str" - node["mutex"] = False - node["mutex_required"] = False - - assert node["names"] == ["-f", "--file"] - assert node["help"] == "Input file" - assert node["metavar"] == "FILE" - assert node["required"] is True - assert node["default_string"] == "input.txt" - assert node["choices"] == ["a", "b", "c"] - assert node["type_name"] == "str" - - -def test_argument_with_mutex_marker() -> None: - """Test argument node marked as part of mutex group.""" - node = argparse_argument() - node["names"] = ["-v"] - node["mutex"] = True - node["mutex_required"] = True - - assert node["mutex"] is True - assert node["mutex_required"] is True - - -def test_node_get_with_default() -> None: - """Test getting attributes with defaults.""" - node = argparse_argument() - node["names"] = ["-v"] - - # Attribute that exists - assert node.get("names") == ["-v"] - - # Attribute that doesn't exist - get() returns None - assert node.get("nonexistent") is None - - # Attribute with explicit default - assert node.get("help", "default help") == "default help" - - -# --- Full tree construction test --- - - -def test_full_node_tree() -> None: - """Test constructing a complete node tree.""" - # Root program - program = argparse_program() - program["prog"] = "myapp" - - # Usage - usage = argparse_usage() - usage["usage"] = "myapp [-h] [-v] command" - program.append(usage) - - # Positional group - pos_group = argparse_group() - pos_group["title"] = "Positional Arguments" - - cmd_arg = argparse_argument() - cmd_arg["names"] = ["command"] - cmd_arg["help"] = "Command to run" - pos_group.append(cmd_arg) - program.append(pos_group) - - # Optional group - opt_group = argparse_group() - opt_group["title"] = "Optional Arguments" - - verbose = argparse_argument() - verbose["names"] = ["-v", "--verbose"] - verbose["help"] = "Verbose mode" - opt_group.append(verbose) - program.append(opt_group) - - # Subcommands - subs = argparse_subcommands() - subs["title"] = "Commands" - - sync_sub = argparse_subcommand() - sync_sub["name"] = "sync" - sync_sub["help"] = "Sync repos" - subs.append(sync_sub) - - program.append(subs) - - # Verify tree structure - assert len(program.children) == 4 # usage, pos_group, opt_group, subs - assert isinstance(program.children[0], argparse_usage) - assert isinstance(program.children[1], argparse_group) - assert isinstance(program.children[2], argparse_group) - assert isinstance(program.children[3], argparse_subcommands) - - -# --- ID generation tests --- - - -def test_generate_argument_id_short_option() -> None: - """Test ID generation for short option.""" - assert _generate_argument_id(["-L"]) == "L" - - -def test_generate_argument_id_long_option() -> None: - """Test ID generation for long option.""" - assert _generate_argument_id(["--help"]) == "help" - - -def test_generate_argument_id_multiple_names() -> None: - """Test ID generation for argument with multiple names.""" - assert _generate_argument_id(["-v", "--verbose"]) == "v-verbose" - - -def test_generate_argument_id_with_prefix() -> None: - """Test ID generation with prefix for namespace isolation.""" - assert _generate_argument_id(["-L"], "shell") == "shell-L" - assert _generate_argument_id(["--help"], "load") == "load-help" - - -def test_generate_argument_id_positional() -> None: - """Test ID generation for positional argument.""" - assert _generate_argument_id(["filename"]) == "filename" - - -def test_generate_argument_id_empty() -> None: - """Test ID generation with empty names list.""" - assert _generate_argument_id([]) == "" - - -def test_generate_argument_id_prefix_no_names() -> None: - """Test that prefix alone doesn't create ID when no names.""" - assert _generate_argument_id([], "shell") == "" - - -# --- HTML rendering tests using NamedTuple for parametrization --- - - -class ArgumentHTMLCase(t.NamedTuple): - """Test case for argument HTML rendering.""" - - test_id: str - names: list[str] - metavar: str | None - help_text: str | None - default: str | None - type_name: str | None - required: bool - id_prefix: str - expected_patterns: list[str] # Regex patterns to match - - -ARGUMENT_HTML_CASES = [ - ArgumentHTMLCase( - test_id="short_option_with_metavar", - names=["-L"], - metavar="socket-name", - help_text="pass-through for tmux -L", - default="None", - type_name=None, - required=False, - id_prefix="shell", - expected_patterns=[ - r'
', - r'
', - r'-L', - r'socket-name', - r'', - r'
', - r'
Default
', - r'
None
', - r"
", - ], - ), - ArgumentHTMLCase( - test_id="long_option", - names=["--help"], - metavar=None, - help_text="show help", - default=None, - type_name=None, - required=False, - id_prefix="", - expected_patterns=[ - r'--help', - r'id="help"', - r'href="#help"', - ], - ), - ArgumentHTMLCase( - test_id="positional_argument", - names=["filename"], - metavar=None, - help_text="input file", - default=None, - type_name=None, - required=False, - id_prefix="", - expected_patterns=[ - r'filename', - r'id="filename"', - ], - ), - ArgumentHTMLCase( - test_id="multiple_names", - names=["-v", "--verbose"], - metavar=None, - help_text="Enable verbose mode", - default=None, - type_name=None, - required=False, - id_prefix="load", - expected_patterns=[ - r'id="load-v-verbose"', - r'-v', - r'--verbose', - r'href="#load-v-verbose"', - ], - ), - ArgumentHTMLCase( - test_id="metadata_definition_list", - names=["workspace_file"], - metavar="workspace-file", - help_text="checks current tmuxp for workspace files.", - default="None", - type_name="str", - required=True, - id_prefix="edit", - expected_patterns=[ - r'
', - r'
Default
', - r'
None
', - r'
Type
', - r'
str
', - r'
Required
', - r"
", - ], - ), -] - - -class MockTranslator: - """Mock HTML5Translator for testing HTML generation.""" - - def __init__(self) -> None: - """Initialize mock translator.""" - self.body: list[str] = [] - - def encode(self, text: str) -> str: - """HTML encode text.""" - return str(text).replace("&", "&").replace("<", "<").replace(">", ">") - - -def render_argument_to_html( - names: list[str], - metavar: str | None, - help_text: str | None, - default: str | None, - type_name: str | None, - required: bool, - id_prefix: str, -) -> str: - """Render an argument node to HTML string for testing. - - Parameters - ---------- - names - Argument names (e.g., ["-v", "--verbose"]). - metavar - Optional metavar (e.g., "FILE"). - help_text - Help text for the argument. - default - Default value string. - type_name - Type name for the argument (e.g., "str", "int"). - required - Whether the argument is required. - id_prefix - Prefix for the argument ID. - - Returns - ------- - str - HTML string from the mock translator's body. - """ - from sphinx_argparse_neo.nodes import ( - depart_argparse_argument_html, - visit_argparse_argument_html, - ) - - node = argparse_argument() - node["names"] = names - node["metavar"] = metavar - node["help"] = help_text - node["default_string"] = default - node["type_name"] = type_name - node["required"] = required - node["id_prefix"] = id_prefix - - translator = MockTranslator() - visit_argparse_argument_html(translator, node) - depart_argparse_argument_html(translator, node) - - return "".join(translator.body) - - -@pytest.mark.parametrize( - "case", - ARGUMENT_HTML_CASES, - ids=lambda c: c.test_id, -) -def test_argument_html_rendering(case: ArgumentHTMLCase) -> None: - """Test HTML output for argument nodes.""" - html = render_argument_to_html( - names=case.names, - metavar=case.metavar, - help_text=case.help_text, - default=case.default, - type_name=case.type_name, - required=case.required, - id_prefix=case.id_prefix, - ) - - for pattern in case.expected_patterns: - assert re.search(pattern, html), f"Pattern not found: {pattern}\nHTML: {html}" - - -def test_argument_wrapper_has_id() -> None: - """Verify wrapper div has correct ID attribute.""" - html = render_argument_to_html( - names=["-f", "--file"], - metavar="PATH", - help_text="Input file", - default=None, - type_name=None, - required=False, - id_prefix="convert", - ) - - assert 'id="convert-f-file"' in html - assert '
None: - """Verify headerlink anchor exists with correct href.""" - html = render_argument_to_html( - names=["--output"], - metavar="FILE", - help_text="Output file", - default=None, - type_name=None, - required=False, - id_prefix="freeze", - ) - - assert '' in html - - -def test_default_value_styled() -> None: - """Verify default value is wrapped in nv span within definition list.""" - html = render_argument_to_html( - names=["--format"], - metavar=None, - help_text="Output format", - default="json", - type_name=None, - required=False, - id_prefix="", - ) - - assert '
' in html - assert '
Default
' in html - assert '
json
' in html - - -def test_wrapper_div_closed() -> None: - """Verify wrapper div is properly closed.""" - html = render_argument_to_html( - names=["-v"], - metavar=None, - help_text="Verbose", - default=None, - type_name=None, - required=False, - id_prefix="", - ) - - # Count opening and closing div tags - open_divs = html.count("") - assert open_divs == close_divs, f"Unbalanced divs in HTML: {html}" - - -def test_argument_no_id_prefix() -> None: - """Test argument rendering without ID prefix.""" - html = render_argument_to_html( - names=["--debug"], - metavar=None, - help_text="Enable debug mode", - default=None, - type_name=None, - required=False, - id_prefix="", - ) - - assert 'id="debug"' in html - assert 'href="#debug"' in html - - -def test_metadata_uses_definition_list() -> None: - """Verify metadata renders as definition list, not inline paragraph.""" - html = render_argument_to_html( - names=["--format"], - metavar=None, - help_text="Output format", - default="json", - type_name="str", - required=False, - id_prefix="", - ) - - assert '
' in html - assert '
Default
' in html - assert '
json
' in html - assert '
Type
' in html - assert '
str
' in html - - -def test_required_renders_as_tag() -> None: - """Verify Required renders as standalone tag, not key-value.""" - html = render_argument_to_html( - names=["--config"], - metavar="FILE", - help_text="Config file", - default=None, - type_name=None, - required=True, - id_prefix="", - ) - - assert '
Required
' in html - # Should NOT have a matching dd for Required - assert 'argparse-meta-value">Required' not in html diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_parser.py b/tests/docs/_ext/sphinx_argparse_neo/test_parser.py deleted file mode 100644 index 48f43d937f..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_parser.py +++ /dev/null @@ -1,524 +0,0 @@ -"""Tests for sphinx_argparse_neo.parser module.""" - -from __future__ import annotations - -import argparse -import typing as t - -import pytest -from sphinx_argparse_neo.parser import ( - ArgumentInfo, - ParserInfo, - SubcommandInfo, - _extract_argument, - _format_default, - _get_action_name, - _get_type_name, - extract_parser, -) - -# --- _format_default tests --- - - -class FormatDefaultFixture(t.NamedTuple): - """Test fixture for _format_default function.""" - - test_id: str - default: t.Any - expected: str | None - - -FORMAT_DEFAULT_FIXTURES: list[FormatDefaultFixture] = [ - FormatDefaultFixture( - test_id="none_value", - default=None, - expected="None", - ), - FormatDefaultFixture( - test_id="string_value", - default="hello", - expected="hello", - ), - FormatDefaultFixture( - test_id="integer_value", - default=42, - expected="42", - ), - FormatDefaultFixture( - test_id="float_value", - default=3.14, - expected="3.14", - ), - FormatDefaultFixture( - test_id="list_value", - default=[1, 2, 3], - expected="[1, 2, 3]", - ), - FormatDefaultFixture( - test_id="suppress_value", - default=argparse.SUPPRESS, - expected=None, - ), - FormatDefaultFixture( - test_id="empty_string", - default="", - expected="", - ), - FormatDefaultFixture( - test_id="boolean_true", - default=True, - expected="True", - ), - FormatDefaultFixture( - test_id="boolean_false", - default=False, - expected="False", - ), -] - - -@pytest.mark.parametrize( - FormatDefaultFixture._fields, - FORMAT_DEFAULT_FIXTURES, - ids=[f.test_id for f in FORMAT_DEFAULT_FIXTURES], -) -def test_format_default(test_id: str, default: t.Any, expected: str | None) -> None: - """Test default value formatting.""" - assert _format_default(default) == expected - - -# --- _get_type_name tests --- - - -def test_get_type_name_int() -> None: - """Test type name extraction for int.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--count", type=int) - assert _get_type_name(action) == "int" - - -def test_get_type_name_float() -> None: - """Test type name extraction for float.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--ratio", type=float) - assert _get_type_name(action) == "float" - - -def test_get_type_name_str() -> None: - """Test type name extraction for str.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--name", type=str) - assert _get_type_name(action) == "str" - - -def test_get_type_name_none() -> None: - """Test type name extraction when no type specified.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--name") - assert _get_type_name(action) is None - - -def test_get_type_name_callable() -> None: - """Test type name extraction for callable types.""" - from pathlib import Path - - parser = argparse.ArgumentParser() - action = parser.add_argument("--path", type=Path) - assert _get_type_name(action) == "Path" - - -# --- _get_action_name tests --- - - -class ActionNameFixture(t.NamedTuple): - """Test fixture for _get_action_name function.""" - - test_id: str - action_kwargs: dict[str, t.Any] - expected: str - - -ACTION_NAME_FIXTURES: list[ActionNameFixture] = [ - ActionNameFixture( - test_id="store_default", - action_kwargs={"dest": "name"}, - expected="store", - ), - ActionNameFixture( - test_id="store_true", - action_kwargs={"action": "store_true", "dest": "flag"}, - expected="store_true", - ), - ActionNameFixture( - test_id="store_false", - action_kwargs={"action": "store_false", "dest": "flag"}, - expected="store_false", - ), - ActionNameFixture( - test_id="store_const", - action_kwargs={"action": "store_const", "const": "value", "dest": "const"}, - expected="store_const", - ), - ActionNameFixture( - test_id="append", - action_kwargs={"action": "append", "dest": "items"}, - expected="append", - ), - ActionNameFixture( - test_id="count", - action_kwargs={"action": "count", "dest": "verbose"}, - expected="count", - ), -] - - -@pytest.mark.parametrize( - ActionNameFixture._fields, - ACTION_NAME_FIXTURES, - ids=[f.test_id for f in ACTION_NAME_FIXTURES], -) -def test_get_action_name( - test_id: str, action_kwargs: dict[str, t.Any], expected: str -) -> None: - """Test action name extraction.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--test", **action_kwargs) - assert _get_action_name(action) == expected - - -# --- _extract_argument tests --- - - -def test_extract_argument_positional() -> None: - """Test extracting a positional argument.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("filename", help="Input file") - info = _extract_argument(action) - - assert info.names == ["filename"] - assert info.help == "Input file" - assert info.is_positional is True - assert info.required is True - - -def test_extract_argument_optional() -> None: - """Test extracting an optional argument.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("-v", "--verbose", action="store_true", help="Verbose") - info = _extract_argument(action) - - assert info.names == ["-v", "--verbose"] - assert info.action == "store_true" - assert info.is_positional is False - assert info.required is False - - -def test_extract_argument_with_choices() -> None: - """Test extracting argument with choices.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--format", choices=["json", "yaml", "xml"]) - info = _extract_argument(action) - - assert info.choices == ["json", "yaml", "xml"] - - -def test_extract_argument_with_metavar() -> None: - """Test extracting argument with metavar.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--output", metavar="FILE") - info = _extract_argument(action) - - assert info.metavar == "FILE" - - -def test_extract_argument_tuple_metavar() -> None: - """Test extracting argument with tuple metavar.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--range", nargs=2, metavar=("MIN", "MAX")) - info = _extract_argument(action) - - assert info.metavar == "MIN MAX" - - -def test_extract_argument_suppressed_help() -> None: - """Test extracting argument with suppressed help.""" - parser = argparse.ArgumentParser() - action = parser.add_argument("--secret", help=argparse.SUPPRESS) - info = _extract_argument(action) - - assert info.help is None - - -# --- extract_parser integration tests --- - - -def test_extract_parser_simple(simple_parser: argparse.ArgumentParser) -> None: - """Test extracting a simple parser.""" - info = extract_parser(simple_parser) - - assert info.prog == "myapp" - assert info.description == "A simple test application" - assert len(info.argument_groups) >= 1 - - # Find arguments - all_args = [arg for group in info.argument_groups for arg in group.arguments] - arg_names = [name for arg in all_args for name in arg.names] - - assert "filename" in arg_names - assert "--verbose" in arg_names or "-v" in arg_names - - -def test_extract_parser_with_groups( - parser_with_groups: argparse.ArgumentParser, -) -> None: - """Test extracting parser with custom groups.""" - info = extract_parser(parser_with_groups) - - group_titles = [g.title for g in info.argument_groups] - assert "Input Options" in group_titles - assert "Output Options" in group_titles - - -def test_extract_parser_with_subcommands( - parser_with_subcommands: argparse.ArgumentParser, -) -> None: - """Test extracting parser with subcommands.""" - info = extract_parser(parser_with_subcommands) - - assert info.subcommands is not None - assert len(info.subcommands) == 2 - - subcmd_names = [s.name for s in info.subcommands] - assert "sync" in subcmd_names - assert "add" in subcmd_names - - # Check alias - add_cmd = next(s for s in info.subcommands if s.name == "add") - assert "a" in add_cmd.aliases - - -def test_extract_parser_with_mutex(parser_with_mutex: argparse.ArgumentParser) -> None: - """Test extracting parser with mutually exclusive group.""" - info = extract_parser(parser_with_mutex) - - # Find the group with mutex - for group in info.argument_groups: - if group.mutually_exclusive: - mutex = group.mutually_exclusive[0] - assert mutex.required is True - assert len(mutex.arguments) == 2 - return - - pytest.fail("No mutually exclusive group found") - - -def test_extract_parser_with_all_actions( - parser_with_all_actions: argparse.ArgumentParser, -) -> None: - """Test extracting parser with all action types.""" - info = extract_parser(parser_with_all_actions) - - all_args = [arg for group in info.argument_groups for arg in group.arguments] - actions = {arg.dest: arg.action for arg in all_args} - - assert actions.get("name") == "store" - assert actions.get("enable") == "store_const" - assert actions.get("flag") == "store_true" - assert actions.get("item") == "append" - assert actions.get("verbose") == "count" - - -def test_extract_parser_with_types( - parser_with_types: argparse.ArgumentParser, -) -> None: - """Test extracting parser with typed arguments.""" - info = extract_parser(parser_with_types) - - all_args = [arg for group in info.argument_groups for arg in group.arguments] - types = {arg.dest: arg.type_name for arg in all_args} - - assert types.get("count") == "int" - assert types.get("ratio") == "float" - assert types.get("path") == "Path" - - -def test_extract_parser_with_nargs( - parser_with_nargs: argparse.ArgumentParser, -) -> None: - """Test extracting parser with nargs variants.""" - info = extract_parser(parser_with_nargs) - - all_args = [arg for group in info.argument_groups for arg in group.arguments] - nargs_map = {arg.dest: arg.nargs for arg in all_args} - - assert nargs_map.get("optional") == "?" - assert nargs_map.get("zero_or_more") == "*" - assert nargs_map.get("one_or_more") == "+" - assert nargs_map.get("exactly_two") == 2 - - -def test_extract_parser_with_defaults( - parser_with_defaults: argparse.ArgumentParser, -) -> None: - """Test extracting parser with various defaults.""" - info = extract_parser(parser_with_defaults) - - all_args = [arg for group in info.argument_groups for arg in group.arguments] - defaults = {arg.dest: arg.default_string for arg in all_args} - - assert defaults.get("none_default") == "None" - assert defaults.get("string_default") == "hello" - assert defaults.get("int_default") == "42" - # Suppressed default should have None default_string - assert "suppress" not in defaults or defaults.get("suppress") is None - - -def test_extract_parser_nested_subcommands( - nested_subcommands_parser: argparse.ArgumentParser, -) -> None: - """Test extracting parser with nested subcommands.""" - info = extract_parser(nested_subcommands_parser) - - assert info.subcommands is not None - assert len(info.subcommands) == 1 - - repo = info.subcommands[0] - assert repo.name == "repo" - assert repo.parser.subcommands is not None - assert len(repo.parser.subcommands) == 2 - - -def test_extract_parser_usage_generation() -> None: - """Test usage string generation.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("file") - parser.add_argument("-v", "--verbose", action="store_true") - - info = extract_parser(parser) - - assert "test" in info.bare_usage - assert "file" in info.bare_usage - - -def test_extract_parser_custom_usage() -> None: - """Test parser with custom usage string.""" - parser = argparse.ArgumentParser(prog="test", usage="test [options] file") - - info = extract_parser(parser) - - assert info.usage == "test [options] file" - - -def test_extract_parser_with_epilog() -> None: - """Test parser with epilog.""" - parser = argparse.ArgumentParser( - prog="test", - epilog="For more info, see docs.", - ) - - info = extract_parser(parser) - - assert info.epilog == "For more info, see docs." - - -# --- ArgumentInfo property tests --- - - -def test_argument_info_is_positional_true() -> None: - """Test is_positional for positional argument.""" - info = ArgumentInfo( - names=["filename"], - help=None, - default=None, - default_string=None, - choices=None, - required=True, - metavar=None, - nargs=None, - action="store", - type_name=None, - const=None, - dest="filename", - ) - assert info.is_positional is True - - -def test_argument_info_is_positional_false() -> None: - """Test is_positional for optional argument.""" - info = ArgumentInfo( - names=["-f", "--file"], - help=None, - default=None, - default_string=None, - choices=None, - required=False, - metavar=None, - nargs=None, - action="store", - type_name=None, - const=None, - dest="file", - ) - assert info.is_positional is False - - -def test_argument_info_empty_names() -> None: - """Test is_positional with empty names list.""" - info = ArgumentInfo( - names=[], - help=None, - default=None, - default_string=None, - choices=None, - required=False, - metavar=None, - nargs=None, - action="store", - type_name=None, - const=None, - dest="empty", - ) - assert info.is_positional is False - - -# --- Dataclass tests --- - - -def test_parser_info_dataclass() -> None: - """Test ParserInfo dataclass creation.""" - info = ParserInfo( - prog="test", - usage=None, - bare_usage="test [-h]", - description="Test description", - epilog="Test epilog", - argument_groups=[], - subcommands=None, - subcommand_dest=None, - ) - - assert info.prog == "test" - assert info.description == "Test description" - - -def test_subcommand_info_recursive() -> None: - """Test SubcommandInfo with nested parser.""" - nested_info = ParserInfo( - prog="nested", - usage=None, - bare_usage="nested [-h]", - description=None, - epilog=None, - argument_groups=[], - subcommands=None, - subcommand_dest=None, - ) - - sub = SubcommandInfo( - name="sub", - aliases=[], - help="Subcommand help", - parser=nested_info, - ) - - assert sub.parser.prog == "nested" diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_renderer.py b/tests/docs/_ext/sphinx_argparse_neo/test_renderer.py deleted file mode 100644 index 0a0b0da698..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_renderer.py +++ /dev/null @@ -1,498 +0,0 @@ -"""Tests for sphinx_argparse_neo.renderer module.""" - -from __future__ import annotations - -import argparse -import typing as t - -from docutils import nodes -from sphinx_argparse_neo.nodes import ( - argparse_argument, - argparse_group, - argparse_program, - argparse_subcommand, - argparse_subcommands, - argparse_usage, -) -from sphinx_argparse_neo.parser import ( - ArgumentGroup, - ArgumentInfo, - MutuallyExclusiveGroup, - ParserInfo, - SubcommandInfo, - extract_parser, -) -from sphinx_argparse_neo.renderer import ( - ArgparseRenderer, - RenderConfig, - create_renderer, -) - -# --- RenderConfig tests --- - - -def test_render_config_defaults() -> None: - """Test RenderConfig default values.""" - config = RenderConfig() - - assert config.group_title_prefix == "" - assert config.show_defaults is True - assert config.show_choices is True - assert config.show_types is True - - -def test_render_config_custom_values() -> None: - """Test RenderConfig with custom values.""" - config = RenderConfig( - group_title_prefix="CLI ", - show_defaults=False, - show_choices=False, - show_types=False, - ) - - assert config.group_title_prefix == "CLI " - assert config.show_defaults is False - assert config.show_choices is False - assert config.show_types is False - - -# --- ArgparseRenderer basic tests --- - - -def test_renderer_creation_default_config() -> None: - """Test creating renderer with default config.""" - renderer = ArgparseRenderer() - - assert renderer.config is not None - assert renderer.config.show_defaults is True - - -def test_renderer_creation_custom_config() -> None: - """Test creating renderer with custom config.""" - config = RenderConfig(group_title_prefix="CLI ") - renderer = ArgparseRenderer(config=config) - - assert renderer.config.group_title_prefix == "CLI " - - -def test_create_renderer_factory() -> None: - """Test create_renderer factory function.""" - renderer = create_renderer() - assert isinstance(renderer, ArgparseRenderer) - - -def test_create_renderer_with_config() -> None: - """Test create_renderer with custom config.""" - config = RenderConfig(show_types=False) - renderer = create_renderer(config=config) - - assert renderer.config.show_types is False - - -# --- Render method tests --- - - -def test_render_simple_parser(simple_parser: argparse.ArgumentParser) -> None: - """Test rendering a simple parser produces sibling nodes for TOC. - - The renderer now outputs sections as siblings of argparse_program: - - argparse_program (description only) - - section#usage - - section#positional-arguments - - section#options - """ - parser_info = extract_parser(simple_parser) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Should have multiple nodes: program + usage section + group sections - assert len(rendered_nodes) >= 3 - - # First node is argparse_program - assert isinstance(rendered_nodes[0], argparse_program) - assert rendered_nodes[0]["prog"] == "myapp" - - # Second node should be usage section - assert isinstance(rendered_nodes[1], nodes.section) - assert "usage" in rendered_nodes[1]["ids"] - - -def test_render_includes_usage(simple_parser: argparse.ArgumentParser) -> None: - """Test that render includes usage as a sibling section.""" - parser_info = extract_parser(simple_parser) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Find the usage section (sibling of program, not child) - usage_sections = [ - n - for n in rendered_nodes - if isinstance(n, nodes.section) and "usage" in n.get("ids", []) - ] - - assert len(usage_sections) == 1 - - # Usage section should contain argparse_usage node - usage_section = usage_sections[0] - usage_node = [c for c in usage_section.children if isinstance(c, argparse_usage)] - assert len(usage_node) == 1 - assert "myapp" in usage_node[0]["usage"] - - -def test_render_includes_groups(simple_parser: argparse.ArgumentParser) -> None: - """Test that render includes argument groups as sibling sections.""" - parser_info = extract_parser(simple_parser) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Groups are now wrapped in sections and are siblings of program - # Find sections that contain argparse_group nodes - group_sections = [ - n - for n in rendered_nodes - if isinstance(n, nodes.section) - and any(isinstance(c, argparse_group) for c in n.children) - ] - - assert len(group_sections) >= 1 - - -def test_render_groups_contain_arguments( - simple_parser: argparse.ArgumentParser, -) -> None: - """Test that rendered groups contain argument nodes.""" - parser_info = extract_parser(simple_parser) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Find sections that contain argparse_group nodes - group_sections = [ - n - for n in rendered_nodes - if isinstance(n, nodes.section) - and any(isinstance(c, argparse_group) for c in n.children) - ] - - # Collect all arguments from groups inside sections - all_args: list[argparse_argument] = [] - for section in group_sections: - for child in section.children: - if isinstance(child, argparse_group): - all_args.extend( - arg for arg in child.children if isinstance(arg, argparse_argument) - ) - - assert len(all_args) >= 1 - - -def test_render_with_subcommands( - parser_with_subcommands: argparse.ArgumentParser, -) -> None: - """Test rendering parser with subcommands.""" - parser_info = extract_parser(parser_with_subcommands) - renderer = ArgparseRenderer() - rendered_nodes = renderer.render(parser_info) - - # Subcommands node is a sibling of program - subcommands_nodes = [ - n for n in rendered_nodes if isinstance(n, argparse_subcommands) - ] - - assert len(subcommands_nodes) == 1 - - # Check subcommand children - subs_container = subcommands_nodes[0] - subcmd_nodes = [ - c for c in subs_container.children if isinstance(c, argparse_subcommand) - ] - assert len(subcmd_nodes) == 2 - - -# --- Config option effect tests --- - - -def _collect_args_from_rendered_nodes( - rendered_nodes: list[nodes.Node], -) -> list[argparse_argument]: - """Collect all argparse_argument nodes from rendered output.""" - all_args: list[argparse_argument] = [] - for node in rendered_nodes: - if isinstance(node, nodes.section): - for child in node.children: - if isinstance(child, argparse_group): - all_args.extend( - arg - for arg in child.children - if isinstance(arg, argparse_argument) - ) - return all_args - - -def test_render_group_title_prefix() -> None: - """Test that group_title_prefix is applied to section titles.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("--opt") - parser_info = extract_parser(parser) - - config = RenderConfig(group_title_prefix="CLI: ") - renderer = ArgparseRenderer(config=config) - rendered_nodes = renderer.render(parser_info) - - # Find sections that contain argparse_group - group_sections = [ - n - for n in rendered_nodes - if isinstance(n, nodes.section) - and any(isinstance(c, argparse_group) for c in n.children) - ] - - # Section IDs should include the prefix (normalized) - ids = [section["ids"][0] for section in group_sections if section["ids"]] - assert any("cli:" in id_str.lower() for id_str in ids) - - -def test_render_show_defaults_false() -> None: - """Test that show_defaults=False hides defaults.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("--opt", default="value") - parser_info = extract_parser(parser) - - config = RenderConfig(show_defaults=False) - renderer = ArgparseRenderer(config=config) - rendered_nodes = renderer.render(parser_info) - - all_args = _collect_args_from_rendered_nodes(rendered_nodes) - - # Default string should not be set - for arg in all_args: - assert arg.get("default_string") is None - - -def test_render_show_choices_false() -> None: - """Test that show_choices=False hides choices.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("--format", choices=["json", "yaml"]) - parser_info = extract_parser(parser) - - config = RenderConfig(show_choices=False) - renderer = ArgparseRenderer(config=config) - rendered_nodes = renderer.render(parser_info) - - all_args = _collect_args_from_rendered_nodes(rendered_nodes) - - # Choices should not be set - for arg in all_args: - assert arg.get("choices") is None - - -def test_render_show_types_false() -> None: - """Test that show_types=False hides type info.""" - parser = argparse.ArgumentParser(prog="test") - parser.add_argument("--count", type=int) - parser_info = extract_parser(parser) - - config = RenderConfig(show_types=False) - renderer = ArgparseRenderer(config=config) - rendered_nodes = renderer.render(parser_info) - - all_args = _collect_args_from_rendered_nodes(rendered_nodes) - - # Type name should not be set - for arg in all_args: - assert arg.get("type_name") is None - - -# --- Individual render method tests --- - - -def test_render_usage_method() -> None: - """Test render_usage method directly.""" - parser_info = ParserInfo( - prog="test", - usage=None, - bare_usage="test [-h] [-v]", - description=None, - epilog=None, - argument_groups=[], - subcommands=None, - subcommand_dest=None, - ) - - renderer = ArgparseRenderer() - usage_node = renderer.render_usage(parser_info) - - assert isinstance(usage_node, argparse_usage) - assert usage_node["usage"] == "test [-h] [-v]" - - -def test_render_argument_method() -> None: - """Test render_argument method directly.""" - arg_info = ArgumentInfo( - names=["-v", "--verbose"], - help="Enable verbose mode", - default=False, - default_string="False", - choices=None, - required=False, - metavar=None, - nargs=None, - action="store_true", - type_name=None, - const=True, - dest="verbose", - ) - - renderer = ArgparseRenderer() - arg_node = renderer.render_argument(arg_info) - - assert isinstance(arg_node, argparse_argument) - assert arg_node["names"] == ["-v", "--verbose"] - assert arg_node["help"] == "Enable verbose mode" - - -def test_render_group_method() -> None: - """Test render_group method directly.""" - group_info = ArgumentGroup( - title="Options", - description="Available options", - arguments=[ - ArgumentInfo( - names=["-v"], - help="Verbose", - default=False, - default_string="False", - choices=None, - required=False, - metavar=None, - nargs=None, - action="store_true", - type_name=None, - const=True, - dest="verbose", - ), - ], - mutually_exclusive=[], - ) - - renderer = ArgparseRenderer() - group_node = renderer.render_group(group_info) - - assert isinstance(group_node, argparse_group) - assert group_node["title"] == "Options" - assert group_node["description"] == "Available options" - assert len(group_node.children) == 1 - - -def test_render_mutex_group_method() -> None: - """Test render_mutex_group method.""" - mutex = MutuallyExclusiveGroup( - arguments=[ - ArgumentInfo( - names=["-v"], - help="Verbose", - default=False, - default_string="False", - choices=None, - required=False, - metavar=None, - nargs=None, - action="store_true", - type_name=None, - const=True, - dest="verbose", - ), - ArgumentInfo( - names=["-q"], - help="Quiet", - default=False, - default_string="False", - choices=None, - required=False, - metavar=None, - nargs=None, - action="store_true", - type_name=None, - const=True, - dest="quiet", - ), - ], - required=True, - ) - - renderer = ArgparseRenderer() - nodes = renderer.render_mutex_group(mutex) - - assert len(nodes) == 2 - assert all(isinstance(n, argparse_argument) for n in nodes) - assert all(n.get("mutex") is True for n in nodes) - assert all(n.get("mutex_required") is True for n in nodes) - - -def test_render_subcommand_method() -> None: - """Test render_subcommand method.""" - nested_parser = ParserInfo( - prog="myapp sub", - usage=None, - bare_usage="myapp sub [-h]", - description="Subcommand description", - epilog=None, - argument_groups=[], - subcommands=None, - subcommand_dest=None, - ) - - subcmd_info = SubcommandInfo( - name="sub", - aliases=["s"], - help="Subcommand help", - parser=nested_parser, - ) - - renderer = ArgparseRenderer() - subcmd_node = renderer.render_subcommand(subcmd_info) - - assert isinstance(subcmd_node, argparse_subcommand) - assert subcmd_node["name"] == "sub" - assert subcmd_node["aliases"] == ["s"] - assert subcmd_node["help"] == "Subcommand help" - - # Should have nested program - nested = [c for c in subcmd_node.children if isinstance(c, argparse_program)] - assert len(nested) == 1 - - -# --- Post-process hook test --- - - -def test_post_process_default() -> None: - """Test that default post_process returns nodes unchanged.""" - renderer = ArgparseRenderer() - - from docutils import nodes as dn - - input_nodes = [dn.paragraph(text="test")] - - result = renderer.post_process(input_nodes) - - assert result == input_nodes - - -def test_post_process_custom() -> None: - """Test custom post_process implementation.""" - - class CustomRenderer(ArgparseRenderer): # type: ignore[misc] - def post_process(self, result_nodes: list[t.Any]) -> list[t.Any]: - # Add a marker to each node - for node in result_nodes: - node["custom_marker"] = True - return result_nodes - - renderer = CustomRenderer() - - from docutils import nodes as dn - - input_nodes = [dn.paragraph(text="test")] - - result = renderer.post_process(input_nodes) - - assert result[0].get("custom_marker") is True diff --git a/tests/docs/_ext/sphinx_argparse_neo/test_utils.py b/tests/docs/_ext/sphinx_argparse_neo/test_utils.py deleted file mode 100644 index 129a8fd36d..0000000000 --- a/tests/docs/_ext/sphinx_argparse_neo/test_utils.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Tests for sphinx_argparse_neo text processing utilities.""" - -from __future__ import annotations - -import typing as t - -import pytest -from sphinx_argparse_neo.utils import escape_rst_emphasis, strip_ansi - -# --- strip_ansi tests --- - - -class StripAnsiFixture(t.NamedTuple): - """Test fixture for strip_ansi function.""" - - test_id: str - input_text: str - expected: str - - -STRIP_ANSI_FIXTURES: list[StripAnsiFixture] = [ - StripAnsiFixture( - test_id="plain_text", - input_text="hello", - expected="hello", - ), - StripAnsiFixture( - test_id="green_color", - input_text="\033[32mgreen\033[0m", - expected="green", - ), - StripAnsiFixture( - test_id="bold_blue", - input_text="\033[1;34mbold\033[0m", - expected="bold", - ), - StripAnsiFixture( - test_id="multiple_codes", - input_text="\033[1m\033[32mtest\033[0m", - expected="test", - ), - StripAnsiFixture( - test_id="empty_string", - input_text="", - expected="", - ), - StripAnsiFixture( - test_id="mixed_content", - input_text="pre\033[31mred\033[0mpost", - expected="preredpost", - ), - StripAnsiFixture( - test_id="reset_only", - input_text="\033[0m", - expected="", - ), - StripAnsiFixture( - test_id="sgr_params", - input_text="\033[38;5;196mred256\033[0m", - expected="red256", - ), -] - - -@pytest.mark.parametrize( - StripAnsiFixture._fields, - STRIP_ANSI_FIXTURES, - ids=[f.test_id for f in STRIP_ANSI_FIXTURES], -) -def test_strip_ansi(test_id: str, input_text: str, expected: str) -> None: - """Test ANSI escape code stripping.""" - assert strip_ansi(input_text) == expected - - -# --- escape_rst_emphasis tests --- - - -class EscapeRstEmphasisFixture(t.NamedTuple): - """Test fixture for escape_rst_emphasis function.""" - - test_id: str - input_text: str - expected: str - - -ESCAPE_RST_EMPHASIS_FIXTURES: list[EscapeRstEmphasisFixture] = [ - EscapeRstEmphasisFixture( - test_id="glob_pattern_quoted", - input_text='tmuxp load "my-*"', - expected='tmuxp load "my-\\*"', - ), - EscapeRstEmphasisFixture( - test_id="glob_pattern_django", - input_text="django-*", - expected="django-\\*", - ), - EscapeRstEmphasisFixture( - test_id="glob_pattern_flask", - input_text="flask-*", - expected="flask-\\*", - ), - EscapeRstEmphasisFixture( - test_id="multiple_patterns", - input_text="match django-* or flask-* packages", - expected="match django-\\* or flask-\\* packages", - ), - EscapeRstEmphasisFixture( - test_id="plain_text", - input_text="plain text without patterns", - expected="plain text without patterns", - ), - EscapeRstEmphasisFixture( - test_id="rst_emphasis_unchanged", - input_text="*emphasis* is ok", - expected="*emphasis* is ok", - ), - EscapeRstEmphasisFixture( - test_id="already_escaped", - input_text="django-\\*", - expected="django-\\*", - ), - EscapeRstEmphasisFixture( - test_id="empty_string", - input_text="", - expected="", - ), - EscapeRstEmphasisFixture( - test_id="pattern_at_end", - input_text="ending with pattern-*", - expected="ending with pattern-\\*", - ), - EscapeRstEmphasisFixture( - test_id="hyphen_without_asterisk", - input_text="word-with-hyphens", - expected="word-with-hyphens", - ), - EscapeRstEmphasisFixture( - test_id="asterisk_without_hyphen", - input_text="asterisk * alone", - expected="asterisk * alone", - ), - EscapeRstEmphasisFixture( - test_id="double_asterisk", - input_text="glob-** pattern", - expected="glob-** pattern", - ), - EscapeRstEmphasisFixture( - test_id="space_after_asterisk", - input_text="word-* followed by space", - expected="word-\\* followed by space", - ), -] - - -@pytest.mark.parametrize( - EscapeRstEmphasisFixture._fields, - ESCAPE_RST_EMPHASIS_FIXTURES, - ids=[f.test_id for f in ESCAPE_RST_EMPHASIS_FIXTURES], -) -def test_escape_rst_emphasis(test_id: str, input_text: str, expected: str) -> None: - """Test RST emphasis escaping for glob patterns.""" - assert escape_rst_emphasis(input_text) == expected diff --git a/tests/docs/_ext/test_argparse_exemplar.py b/tests/docs/_ext/test_argparse_exemplar.py deleted file mode 100644 index c25b48f9da..0000000000 --- a/tests/docs/_ext/test_argparse_exemplar.py +++ /dev/null @@ -1,1073 +0,0 @@ -"""Tests for argparse_exemplar sphinx extension. - -This tests the examples transformation functionality that converts argparse -epilog definition lists into proper documentation sections. - -Note: Tests for strip_ansi have moved to -tests/docs/_ext/sphinx_argparse_neo/test_utils.py since that utility -now lives in sphinx_argparse_neo.utils. -""" - -from __future__ import annotations - -import typing as t - -import pytest -from docutils import nodes -from sphinx_argparse_neo.exemplar import ( - ExemplarConfig, - _extract_sections_from_container, - _is_examples_section, - _is_usage_block, - _reorder_nodes, - is_base_examples_term, - is_examples_term, - make_section_id, - make_section_title, - process_node, - transform_definition_list, -) - -# --- is_examples_term tests --- - - -class IsExamplesTermFixture(t.NamedTuple): - """Test fixture for is_examples_term function.""" - - test_id: str - term_text: str - expected: bool - - -IS_EXAMPLES_TERM_FIXTURES: list[IsExamplesTermFixture] = [ - IsExamplesTermFixture( - test_id="base_examples_colon", - term_text="examples:", - expected=True, - ), - IsExamplesTermFixture( - test_id="base_examples_no_colon", - term_text="examples", - expected=True, - ), - IsExamplesTermFixture( - test_id="prefixed_machine_readable", - term_text="Machine-readable output examples:", - expected=True, - ), - IsExamplesTermFixture( - test_id="prefixed_field_scoped", - term_text="Field-scoped search examples:", - expected=True, - ), - IsExamplesTermFixture( - test_id="colon_pattern", - term_text="Machine-readable output: examples:", - expected=True, - ), - IsExamplesTermFixture( - test_id="usage_not_examples", - term_text="Usage:", - expected=False, - ), - IsExamplesTermFixture( - test_id="arguments_not_examples", - term_text="Named Arguments:", - expected=False, - ), - IsExamplesTermFixture( - test_id="case_insensitive_upper", - term_text="EXAMPLES:", - expected=True, - ), - IsExamplesTermFixture( - test_id="case_insensitive_mixed", - term_text="Examples:", - expected=True, - ), -] - - -@pytest.mark.parametrize( - IsExamplesTermFixture._fields, - IS_EXAMPLES_TERM_FIXTURES, - ids=[f.test_id for f in IS_EXAMPLES_TERM_FIXTURES], -) -def test_is_examples_term(test_id: str, term_text: str, expected: bool) -> None: - """Test examples term detection.""" - assert is_examples_term(term_text) == expected - - -# --- is_base_examples_term tests --- - - -class IsBaseExamplesTermFixture(t.NamedTuple): - """Test fixture for is_base_examples_term function.""" - - test_id: str - term_text: str - expected: bool - - -IS_BASE_EXAMPLES_TERM_FIXTURES: list[IsBaseExamplesTermFixture] = [ - IsBaseExamplesTermFixture( - test_id="base_with_colon", - term_text="examples:", - expected=True, - ), - IsBaseExamplesTermFixture( - test_id="base_no_colon", - term_text="examples", - expected=True, - ), - IsBaseExamplesTermFixture( - test_id="uppercase", - term_text="EXAMPLES", - expected=True, - ), - IsBaseExamplesTermFixture( - test_id="mixed_case", - term_text="Examples:", - expected=True, - ), - IsBaseExamplesTermFixture( - test_id="prefixed_not_base", - term_text="Field-scoped examples:", - expected=False, - ), - IsBaseExamplesTermFixture( - test_id="output_examples_not_base", - term_text="Machine-readable output examples:", - expected=False, - ), - IsBaseExamplesTermFixture( - test_id="colon_pattern_not_base", - term_text="Output: examples:", - expected=False, - ), -] - - -@pytest.mark.parametrize( - IsBaseExamplesTermFixture._fields, - IS_BASE_EXAMPLES_TERM_FIXTURES, - ids=[f.test_id for f in IS_BASE_EXAMPLES_TERM_FIXTURES], -) -def test_is_base_examples_term(test_id: str, term_text: str, expected: bool) -> None: - """Test base examples term detection.""" - assert is_base_examples_term(term_text) == expected - - -# --- make_section_id tests --- - - -class MakeSectionIdFixture(t.NamedTuple): - """Test fixture for make_section_id function.""" - - test_id: str - term_text: str - counter: int - is_subsection: bool - expected: str - - -MAKE_SECTION_ID_FIXTURES: list[MakeSectionIdFixture] = [ - MakeSectionIdFixture( - test_id="base_examples", - term_text="examples:", - counter=0, - is_subsection=False, - expected="examples", - ), - MakeSectionIdFixture( - test_id="prefixed_standard", - term_text="Machine-readable output examples:", - counter=0, - is_subsection=False, - expected="machine-readable-output-examples", - ), - MakeSectionIdFixture( - test_id="subsection_omits_suffix", - term_text="Field-scoped examples:", - counter=0, - is_subsection=True, - expected="field-scoped", - ), - MakeSectionIdFixture( - test_id="with_counter", - term_text="examples:", - counter=2, - is_subsection=False, - expected="examples-2", - ), - MakeSectionIdFixture( - test_id="counter_zero_no_suffix", - term_text="examples:", - counter=0, - is_subsection=False, - expected="examples", - ), - MakeSectionIdFixture( - test_id="colon_pattern", - term_text="Machine-readable output: examples:", - counter=0, - is_subsection=False, - expected="machine-readable-output-examples", - ), - MakeSectionIdFixture( - test_id="subsection_with_counter", - term_text="Field-scoped examples:", - counter=1, - is_subsection=True, - expected="field-scoped-1", - ), -] - - -@pytest.mark.parametrize( - MakeSectionIdFixture._fields, - MAKE_SECTION_ID_FIXTURES, - ids=[f.test_id for f in MAKE_SECTION_ID_FIXTURES], -) -def test_make_section_id( - test_id: str, - term_text: str, - counter: int, - is_subsection: bool, - expected: str, -) -> None: - """Test section ID generation.""" - assert make_section_id(term_text, counter, is_subsection=is_subsection) == expected - - -def test_make_section_id_with_page_prefix() -> None: - """Test section ID generation with page_prefix for cross-page uniqueness.""" - # Base "examples:" with page_prefix becomes "sync-examples" - assert make_section_id("examples:", page_prefix="sync") == "sync-examples" - assert make_section_id("examples:", page_prefix="add") == "add-examples" - - # Prefixed examples already unique - page_prefix not added - assert ( - make_section_id("Machine-readable output examples:", page_prefix="sync") - == "machine-readable-output-examples" - ) - - # Subsection with page_prefix - result = make_section_id( - "Field-scoped examples:", is_subsection=True, page_prefix="sync" - ) - assert result == "field-scoped" - - # Empty page_prefix behaves like before - assert make_section_id("examples:", page_prefix="") == "examples" - - -# --- make_section_title tests --- - - -class MakeSectionTitleFixture(t.NamedTuple): - """Test fixture for make_section_title function.""" - - test_id: str - term_text: str - is_subsection: bool - expected: str - - -MAKE_SECTION_TITLE_FIXTURES: list[MakeSectionTitleFixture] = [ - MakeSectionTitleFixture( - test_id="base_examples", - term_text="examples:", - is_subsection=False, - expected="Examples", - ), - MakeSectionTitleFixture( - test_id="prefixed_with_examples_suffix", - term_text="Machine-readable output examples:", - is_subsection=False, - expected="Machine-Readable Output Examples", - ), - MakeSectionTitleFixture( - test_id="subsection_omits_examples", - term_text="Field-scoped examples:", - is_subsection=True, - expected="Field-Scoped", - ), - MakeSectionTitleFixture( - test_id="colon_pattern", - term_text="Machine-readable output: examples:", - is_subsection=False, - expected="Machine-Readable Output Examples", - ), - MakeSectionTitleFixture( - test_id="subsection_colon_pattern", - term_text="Machine-readable output: examples:", - is_subsection=True, - expected="Machine-Readable Output", - ), - MakeSectionTitleFixture( - test_id="base_examples_no_colon", - term_text="examples", - is_subsection=False, - expected="Examples", - ), -] - - -@pytest.mark.parametrize( - MakeSectionTitleFixture._fields, - MAKE_SECTION_TITLE_FIXTURES, - ids=[f.test_id for f in MAKE_SECTION_TITLE_FIXTURES], -) -def test_make_section_title( - test_id: str, - term_text: str, - is_subsection: bool, - expected: str, -) -> None: - """Test section title generation.""" - assert make_section_title(term_text, is_subsection=is_subsection) == expected - - -# --- transform_definition_list integration tests --- - - -def _make_dl_item(term: str, definition: str) -> nodes.definition_list_item: - """Create a definition list item for testing. - - Parameters - ---------- - term : str - The definition term text. - definition : str - The definition content text. - - Returns - ------- - nodes.definition_list_item - A definition list item with term and definition. - """ - item = nodes.definition_list_item() - term_node = nodes.term(text=term) - def_node = nodes.definition() - def_node += nodes.paragraph(text=definition) - item += term_node - item += def_node - return item - - -def test_transform_definition_list_single_examples() -> None: - """Single examples section creates one section node.""" - dl = nodes.definition_list() - dl += _make_dl_item("examples:", "vcspull ls") - - result = transform_definition_list(dl) - - assert len(result) == 1 - assert isinstance(result[0], nodes.section) - assert result[0]["ids"] == ["examples"] - - -def test_transform_definition_list_nested_examples() -> None: - """Base examples with category creates nested sections.""" - dl = nodes.definition_list() - dl += _make_dl_item("examples:", "vcspull ls") - dl += _make_dl_item("Machine-readable output examples:", "vcspull ls --json") - - result = transform_definition_list(dl) - - # Should have single parent section containing nested subsection - assert len(result) == 1 - parent = result[0] - assert isinstance(parent, nodes.section) - assert parent["ids"] == ["examples"] - - # Find nested subsection - subsections = [c for c in parent.children if isinstance(c, nodes.section)] - assert len(subsections) == 1 - assert subsections[0]["ids"] == ["machine-readable-output"] - - -def test_transform_definition_list_multiple_categories() -> None: - """Multiple example categories all nest under parent.""" - dl = nodes.definition_list() - dl += _make_dl_item("examples:", "vcspull ls") - dl += _make_dl_item("Field-scoped examples:", "vcspull ls --field name") - dl += _make_dl_item("Machine-readable output examples:", "vcspull ls --json") - - result = transform_definition_list(dl) - - assert len(result) == 1 - parent = result[0] - assert isinstance(parent, nodes.section) - - subsections = [c for c in parent.children if isinstance(c, nodes.section)] - assert len(subsections) == 2 - - -def test_transform_definition_list_preserves_non_examples() -> None: - """Non-example items preserved as definition list.""" - dl = nodes.definition_list() - dl += _make_dl_item("Usage:", "How to use this command") - dl += _make_dl_item("examples:", "vcspull ls") - - result = transform_definition_list(dl) - - # Should have both definition list (non-examples) and section (examples) - has_dl = any(isinstance(n, nodes.definition_list) for n in result) - has_section = any(isinstance(n, nodes.section) for n in result) - assert has_dl, "Non-example items should be preserved as definition list" - assert has_section, "Example items should become sections" - - -def test_transform_definition_list_no_examples() -> None: - """Definition list without examples returns empty list.""" - dl = nodes.definition_list() - dl += _make_dl_item("Usage:", "How to use") - dl += _make_dl_item("Options:", "Available options") - - result = transform_definition_list(dl) - - # All items are non-examples, should return definition list - assert len(result) == 1 - assert isinstance(result[0], nodes.definition_list) - - -def test_transform_definition_list_only_category_no_base() -> None: - """Single category example without base examples stays flat.""" - dl = nodes.definition_list() - dl += _make_dl_item("Machine-readable output examples:", "vcspull ls --json") - - result = transform_definition_list(dl) - - # Without base "examples:", no nesting - just single section - assert len(result) == 1 - assert isinstance(result[0], nodes.section) - # Should have full title since it's not nested - assert result[0]["ids"] == ["machine-readable-output-examples"] - - -def test_transform_definition_list_code_blocks_created() -> None: - """Each command line becomes a separate code block.""" - dl = nodes.definition_list() - dl += _make_dl_item("examples:", "cmd1\ncmd2\ncmd3") - - result = transform_definition_list(dl) - - section = result[0] - code_blocks = [c for c in section.children if isinstance(c, nodes.literal_block)] - assert len(code_blocks) == 3 - assert code_blocks[0].astext() == "$ cmd1" - assert code_blocks[1].astext() == "$ cmd2" - assert code_blocks[2].astext() == "$ cmd3" - - -# --- _is_usage_block tests --- - - -class IsUsageBlockFixture(t.NamedTuple): - """Test fixture for _is_usage_block function.""" - - test_id: str - node_type: str - node_text: str - expected: bool - - -IS_USAGE_BLOCK_FIXTURES: list[IsUsageBlockFixture] = [ - IsUsageBlockFixture( - test_id="literal_block_usage_lowercase", - node_type="literal_block", - node_text="usage: cmd [-h]", - expected=True, - ), - IsUsageBlockFixture( - test_id="literal_block_usage_uppercase", - node_type="literal_block", - node_text="Usage: vcspull sync", - expected=True, - ), - IsUsageBlockFixture( - test_id="literal_block_usage_leading_space", - node_type="literal_block", - node_text=" usage: cmd", - expected=True, - ), - IsUsageBlockFixture( - test_id="literal_block_not_usage", - node_type="literal_block", - node_text="some other text", - expected=False, - ), - IsUsageBlockFixture( - test_id="literal_block_usage_in_middle", - node_type="literal_block", - node_text="see usage: for more", - expected=False, - ), - IsUsageBlockFixture( - test_id="paragraph_with_usage", - node_type="paragraph", - node_text="usage: cmd", - expected=False, - ), - IsUsageBlockFixture( - test_id="section_node", - node_type="section", - node_text="", - expected=False, - ), -] - - -def _make_test_node(node_type: str, node_text: str) -> nodes.Node: - """Create a test node of the specified type. - - Parameters - ---------- - node_type : str - Type of node to create ("literal_block", "paragraph", "section"). - node_text : str - Text content for the node. - - Returns - ------- - nodes.Node - The created node. - """ - if node_type == "literal_block": - return nodes.literal_block(text=node_text) - if node_type == "paragraph": - return nodes.paragraph(text=node_text) - if node_type == "section": - return nodes.section() - msg = f"Unknown node type: {node_type}" - raise ValueError(msg) - - -@pytest.mark.parametrize( - IsUsageBlockFixture._fields, - IS_USAGE_BLOCK_FIXTURES, - ids=[f.test_id for f in IS_USAGE_BLOCK_FIXTURES], -) -def test_is_usage_block( - test_id: str, - node_type: str, - node_text: str, - expected: bool, -) -> None: - """Test usage block detection.""" - node = _make_test_node(node_type, node_text) - assert _is_usage_block(node) == expected - - -# --- _is_examples_section tests --- - - -class IsExamplesSectionFixture(t.NamedTuple): - """Test fixture for _is_examples_section function.""" - - test_id: str - node_type: str - section_ids: list[str] - expected: bool - - -IS_EXAMPLES_SECTION_FIXTURES: list[IsExamplesSectionFixture] = [ - IsExamplesSectionFixture( - test_id="section_with_examples_id", - node_type="section", - section_ids=["examples"], - expected=True, - ), - IsExamplesSectionFixture( - test_id="section_with_prefixed_examples", - node_type="section", - section_ids=["machine-readable-output-examples"], - expected=True, - ), - IsExamplesSectionFixture( - test_id="section_with_uppercase_examples", - node_type="section", - section_ids=["EXAMPLES"], - expected=True, - ), - IsExamplesSectionFixture( - test_id="section_without_examples", - node_type="section", - section_ids=["positional-arguments"], - expected=False, - ), - IsExamplesSectionFixture( - test_id="section_with_multiple_ids", - node_type="section", - section_ids=["main-id", "examples-alias"], - expected=True, - ), - IsExamplesSectionFixture( - test_id="section_empty_ids", - node_type="section", - section_ids=[], - expected=False, - ), - IsExamplesSectionFixture( - test_id="paragraph_node", - node_type="paragraph", - section_ids=[], - expected=False, - ), - IsExamplesSectionFixture( - test_id="literal_block_node", - node_type="literal_block", - section_ids=[], - expected=False, - ), -] - - -def _make_section_node(node_type: str, section_ids: list[str]) -> nodes.Node: - """Create a test node with optional section IDs. - - Parameters - ---------- - node_type : str - Type of node to create. - section_ids : list[str] - IDs to assign if creating a section. - - Returns - ------- - nodes.Node - The created node. - """ - if node_type == "section": - section = nodes.section() - section["ids"] = section_ids - return section - if node_type == "paragraph": - return nodes.paragraph() - if node_type == "literal_block": - return nodes.literal_block(text="examples") - msg = f"Unknown node type: {node_type}" - raise ValueError(msg) - - -@pytest.mark.parametrize( - IsExamplesSectionFixture._fields, - IS_EXAMPLES_SECTION_FIXTURES, - ids=[f.test_id for f in IS_EXAMPLES_SECTION_FIXTURES], -) -def test_is_examples_section( - test_id: str, - node_type: str, - section_ids: list[str], - expected: bool, -) -> None: - """Test examples section detection.""" - node = _make_section_node(node_type, section_ids) - assert _is_examples_section(node) == expected - - -# --- _reorder_nodes tests --- - - -def _make_usage_node(text: str = "usage: cmd [-h]") -> nodes.literal_block: - """Create a usage block node. - - Parameters - ---------- - text : str - Text content for the usage block. - - Returns - ------- - nodes.literal_block - A literal block node with usage text. - """ - return nodes.literal_block(text=text) - - -def _make_examples_section(section_id: str = "examples") -> nodes.section: - """Create an examples section node. - - Parameters - ---------- - section_id : str - The ID for the section. - - Returns - ------- - nodes.section - A section node with the specified ID. - """ - section = nodes.section() - section["ids"] = [section_id] - return section - - -def test_reorder_nodes_usage_after_examples() -> None: - """Usage block after examples gets moved before examples.""" - desc = nodes.paragraph(text="Description") - examples = _make_examples_section() - usage = _make_usage_node() - - # Create a non-examples section - args_section = nodes.section() - args_section["ids"] = ["arguments"] - - result = _reorder_nodes([desc, examples, usage, args_section]) - - # Should be: desc, usage, examples, args - assert len(result) == 4 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.section) - assert result[2]["ids"] == ["examples"] - assert isinstance(result[3], nodes.section) - assert result[3]["ids"] == ["arguments"] - - -def test_reorder_nodes_no_examples() -> None: - """Without examples, original order is preserved.""" - desc = nodes.paragraph(text="Description") - usage = _make_usage_node() - args = nodes.section() - args["ids"] = ["arguments"] - - result = _reorder_nodes([desc, usage, args]) - - # Order unchanged: desc, usage, args - assert len(result) == 3 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.section) - - -def test_reorder_nodes_usage_already_before_examples() -> None: - """When usage is already before examples, order is preserved.""" - desc = nodes.paragraph(text="Description") - usage = _make_usage_node() - examples = _make_examples_section() - args = nodes.section() - args["ids"] = ["arguments"] - - result = _reorder_nodes([desc, usage, examples, args]) - - # Order should be: desc, usage, examples, args - assert len(result) == 4 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.section) - assert result[2]["ids"] == ["examples"] - - -def test_reorder_nodes_empty_list() -> None: - """Empty input returns empty output.""" - result = _reorder_nodes([]) - assert result == [] - - -def test_reorder_nodes_multiple_usage_blocks() -> None: - """Multiple usage blocks are all moved before examples.""" - desc = nodes.paragraph(text="Description") - examples = _make_examples_section() - usage1 = _make_usage_node("usage: cmd1 [-h]") - usage2 = _make_usage_node("usage: cmd2 [-v]") - - result = _reorder_nodes([desc, examples, usage1, usage2]) - - # Should be: desc, usage1, usage2, examples - assert len(result) == 4 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.literal_block) - assert isinstance(result[3], nodes.section) - - -def test_reorder_nodes_multiple_examples_sections() -> None: - """Multiple examples sections are grouped together.""" - desc = nodes.paragraph(text="Description") - examples1 = _make_examples_section("examples") - usage = _make_usage_node() - examples2 = _make_examples_section("machine-readable-output-examples") - args = nodes.section() - args["ids"] = ["arguments"] - - result = _reorder_nodes([desc, examples1, usage, examples2, args]) - - # Should be: desc, usage, examples1, examples2, args - assert len(result) == 5 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.literal_block) - assert result[2]["ids"] == ["examples"] - assert result[3]["ids"] == ["machine-readable-output-examples"] - assert result[4]["ids"] == ["arguments"] - - -def test_reorder_nodes_preserves_non_examples_after() -> None: - """Non-examples nodes after examples stay at the end.""" - desc = nodes.paragraph(text="Description") - examples = _make_examples_section() - usage = _make_usage_node() - epilog = nodes.paragraph(text="Epilog") - - result = _reorder_nodes([desc, examples, usage, epilog]) - - # Should be: desc, usage, examples, epilog - assert len(result) == 4 - assert result[0].astext() == "Description" - assert isinstance(result[1], nodes.literal_block) - assert isinstance(result[2], nodes.section) - assert result[3].astext() == "Epilog" - - -# --- ExemplarConfig tests --- - - -def test_exemplar_config_defaults() -> None: - """ExemplarConfig has sensible defaults.""" - config = ExemplarConfig() - - assert config.examples_term_suffix == "examples" - assert config.examples_base_term == "examples" - assert config.examples_section_title == "Examples" - assert config.usage_pattern == "usage:" - assert config.command_prefix == "$ " - assert config.code_language == "console" - assert config.code_classes == ("highlight-console",) - assert config.usage_code_language == "cli-usage" - assert config.reorder_usage_before_examples is True - - -def test_exemplar_config_custom_values() -> None: - """ExemplarConfig accepts custom values.""" - config = ExemplarConfig( - examples_term_suffix="demos", - examples_base_term="demos", - examples_section_title="Demos", - usage_pattern="synopsis:", - command_prefix="> ", - code_language="bash", - code_classes=("highlight-bash",), - usage_code_language="cli-synopsis", - reorder_usage_before_examples=False, - ) - - assert config.examples_term_suffix == "demos" - assert config.examples_base_term == "demos" - assert config.examples_section_title == "Demos" - assert config.usage_pattern == "synopsis:" - assert config.command_prefix == "> " - assert config.code_language == "bash" - assert config.code_classes == ("highlight-bash",) - assert config.usage_code_language == "cli-synopsis" - assert config.reorder_usage_before_examples is False - - -# --- Config integration tests --- - - -def test_is_examples_term_with_custom_config() -> None: - """is_examples_term respects custom config.""" - config = ExemplarConfig(examples_term_suffix="demos") - - # Custom term should match - assert is_examples_term("demos:", config=config) is True - assert is_examples_term("Machine-readable output demos:", config=config) is True - - # Default term should not match - assert is_examples_term("examples:", config=config) is False - - -def test_is_base_examples_term_with_custom_config() -> None: - """is_base_examples_term respects custom config.""" - config = ExemplarConfig(examples_base_term="demos") - - # Custom term should match - assert is_base_examples_term("demos:", config=config) is True - assert is_base_examples_term("Demos", config=config) is True - - # Default term should not match - assert is_base_examples_term("examples:", config=config) is False - - # Prefixed term should not match (not base) - assert is_base_examples_term("Output demos:", config=config) is False - - -def test_make_section_id_with_custom_config() -> None: - """make_section_id respects custom config.""" - config = ExemplarConfig(examples_term_suffix="demos") - - assert make_section_id("demos:", config=config) == "demos" - assert ( - make_section_id("Machine-readable output demos:", config=config) - == "machine-readable-output-demos" - ) - assert ( - make_section_id("Field-scoped demos:", is_subsection=True, config=config) - == "field-scoped" - ) - - -def test_make_section_title_with_custom_config() -> None: - """make_section_title respects custom config.""" - config = ExemplarConfig( - examples_base_term="demos", - examples_term_suffix="demos", - examples_section_title="Demos", - ) - - assert make_section_title("demos:", config=config) == "Demos" - assert ( - make_section_title("Machine-readable output demos:", config=config) - == "Machine-Readable Output Demos" - ) - assert ( - make_section_title("Field-scoped demos:", is_subsection=True, config=config) - == "Field-Scoped" - ) - - -def test_is_usage_block_with_custom_config() -> None: - """_is_usage_block respects custom config.""" - config = ExemplarConfig(usage_pattern="synopsis:") - - # Custom pattern should match - assert ( - _is_usage_block(nodes.literal_block(text="synopsis: cmd [-h]"), config=config) - is True - ) - assert ( - _is_usage_block(nodes.literal_block(text="Synopsis: cmd"), config=config) - is True - ) - - # Default pattern should not match - assert ( - _is_usage_block(nodes.literal_block(text="usage: cmd [-h]"), config=config) - is False - ) - - -def test_is_examples_section_with_custom_config() -> None: - """_is_examples_section respects custom config.""" - config = ExemplarConfig(examples_term_suffix="demos") - - # Custom term should match - demos_section = nodes.section() - demos_section["ids"] = ["demos"] - assert _is_examples_section(demos_section, config=config) is True - - prefixed_demos = nodes.section() - prefixed_demos["ids"] = ["output-demos"] - assert _is_examples_section(prefixed_demos, config=config) is True - - # Default term should not match - examples_section = nodes.section() - examples_section["ids"] = ["examples"] - assert _is_examples_section(examples_section, config=config) is False - - -def test_reorder_nodes_disabled_via_config() -> None: - """Reordering can be disabled via config.""" - config = ExemplarConfig(reorder_usage_before_examples=False) - - desc = nodes.paragraph(text="Description") - examples = _make_examples_section() - usage = _make_usage_node() - - # Original order: desc, examples, usage - result = _reorder_nodes([desc, examples, usage], config=config) - - # Order should be preserved (not reordered) - assert len(result) == 3 - assert isinstance(result[0], nodes.paragraph) - assert isinstance(result[1], nodes.section) # examples still in position 1 - assert isinstance(result[2], nodes.literal_block) # usage still at end - - -def test_transform_definition_list_with_custom_config() -> None: - """transform_definition_list respects custom config.""" - config = ExemplarConfig( - examples_term_suffix="demos", - examples_base_term="demos", - examples_section_title="Demos", - command_prefix="> ", - code_language="bash", - code_classes=("highlight-bash",), - ) - - dl = nodes.definition_list() - dl += _make_dl_item("demos:", "cmd1") - - result = transform_definition_list(dl, config=config) - - # Should create a section with "demos" id - assert len(result) == 1 - section = result[0] - assert isinstance(section, nodes.section) - assert section["ids"] == ["demos"] - - # Find the title - titles = [c for c in section.children if isinstance(c, nodes.title)] - assert len(titles) == 1 - assert titles[0].astext() == "Demos" - - # Find code blocks - code_blocks = [c for c in section.children if isinstance(c, nodes.literal_block)] - assert len(code_blocks) == 1 - assert code_blocks[0].astext() == "> cmd1" # Custom prefix - assert code_blocks[0]["language"] == "bash" - assert "highlight-bash" in code_blocks[0]["classes"] - - -# --- Parent reference maintenance tests --- - - -def test_process_node_maintains_parent_reference() -> None: - """Verify process_node maintains parent references after child replacement. - - When children are replaced in a container node, the docutils protocol - requires using extend() rather than direct assignment to node.children - to ensure parent-child relationships are properly maintained. - """ - # Create a container with ANSI-encoded text children - container = nodes.container() - text_with_ansi = nodes.Text("\033[32mgreen text\033[0m") - container += text_with_ansi - - # Process the node (will strip ANSI and replace children) - process_node(container) - - # Verify children have correct parent reference - for child in container.children: - assert child.parent is container, ( - f"Child {child!r} should have parent reference to container" - ) - - -def test_extract_sections_maintains_parent_reference() -> None: - """Verify _extract_sections_from_container maintains parent references. - - When remaining children are reassigned to the container, the docutils - protocol requires using extend() to maintain parent-child relationships. - """ - from sphinx_argparse_neo.nodes import argparse_program - - # Create container with mixed children - container = argparse_program() - para = nodes.paragraph(text="Description") - section = nodes.section() - section["ids"] = ["examples"] - - container += para - container += section - - # Extract sections - modified, _extracted = _extract_sections_from_container(container) - - # Verify remaining children have correct parent reference - for child in modified.children: - assert child.parent is modified, ( - f"Child {child!r} should have parent reference to modified container" - ) diff --git a/tests/docs/_ext/test_argparse_lexer.py b/tests/docs/_ext/test_argparse_lexer.py deleted file mode 100644 index d5966007a8..0000000000 --- a/tests/docs/_ext/test_argparse_lexer.py +++ /dev/null @@ -1,825 +0,0 @@ -"""Tests for argparse_lexer Pygments extension.""" - -from __future__ import annotations - -import typing as t - -import pytest -from sphinx_argparse_neo.lexer import ( - ArgparseHelpLexer, - ArgparseLexer, - ArgparseUsageLexer, - tokenize_argparse, - tokenize_usage, -) - -# --- Helper to extract token type names --- - - -def get_tokens(text: str, lexer_class: type = ArgparseLexer) -> list[tuple[str, str]]: - """Get tokens as (type_name, value) tuples. - - Examples - -------- - >>> tokens = get_tokens("usage: cmd [-h]") - >>> any(t[0] == "Token.Name.Attribute" for t in tokens) - True - """ - lexer = lexer_class() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] - - -def get_usage_tokens(text: str) -> list[tuple[str, str]]: - """Get tokens using ArgparseUsageLexer. - - Examples - -------- - >>> tokens = get_usage_tokens("usage: cmd") - >>> tokens[0] - ('Token.Generic.Heading', 'usage:') - """ - return get_tokens(text, ArgparseUsageLexer) - - -def get_help_tokens(text: str) -> list[tuple[str, str]]: - """Get tokens using ArgparseHelpLexer. - - Examples - -------- - >>> tokens = get_help_tokens("positional arguments:") - >>> any("Subheading" in t[0] for t in tokens) - True - """ - return get_tokens(text, ArgparseHelpLexer) - - -# --- Token type fixtures --- - - -class TokenTypeFixture(t.NamedTuple): - """Test fixture for verifying specific token types.""" - - test_id: str - input_text: str - expected_token_type: str - expected_value: str - - -TOKEN_TYPE_FIXTURES: list[TokenTypeFixture] = [ - TokenTypeFixture( - test_id="usage_heading", - input_text="usage:", - expected_token_type="Token.Generic.Heading", - expected_value="usage:", - ), - TokenTypeFixture( - test_id="short_option", - input_text="-h", - expected_token_type="Token.Name.Attribute", - expected_value="-h", - ), - TokenTypeFixture( - test_id="short_option_v", - input_text="-v", - expected_token_type="Token.Name.Attribute", - expected_value="-v", - ), - TokenTypeFixture( - test_id="long_option", - input_text="--verbose", - expected_token_type="Token.Name.Tag", - expected_value="--verbose", - ), - TokenTypeFixture( - test_id="long_option_with_dashes", - input_text="--no-color", - expected_token_type="Token.Name.Tag", - expected_value="--no-color", - ), - TokenTypeFixture( - test_id="uppercase_metavar", - input_text="FILE", - expected_token_type="Token.Name.Variable", - expected_value="FILE", - ), - TokenTypeFixture( - test_id="uppercase_metavar_path", - input_text="PATH", - expected_token_type="Token.Name.Variable", - expected_value="PATH", - ), - TokenTypeFixture( - test_id="uppercase_metavar_with_underscore", - input_text="FILE_PATH", - expected_token_type="Token.Name.Variable", - expected_value="FILE_PATH", - ), - TokenTypeFixture( - test_id="command_name", - input_text="sync", - expected_token_type="Token.Name.Label", - expected_value="sync", - ), - TokenTypeFixture( - test_id="command_with_dash", - input_text="repo-name", - expected_token_type="Token.Name.Label", - expected_value="repo-name", - ), - TokenTypeFixture( - test_id="open_bracket", - input_text="[", - expected_token_type="Token.Punctuation", - expected_value="[", - ), - TokenTypeFixture( - test_id="close_bracket", - input_text="]", - expected_token_type="Token.Punctuation", - expected_value="]", - ), - TokenTypeFixture( - test_id="open_paren", - input_text="(", - expected_token_type="Token.Punctuation", - expected_value="(", - ), - TokenTypeFixture( - test_id="close_paren", - input_text=")", - expected_token_type="Token.Punctuation", - expected_value=")", - ), - TokenTypeFixture( - test_id="open_brace", - input_text="{", - expected_token_type="Token.Punctuation", - expected_value="{", - ), - TokenTypeFixture( - test_id="pipe_operator", - input_text="|", - expected_token_type="Token.Operator", - expected_value="|", - ), - TokenTypeFixture( - test_id="ellipsis", - input_text="...", - expected_token_type="Token.Punctuation", - expected_value="...", - ), -] - - -@pytest.mark.parametrize( - list(TokenTypeFixture._fields), - TOKEN_TYPE_FIXTURES, - ids=[f.test_id for f in TOKEN_TYPE_FIXTURES], -) -def test_token_type( - test_id: str, - input_text: str, - expected_token_type: str, - expected_value: str, -) -> None: - """Test individual token type detection.""" - tokens = get_usage_tokens(input_text) - # Find the expected token (skip whitespace) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t and v.strip()] - assert len(non_ws_tokens) >= 1, f"No non-whitespace tokens found for '{input_text}'" - token_type, token_value = non_ws_tokens[0] - assert token_type == expected_token_type, ( - f"Expected {expected_token_type}, got {token_type}" - ) - assert token_value == expected_value - - -# --- Choice fixtures --- - - -class ChoiceFixture(t.NamedTuple): - """Test fixture for choice enumeration patterns.""" - - test_id: str - input_text: str - expected_choices: list[str] - - -CHOICE_FIXTURES: list[ChoiceFixture] = [ - ChoiceFixture( - test_id="simple_choices", - input_text="{json,yaml,table}", - expected_choices=["json", "yaml", "table"], - ), - ChoiceFixture( - test_id="numeric_choices", - input_text="{1,2,3}", - expected_choices=["1", "2", "3"], - ), - ChoiceFixture( - test_id="auto_always_never", - input_text="{auto,always,never}", - expected_choices=["auto", "always", "never"], - ), - ChoiceFixture( - test_id="two_choices", - input_text="{a,b}", - expected_choices=["a", "b"], - ), -] - - -@pytest.mark.parametrize( - list(ChoiceFixture._fields), - CHOICE_FIXTURES, - ids=[f.test_id for f in CHOICE_FIXTURES], -) -def test_choices( - test_id: str, - input_text: str, - expected_choices: list[str], -) -> None: - """Test choice enumeration tokenization.""" - tokens = get_usage_tokens(input_text) - # Extract choice values (Name.Constant tokens) - choice_tokens = [v for t, v in tokens if t == "Token.Name.Constant"] - assert choice_tokens == expected_choices - - -# --- Mutex group fixtures --- - - -class MutexGroupFixture(t.NamedTuple): - """Test fixture for mutually exclusive group patterns.""" - - test_id: str - input_text: str - expected_options: list[str] - is_required: bool - - -MUTEX_GROUP_FIXTURES: list[MutexGroupFixture] = [ - MutexGroupFixture( - test_id="optional_short", - input_text="[-a | -b | -c]", - expected_options=["-a", "-b", "-c"], - is_required=False, - ), - MutexGroupFixture( - test_id="optional_long", - input_text="[--foo FOO | --bar BAR]", - expected_options=["--foo", "--bar"], - is_required=False, - ), - MutexGroupFixture( - test_id="required_long", - input_text="(--foo | --bar)", - expected_options=["--foo", "--bar"], - is_required=True, - ), - MutexGroupFixture( - test_id="required_with_metavar", - input_text="(--input FILE | --stdin)", - expected_options=["--input", "--stdin"], - is_required=True, - ), - MutexGroupFixture( - test_id="optional_output_formats", - input_text="[--json | --ndjson | --table]", - expected_options=["--json", "--ndjson", "--table"], - is_required=False, - ), -] - - -@pytest.mark.parametrize( - list(MutexGroupFixture._fields), - MUTEX_GROUP_FIXTURES, - ids=[f.test_id for f in MUTEX_GROUP_FIXTURES], -) -def test_mutex_groups( - test_id: str, - input_text: str, - expected_options: list[str], - is_required: bool, -) -> None: - """Test mutually exclusive group tokenization.""" - tokens = get_usage_tokens(input_text) - - # Check for proper brackets (required uses parens, optional uses brackets) - if is_required: - assert ("Token.Punctuation", "(") in tokens - assert ("Token.Punctuation", ")") in tokens - else: - assert ("Token.Punctuation", "[") in tokens - assert ("Token.Punctuation", "]") in tokens - - # Check pipe operators present - pipe_count = sum(1 for t, v in tokens if t == "Token.Operator" and v == "|") - assert pipe_count == len(expected_options) - 1 - - # Check options are present - for opt in expected_options: - if opt.startswith("--"): - assert ("Token.Name.Tag", opt) in tokens - else: - assert ("Token.Name.Attribute", opt) in tokens - - -# --- Nargs pattern fixtures --- - - -class NargsFixture(t.NamedTuple): - """Test fixture for nargs/variadic patterns.""" - - test_id: str - input_text: str - has_ellipsis: bool - has_metavar: str | None - - -NARGS_FIXTURES: list[NargsFixture] = [ - NargsFixture( - test_id="nargs_plus", - input_text="FILE ...", - has_ellipsis=True, - has_metavar="FILE", - ), - NargsFixture( - test_id="nargs_star", - input_text="[FILE ...]", - has_ellipsis=True, - has_metavar="FILE", - ), - NargsFixture( - test_id="nargs_question", - input_text="[--foo [FOO]]", - has_ellipsis=False, - has_metavar="FOO", - ), - NargsFixture( - test_id="nargs_plus_with_option", - input_text="[--bar X [X ...]]", - has_ellipsis=True, - has_metavar="X", - ), -] - - -@pytest.mark.parametrize( - list(NargsFixture._fields), - NARGS_FIXTURES, - ids=[f.test_id for f in NARGS_FIXTURES], -) -def test_nargs_patterns( - test_id: str, - input_text: str, - has_ellipsis: bool, - has_metavar: str | None, -) -> None: - """Test nargs/variadic pattern tokenization.""" - tokens = get_usage_tokens(input_text) - - # Check ellipsis - ellipsis_present = ("Token.Punctuation", "...") in tokens - assert ellipsis_present == has_ellipsis - - # Check metavar - if has_metavar: - assert ("Token.Name.Variable", has_metavar) in tokens - - -# --- Long option with value fixtures --- - - -class LongOptionValueFixture(t.NamedTuple): - """Test fixture for long options with = values.""" - - test_id: str - input_text: str - option: str - value: str - - -LONG_OPTION_VALUE_FIXTURES: list[LongOptionValueFixture] = [ - LongOptionValueFixture( - test_id="config_file", - input_text="--config=FILE", - option="--config", - value="FILE", - ), - LongOptionValueFixture( - test_id="log_level", - input_text="--log-level=DEBUG", - option="--log-level", - value="DEBUG", - ), - LongOptionValueFixture( - test_id="lowercase_value", - input_text="--output=path", - option="--output", - value="path", - ), -] - - -@pytest.mark.parametrize( - list(LongOptionValueFixture._fields), - LONG_OPTION_VALUE_FIXTURES, - ids=[f.test_id for f in LONG_OPTION_VALUE_FIXTURES], -) -def test_long_option_with_equals_value( - test_id: str, - input_text: str, - option: str, - value: str, -) -> None: - """Test long option with = value tokenization.""" - tokens = get_usage_tokens(input_text) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] - - assert len(non_ws_tokens) >= 3 - assert non_ws_tokens[0] == ("Token.Name.Tag", option) - assert non_ws_tokens[1] == ("Token.Operator", "=") - assert non_ws_tokens[2][1] == value - - -# --- Short option with value fixtures --- - - -class ShortOptionValueFixture(t.NamedTuple): - """Test fixture for short options with space-separated values.""" - - test_id: str - input_text: str - option: str - value: str - - -SHORT_OPTION_VALUE_FIXTURES: list[ShortOptionValueFixture] = [ - ShortOptionValueFixture( - test_id="config_path", - input_text="-c config-path", - option="-c", - value="config-path", - ), - ShortOptionValueFixture( - test_id="directory", - input_text="-d DIRECTORY", - option="-d", - value="DIRECTORY", - ), - ShortOptionValueFixture( - test_id="simple_name", - input_text="-r name", - option="-r", - value="name", - ), - ShortOptionValueFixture( - test_id="underscore_metavar", - input_text="-L socket_name", - option="-L", - value="socket_name", - ), - ShortOptionValueFixture( - test_id="multiple_underscores", - input_text="-f tmux_config_file", - option="-f", - value="tmux_config_file", - ), -] - - -@pytest.mark.parametrize( - list(ShortOptionValueFixture._fields), - SHORT_OPTION_VALUE_FIXTURES, - ids=[f.test_id for f in SHORT_OPTION_VALUE_FIXTURES], -) -def test_short_option_with_value( - test_id: str, - input_text: str, - option: str, - value: str, -) -> None: - """Test short option followed by value tokenization.""" - tokens = get_usage_tokens(input_text) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] - - assert len(non_ws_tokens) >= 2 - assert non_ws_tokens[0] == ("Token.Name.Attribute", option) - assert non_ws_tokens[1][1] == value - - -# --- Full usage string fixtures --- - - -class UsageStringFixture(t.NamedTuple): - """Test fixture for full usage string tokenization.""" - - test_id: str - input_text: str - expected_contains: list[tuple[str, str]] - - -USAGE_STRING_FIXTURES: list[UsageStringFixture] = [ - UsageStringFixture( - test_id="simple_usage", - input_text="usage: cmd [-h]", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "cmd"), - ("Token.Punctuation", "["), - ("Token.Name.Attribute", "-h"), - ("Token.Punctuation", "]"), - ], - ), - UsageStringFixture( - test_id="mutually_exclusive", - input_text="[--json | --ndjson | --table]", - expected_contains=[ - ("Token.Name.Tag", "--json"), - ("Token.Operator", "|"), - ("Token.Name.Tag", "--ndjson"), - ("Token.Operator", "|"), - ("Token.Name.Tag", "--table"), - ], - ), - UsageStringFixture( - test_id="subcommand", - input_text="usage: vcspull sync", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "vcspull"), - # Subcommands now use Name.Function per 30ea233 - ("Token.Name.Function", "sync"), - ], - ), - UsageStringFixture( - test_id="with_choices", - input_text="usage: cmd {a,b,c}", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Constant", "a"), - ("Token.Name.Constant", "b"), - ("Token.Name.Constant", "c"), - ], - ), - UsageStringFixture( - test_id="complex_usage", - input_text="usage: prog [-h] [--verbose] FILE ...", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "prog"), - ("Token.Name.Attribute", "-h"), - ("Token.Name.Tag", "--verbose"), - ("Token.Name.Variable", "FILE"), - ("Token.Punctuation", "..."), - ], - ), -] - - -@pytest.mark.parametrize( - list(UsageStringFixture._fields), - USAGE_STRING_FIXTURES, - ids=[f.test_id for f in USAGE_STRING_FIXTURES], -) -def test_usage_string( - test_id: str, - input_text: str, - expected_contains: list[tuple[str, str]], -) -> None: - """Test full usage string tokenization contains expected tokens.""" - tokens = get_usage_tokens(input_text) - for expected_type, expected_value in expected_contains: - assert (expected_type, expected_value) in tokens, ( - f"Expected ({expected_type}, {expected_value!r}) not found in tokens" - ) - - -# --- Section header fixtures --- - - -class SectionHeaderFixture(t.NamedTuple): - """Test fixture for section header recognition.""" - - test_id: str - input_text: str - expected_header: str - - -SECTION_HEADER_FIXTURES: list[SectionHeaderFixture] = [ - SectionHeaderFixture( - test_id="positional_arguments", - input_text="positional arguments:", - expected_header="positional arguments:", - ), - SectionHeaderFixture( - test_id="options", - input_text="options:", - expected_header="options:", - ), - SectionHeaderFixture( - test_id="optional_arguments", - input_text="optional arguments:", - expected_header="optional arguments:", - ), - SectionHeaderFixture( - test_id="custom_section", - input_text="advanced options:", - expected_header="advanced options:", - ), -] - - -@pytest.mark.parametrize( - list(SectionHeaderFixture._fields), - SECTION_HEADER_FIXTURES, - ids=[f.test_id for f in SECTION_HEADER_FIXTURES], -) -def test_section_headers( - test_id: str, - input_text: str, - expected_header: str, -) -> None: - """Test section header tokenization.""" - tokens = get_help_tokens(input_text) - # Section headers should be Generic.Subheading - # Strip newlines from token values (lexer may include trailing \n) - subheading_tokens = [ - v.strip() for t, v in tokens if t == "Token.Generic.Subheading" - ] - assert expected_header in subheading_tokens - - -# --- Full help output test --- - - -def test_full_help_output() -> None: - """Test full argparse -h output tokenization.""" - help_text = """\ -usage: vcspull sync [-h] [-c CONFIG] [-d DIRECTORY] - [--json | --ndjson | --table] - [repo-name] [path] - -positional arguments: - repo-name repository name filter - path path filter - -options: - -h, --help show this help message and exit - -c CONFIG, --config CONFIG - config file path - --json output as JSON -""" - tokens = get_help_tokens(help_text) - - # Check usage heading - assert ("Token.Generic.Heading", "usage:") in tokens - - # Check section headers - subheadings = [v for t, v in tokens if t == "Token.Generic.Subheading"] - assert "positional arguments:" in subheadings - assert "options:" in subheadings - - # Check options are recognized - assert ("Token.Name.Attribute", "-h") in tokens - assert ("Token.Name.Tag", "--help") in tokens - assert ("Token.Name.Tag", "--config") in tokens - assert ("Token.Name.Tag", "--json") in tokens - - # Check command/positional names - assert ("Token.Name.Label", "vcspull") in tokens - # Subcommands now use Name.Function per 30ea233 - assert ("Token.Name.Function", "sync") in tokens - - -# --- Real vcspull usage output test --- - - -def test_vcspull_sync_usage() -> None: - """Test real vcspull sync usage output tokenization.""" - usage_text = """\ -usage: vcspull sync [-h] [-c CONFIG] [-d DIRECTORY] - [--json | --ndjson | --table] [--color {auto,always,never}] - [--no-progress] [--verbose] - [repo-name] [path]""" - - tokens = get_usage_tokens(usage_text) - - expected = [ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "vcspull"), - # Subcommands now use Name.Function per 30ea233 - ("Token.Name.Function", "sync"), - ("Token.Name.Attribute", "-h"), - ("Token.Name.Attribute", "-c"), - ("Token.Name.Variable", "CONFIG"), - ("Token.Name.Attribute", "-d"), - ("Token.Name.Variable", "DIRECTORY"), - ("Token.Name.Tag", "--json"), - ("Token.Name.Tag", "--ndjson"), - ("Token.Name.Tag", "--table"), - ("Token.Name.Tag", "--color"), - ("Token.Name.Tag", "--no-progress"), - ("Token.Name.Tag", "--verbose"), - # Optional positional args in brackets also use Name.Function per 30ea233 - ("Token.Name.Function", "repo-name"), - ("Token.Name.Function", "path"), - ] - - for expected_type, expected_value in expected: - assert (expected_type, expected_value) in tokens, ( - f"Expected ({expected_type}, {expected_value!r}) not in tokens" - ) - - # Check choices are properly tokenized - assert ("Token.Name.Constant", "auto") in tokens - assert ("Token.Name.Constant", "always") in tokens - assert ("Token.Name.Constant", "never") in tokens - - -# --- tokenize_argparse helper function test --- - - -def test_tokenize_argparse_helper() -> None: - """Test the tokenize_argparse helper function.""" - result = tokenize_argparse("usage: cmd [-h]") - - assert result[0] == ("Token.Generic.Heading", "usage:") - assert ("Token.Name.Label", "cmd") in result - assert ("Token.Name.Attribute", "-h") in result - - -def test_tokenize_usage_helper() -> None: - """Test the tokenize_usage helper function.""" - result = tokenize_usage("usage: cmd [-h]") - - assert result[0] == ("Token.Generic.Heading", "usage:") - assert ("Token.Name.Label", "cmd") in result - assert ("Token.Name.Attribute", "-h") in result - - -# --- Lexer class selection tests --- - - -def test_argparse_lexer_usage_detection() -> None: - """Test ArgparseLexer handles usage lines correctly.""" - lexer = ArgparseLexer() - tokens = list(lexer.get_tokens("usage: cmd [-h]")) - token_types = [str(t) for t, v in tokens] - assert "Token.Generic.Heading" in token_types - - -def test_argparse_lexer_section_detection() -> None: - """Test ArgparseLexer handles section headers correctly.""" - lexer = ArgparseLexer() - tokens = list(lexer.get_tokens("positional arguments:")) - token_types = [str(t) for t, v in tokens] - assert "Token.Generic.Subheading" in token_types - - -def test_argparse_usage_lexer_standalone() -> None: - """Test ArgparseUsageLexer works standalone.""" - lexer = ArgparseUsageLexer() - tokens = list(lexer.get_tokens("usage: cmd [-h] --foo FILE")) - token_types = [str(t) for t, v in tokens] - - assert "Token.Generic.Heading" in token_types - assert "Token.Name.Label" in token_types # cmd - assert "Token.Name.Attribute" in token_types # -h - assert "Token.Name.Tag" in token_types # --foo - - -def test_argparse_help_lexer_multiline() -> None: - """Test ArgparseHelpLexer handles multiline help.""" - lexer = ArgparseHelpLexer() - help_text = """usage: cmd - -options: - -h help -""" - tokens = list(lexer.get_tokens(help_text)) - token_values = [v for t, v in tokens] - - assert "usage:" in token_values - assert "options:" in token_values or any( - "options:" in v for v in token_values if isinstance(v, str) - ) - - -def test_lowercase_metavar_with_underscores() -> None: - """Test lowercase metavars with underscores are fully captured. - - Regression test: previously `socket_name` was tokenized as `socket` + `_name`. - Example from tmuxp load usage. - """ - usage = "usage: prog [-L socket_name] [-S socket_path] [-f config_file]" - tokens = get_usage_tokens(usage) - - # All underscore metavars should be fully captured - assert ("Token.Name.Variable", "socket_name") in tokens - assert ("Token.Name.Variable", "socket_path") in tokens - assert ("Token.Name.Variable", "config_file") in tokens diff --git a/tests/docs/_ext/test_argparse_roles.py b/tests/docs/_ext/test_argparse_roles.py deleted file mode 100644 index 08093c2481..0000000000 --- a/tests/docs/_ext/test_argparse_roles.py +++ /dev/null @@ -1,439 +0,0 @@ -"""Tests for argparse_roles docutils extension.""" - -from __future__ import annotations - -import typing as t - -import pytest -from docutils import nodes -from sphinx_argparse_neo.roles import ( - cli_choice_role, - cli_command_role, - cli_default_role, - cli_metavar_role, - cli_option_role, - normalize_options, - register_roles, -) - -# --- normalize_options tests --- - - -def test_normalize_options_none() -> None: - """Test normalize_options with None input.""" - assert normalize_options(None) == {} - - -def test_normalize_options_dict() -> None: - """Test normalize_options with dict input.""" - opts = {"class": "custom"} - assert normalize_options(opts) == {"class": "custom"} - - -def test_normalize_options_empty_dict() -> None: - """Test normalize_options with empty dict input.""" - assert normalize_options({}) == {} - - -# --- CLI Option Role Tests --- - - -class OptionRoleFixture(t.NamedTuple): - """Test fixture for CLI option role.""" - - test_id: str - text: str - expected_classes: list[str] - - -OPTION_ROLE_FIXTURES: list[OptionRoleFixture] = [ - OptionRoleFixture( - test_id="long_option", - text="--verbose", - expected_classes=["cli-option", "cli-option-long"], - ), - OptionRoleFixture( - test_id="long_option_with_dash", - text="--no-color", - expected_classes=["cli-option", "cli-option-long"], - ), - OptionRoleFixture( - test_id="short_option", - text="-h", - expected_classes=["cli-option", "cli-option-short"], - ), - OptionRoleFixture( - test_id="short_option_v", - text="-v", - expected_classes=["cli-option", "cli-option-short"], - ), - OptionRoleFixture( - test_id="no_dash_prefix", - text="option", - expected_classes=["cli-option"], - ), -] - - -@pytest.mark.parametrize( - list(OptionRoleFixture._fields), - OPTION_ROLE_FIXTURES, - ids=[f.test_id for f in OPTION_ROLE_FIXTURES], -) -def test_cli_option_role( - test_id: str, - text: str, - expected_classes: list[str], -) -> None: - """Test CLI option role generates correct node classes.""" - node_list, messages = cli_option_role( - "cli-option", - f":cli-option:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == expected_classes - - -def test_cli_option_role_with_options() -> None: - """Test CLI option role accepts options parameter.""" - node_list, _messages = cli_option_role( - "cli-option", - ":cli-option:`--test`", - "--test", - 1, - None, - options={"class": "extra"}, - ) - - assert len(node_list) == 1 - # Options are normalized but classes come from role logic - assert "cli-option" in node_list[0]["classes"] - - -# --- CLI Metavar Role Tests --- - - -class MetavarRoleFixture(t.NamedTuple): - """Test fixture for CLI metavar role.""" - - test_id: str - text: str - - -METAVAR_ROLE_FIXTURES: list[MetavarRoleFixture] = [ - MetavarRoleFixture(test_id="file", text="FILE"), - MetavarRoleFixture(test_id="path", text="PATH"), - MetavarRoleFixture(test_id="directory", text="DIRECTORY"), - MetavarRoleFixture(test_id="config", text="CONFIG"), - MetavarRoleFixture(test_id="lowercase", text="value"), -] - - -@pytest.mark.parametrize( - list(MetavarRoleFixture._fields), - METAVAR_ROLE_FIXTURES, - ids=[f.test_id for f in METAVAR_ROLE_FIXTURES], -) -def test_cli_metavar_role( - test_id: str, - text: str, -) -> None: - """Test CLI metavar role generates correct node.""" - node_list, messages = cli_metavar_role( - "cli-metavar", - f":cli-metavar:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == ["cli-metavar"] - - -# --- CLI Command Role Tests --- - - -class CommandRoleFixture(t.NamedTuple): - """Test fixture for CLI command role.""" - - test_id: str - text: str - - -COMMAND_ROLE_FIXTURES: list[CommandRoleFixture] = [ - CommandRoleFixture(test_id="sync", text="sync"), - CommandRoleFixture(test_id="add", text="add"), - CommandRoleFixture(test_id="vcspull", text="vcspull"), - CommandRoleFixture(test_id="list", text="list"), - CommandRoleFixture(test_id="with_dash", text="repo-add"), -] - - -@pytest.mark.parametrize( - list(CommandRoleFixture._fields), - COMMAND_ROLE_FIXTURES, - ids=[f.test_id for f in COMMAND_ROLE_FIXTURES], -) -def test_cli_command_role( - test_id: str, - text: str, -) -> None: - """Test CLI command role generates correct node.""" - node_list, messages = cli_command_role( - "cli-command", - f":cli-command:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == ["cli-command"] - - -# --- CLI Default Role Tests --- - - -class DefaultRoleFixture(t.NamedTuple): - """Test fixture for CLI default role.""" - - test_id: str - text: str - - -DEFAULT_ROLE_FIXTURES: list[DefaultRoleFixture] = [ - DefaultRoleFixture(test_id="none", text="None"), - DefaultRoleFixture(test_id="quoted_auto", text='"auto"'), - DefaultRoleFixture(test_id="number", text="0"), - DefaultRoleFixture(test_id="empty_string", text='""'), - DefaultRoleFixture(test_id="true", text="True"), - DefaultRoleFixture(test_id="false", text="False"), -] - - -@pytest.mark.parametrize( - list(DefaultRoleFixture._fields), - DEFAULT_ROLE_FIXTURES, - ids=[f.test_id for f in DEFAULT_ROLE_FIXTURES], -) -def test_cli_default_role( - test_id: str, - text: str, -) -> None: - """Test CLI default role generates correct node.""" - node_list, messages = cli_default_role( - "cli-default", - f":cli-default:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == ["cli-default"] - - -# --- CLI Choice Role Tests --- - - -class ChoiceRoleFixture(t.NamedTuple): - """Test fixture for CLI choice role.""" - - test_id: str - text: str - - -CHOICE_ROLE_FIXTURES: list[ChoiceRoleFixture] = [ - ChoiceRoleFixture(test_id="json", text="json"), - ChoiceRoleFixture(test_id="yaml", text="yaml"), - ChoiceRoleFixture(test_id="table", text="table"), - ChoiceRoleFixture(test_id="auto", text="auto"), - ChoiceRoleFixture(test_id="always", text="always"), - ChoiceRoleFixture(test_id="never", text="never"), -] - - -@pytest.mark.parametrize( - list(ChoiceRoleFixture._fields), - CHOICE_ROLE_FIXTURES, - ids=[f.test_id for f in CHOICE_ROLE_FIXTURES], -) -def test_cli_choice_role( - test_id: str, - text: str, -) -> None: - """Test CLI choice role generates correct node.""" - node_list, messages = cli_choice_role( - "cli-choice", - f":cli-choice:`{text}`", - text, - 1, - None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - node = node_list[0] - assert isinstance(node, nodes.literal) - assert node.astext() == text - assert node["classes"] == ["cli-choice"] - - -# --- Register Roles Test --- - - -def test_register_roles() -> None: - """Test register_roles doesn't raise errors.""" - # This should not raise any exceptions - register_roles() - - -# --- Role Return Type Tests --- - - -def test_all_roles_return_correct_types() -> None: - """Test all roles return proper tuple of (nodes, messages).""" - role_functions = [ - cli_option_role, - cli_metavar_role, - cli_command_role, - cli_default_role, - cli_choice_role, - ] - - for role_func in role_functions: - result = role_func("test", ":test:`value`", "value", 1, None) - - assert isinstance(result, tuple), f"{role_func.__name__} should return tuple" - assert len(result) == 2, f"{role_func.__name__} should return 2-tuple" - - node_list, messages = result - assert isinstance(node_list, list), ( - f"{role_func.__name__} first element should be list" - ) - assert isinstance(messages, list), ( - f"{role_func.__name__} second element should be list" - ) - assert len(node_list) == 1, f"{role_func.__name__} should return one node" - assert len(messages) == 0, ( - f"{role_func.__name__} should return no error messages" - ) - - -# --- Node Structure Tests --- - - -def test_cli_option_node_structure() -> None: - """Test CLI option node has expected structure.""" - node_list, _ = cli_option_role( - "cli-option", - ":cli-option:`--test`", - "--test", - 1, - None, - ) - - node = node_list[0] - - # Check node type - assert isinstance(node, nodes.literal) - - # Check rawsource is preserved - assert node.rawsource == ":cli-option:`--test`" - - # Check text content - assert len(node.children) == 1 - assert isinstance(node.children[0], nodes.Text) - assert str(node.children[0]) == "--test" - - -def test_roles_with_none_content_parameter() -> None: - """Test roles handle None content parameter correctly.""" - node_list, messages = cli_option_role( - "cli-option", - ":cli-option:`--test`", - "--test", - 1, - None, - options=None, - content=None, - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - -def test_roles_with_empty_content_parameter() -> None: - """Test roles handle empty content parameter correctly.""" - node_list, messages = cli_option_role( - "cli-option", - ":cli-option:`--test`", - "--test", - 1, - None, - options={}, - content=[], - ) - - assert len(node_list) == 1 - assert len(messages) == 0 - - -# --- Edge Case Tests --- - - -def test_cli_option_role_empty_text() -> None: - """Test CLI option role with empty text.""" - node_list, _messages = cli_option_role( - "cli-option", - ":cli-option:``", - "", - 1, - None, - ) - - assert len(node_list) == 1 - assert node_list[0].astext() == "" - # No dash prefix, so only base class - assert node_list[0]["classes"] == ["cli-option"] - - -def test_cli_option_role_special_characters() -> None: - """Test CLI option role with special characters in text.""" - node_list, _messages = cli_option_role( - "cli-option", - ":cli-option:`--foo-bar_baz`", - "--foo-bar_baz", - 1, - None, - ) - - assert len(node_list) == 1 - assert node_list[0].astext() == "--foo-bar_baz" - assert "cli-option-long" in node_list[0]["classes"] diff --git a/tests/docs/_ext/test_cli_usage_lexer.py b/tests/docs/_ext/test_cli_usage_lexer.py deleted file mode 100644 index fab92a3d14..0000000000 --- a/tests/docs/_ext/test_cli_usage_lexer.py +++ /dev/null @@ -1,358 +0,0 @@ -"""Tests for cli_usage_lexer Pygments extension.""" - -from __future__ import annotations - -import typing as t - -import pytest -from sphinx_argparse_neo.cli_usage_lexer import ( - CLIUsageLexer, - tokenize_usage, -) - -# --- Helper to extract token type names --- - - -def get_tokens(text: str) -> list[tuple[str, str]]: - """Get tokens as (type_name, value) tuples.""" - lexer = CLIUsageLexer() - return [ - (str(tok_type), tok_value) for tok_type, tok_value in lexer.get_tokens(text) - ] - - -# --- Token type fixtures --- - - -class TokenTypeFixture(t.NamedTuple): - """Test fixture for verifying specific token types.""" - - test_id: str - input_text: str - expected_token_type: str - expected_value: str - - -TOKEN_TYPE_FIXTURES: list[TokenTypeFixture] = [ - TokenTypeFixture( - test_id="usage_heading", - input_text="usage:", - expected_token_type="Token.Generic.Heading", - expected_value="usage:", - ), - TokenTypeFixture( - test_id="short_option", - input_text="-h", - expected_token_type="Token.Name.Attribute", - expected_value="-h", - ), - TokenTypeFixture( - test_id="long_option", - input_text="--verbose", - expected_token_type="Token.Name.Tag", - expected_value="--verbose", - ), - TokenTypeFixture( - test_id="long_option_with_dashes", - input_text="--no-color", - expected_token_type="Token.Name.Tag", - expected_value="--no-color", - ), - TokenTypeFixture( - test_id="uppercase_metavar", - input_text="COMMAND", - expected_token_type="Token.Name.Constant", - expected_value="COMMAND", - ), - TokenTypeFixture( - test_id="uppercase_metavar_with_underscore", - input_text="FILE_PATH", - expected_token_type="Token.Name.Constant", - expected_value="FILE_PATH", - ), - TokenTypeFixture( - test_id="positional_arg", - input_text="repo-name", - expected_token_type="Token.Name.Label", - expected_value="repo-name", - ), - TokenTypeFixture( - test_id="command_name", - input_text="vcspull", - expected_token_type="Token.Name.Label", - expected_value="vcspull", - ), - TokenTypeFixture( - test_id="open_bracket", - input_text="[", - expected_token_type="Token.Punctuation", - expected_value="[", - ), - TokenTypeFixture( - test_id="close_bracket", - input_text="]", - expected_token_type="Token.Punctuation", - expected_value="]", - ), - TokenTypeFixture( - test_id="pipe_operator", - input_text="|", - expected_token_type="Token.Operator", - expected_value="|", - ), -] - - -@pytest.mark.parametrize( - TokenTypeFixture._fields, - TOKEN_TYPE_FIXTURES, - ids=[f.test_id for f in TOKEN_TYPE_FIXTURES], -) -def test_token_type( - test_id: str, - input_text: str, - expected_token_type: str, - expected_value: str, -) -> None: - """Test individual token type detection.""" - tokens = get_tokens(input_text) - # Find the expected token (skip whitespace) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t and v.strip()] - assert len(non_ws_tokens) >= 1, f"No non-whitespace tokens found for '{input_text}'" - token_type, token_value = non_ws_tokens[0] - assert token_type == expected_token_type, ( - f"Expected {expected_token_type}, got {token_type}" - ) - assert token_value == expected_value - - -# --- Short option with value fixtures --- - - -class ShortOptionValueFixture(t.NamedTuple): - """Test fixture for short options with values.""" - - test_id: str - input_text: str - option: str - value: str - - -SHORT_OPTION_VALUE_FIXTURES: list[ShortOptionValueFixture] = [ - ShortOptionValueFixture( - test_id="lowercase_value", - input_text="-c config-path", - option="-c", - value="config-path", - ), - ShortOptionValueFixture( - test_id="uppercase_value", - input_text="-d DIRECTORY", - option="-d", - value="DIRECTORY", - ), - ShortOptionValueFixture( - test_id="simple_value", - input_text="-r name", - option="-r", - value="name", - ), -] - - -@pytest.mark.parametrize( - ShortOptionValueFixture._fields, - SHORT_OPTION_VALUE_FIXTURES, - ids=[f.test_id for f in SHORT_OPTION_VALUE_FIXTURES], -) -def test_short_option_with_value( - test_id: str, - input_text: str, - option: str, - value: str, -) -> None: - """Test short option followed by value tokenization.""" - tokens = get_tokens(input_text) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] - - assert len(non_ws_tokens) >= 2 - assert non_ws_tokens[0] == ("Token.Name.Attribute", option) - # Value could be Name.Variable or Name.Constant depending on case - assert non_ws_tokens[1][1] == value - - -# --- Long option with value fixtures --- - - -class LongOptionValueFixture(t.NamedTuple): - """Test fixture for long options with = values.""" - - test_id: str - input_text: str - option: str - value: str - - -LONG_OPTION_VALUE_FIXTURES: list[LongOptionValueFixture] = [ - LongOptionValueFixture( - test_id="uppercase_value", - input_text="--config=FILE", - option="--config", - value="FILE", - ), - LongOptionValueFixture( - test_id="lowercase_value", - input_text="--output=path", - option="--output", - value="path", - ), -] - - -@pytest.mark.parametrize( - LongOptionValueFixture._fields, - LONG_OPTION_VALUE_FIXTURES, - ids=[f.test_id for f in LONG_OPTION_VALUE_FIXTURES], -) -def test_long_option_with_value( - test_id: str, - input_text: str, - option: str, - value: str, -) -> None: - """Test long option with = value tokenization.""" - tokens = get_tokens(input_text) - non_ws_tokens = [(t, v) for t, v in tokens if "Whitespace" not in t] - - assert len(non_ws_tokens) >= 3 - assert non_ws_tokens[0] == ("Token.Name.Tag", option) - assert non_ws_tokens[1] == ("Token.Operator", "=") - assert non_ws_tokens[2][1] == value - - -# --- Full usage string fixtures --- - - -class UsageStringFixture(t.NamedTuple): - """Test fixture for full usage string tokenization.""" - - test_id: str - input_text: str - expected_contains: list[tuple[str, str]] - - -USAGE_STRING_FIXTURES: list[UsageStringFixture] = [ - UsageStringFixture( - test_id="simple_usage", - input_text="usage: cmd [-h]", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "cmd"), - ("Token.Punctuation", "["), - ("Token.Name.Attribute", "-h"), - ("Token.Punctuation", "]"), - ], - ), - UsageStringFixture( - test_id="mutually_exclusive", - input_text="[--json | --ndjson | --table]", - expected_contains=[ - ("Token.Name.Tag", "--json"), - ("Token.Operator", "|"), - ("Token.Name.Tag", "--ndjson"), - ("Token.Operator", "|"), - ("Token.Name.Tag", "--table"), - ], - ), - UsageStringFixture( - test_id="subcommand", - input_text="usage: vcspull sync", - expected_contains=[ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "vcspull"), - ("Token.Name.Label", "sync"), - ], - ), - UsageStringFixture( - test_id="positional_args", - input_text="[repo-name] [path]", - expected_contains=[ - ("Token.Punctuation", "["), - ("Token.Name.Label", "repo-name"), - ("Token.Punctuation", "]"), - ("Token.Punctuation", "["), - ("Token.Name.Label", "path"), - ("Token.Punctuation", "]"), - ], - ), -] - - -@pytest.mark.parametrize( - UsageStringFixture._fields, - USAGE_STRING_FIXTURES, - ids=[f.test_id for f in USAGE_STRING_FIXTURES], -) -def test_usage_string( - test_id: str, - input_text: str, - expected_contains: list[tuple[str, str]], -) -> None: - """Test full usage string tokenization contains expected tokens.""" - tokens = get_tokens(input_text) - for expected_type, expected_value in expected_contains: - assert (expected_type, expected_value) in tokens, ( - f"Expected ({expected_type}, {expected_value!r}) not found in tokens" - ) - - -# --- Real vcspull usage output test --- - - -def test_vcspull_sync_usage() -> None: - """Test real vcspull sync usage output tokenization.""" - usage_text = """\ -usage: vcspull sync [-h] [-c CONFIG] [-d DIRECTORY] - [--json | --ndjson | --table] [--color {auto,always,never}] - [--no-progress] [--verbose] - [repo-name] [path]""" - - tokens = get_tokens(usage_text) - - # Check key elements are present - # Note: DIRECTORY after -d is Name.Variable (option value), not Name.Constant - expected = [ - ("Token.Generic.Heading", "usage:"), - ("Token.Name.Label", "vcspull"), - ("Token.Name.Label", "sync"), - ("Token.Name.Attribute", "-h"), - ("Token.Name.Attribute", "-c"), - ("Token.Name.Variable", "CONFIG"), # Option value, not standalone metavar - ("Token.Name.Attribute", "-d"), - ("Token.Name.Variable", "DIRECTORY"), # Option value, not standalone metavar - ("Token.Name.Tag", "--json"), - ("Token.Name.Tag", "--ndjson"), - ("Token.Name.Tag", "--table"), - ("Token.Name.Tag", "--color"), - ("Token.Name.Tag", "--no-progress"), - ("Token.Name.Tag", "--verbose"), - ("Token.Name.Label", "repo-name"), - ("Token.Name.Label", "path"), - ] - - for expected_type, expected_value in expected: - assert (expected_type, expected_value) in tokens, ( - f"Expected ({expected_type}, {expected_value!r}) not in tokens" - ) - - -# --- tokenize_usage helper function test --- - - -def test_tokenize_usage_helper() -> None: - """Test the tokenize_usage helper function.""" - result = tokenize_usage("usage: cmd [-h]") - - assert result[0] == ("Token.Generic.Heading", "usage:") - assert ("Token.Name.Label", "cmd") in result - assert ("Token.Name.Attribute", "-h") in result diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py deleted file mode 100644 index df59c5208a..0000000000 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ /dev/null @@ -1,576 +0,0 @@ -"""Tests for sphinx_fonts Sphinx extension.""" - -from __future__ import annotations - -import logging -import pathlib -import types -import typing as t -import urllib.error - -import pytest -import sphinx_fonts - -# --- _cache_dir tests --- - - -def test_cache_dir_returns_home_cache_path() -> None: - """_cache_dir returns ~/.cache/sphinx-fonts.""" - result = sphinx_fonts._cache_dir() - assert result == pathlib.Path.home() / ".cache" / "sphinx-fonts" - - -# --- _cdn_url tests --- - - -class CdnUrlFixture(t.NamedTuple): - """Test fixture for CDN URL generation.""" - - test_id: str - package: str - version: str - font_id: str - subset: str - weight: int - style: str - expected_url: str - - -CDN_URL_FIXTURES: list[CdnUrlFixture] = [ - CdnUrlFixture( - test_id="normal_weight", - package="@fontsource/open-sans", - version="5.2.5", - font_id="open-sans", - subset="latin", - weight=400, - style="normal", - expected_url=( - "https://cdn.jsdelivr.net/npm/@fontsource/open-sans@5.2.5" - "/files/open-sans-latin-400-normal.woff2" - ), - ), - CdnUrlFixture( - test_id="bold_italic", - package="@fontsource/roboto", - version="5.0.0", - font_id="roboto", - subset="latin-ext", - weight=700, - style="italic", - expected_url=( - "https://cdn.jsdelivr.net/npm/@fontsource/roboto@5.0.0" - "/files/roboto-latin-ext-700-italic.woff2" - ), - ), -] - - -@pytest.mark.parametrize( - list(CdnUrlFixture._fields), - CDN_URL_FIXTURES, - ids=[f.test_id for f in CDN_URL_FIXTURES], -) -def test_cdn_url( - test_id: str, - package: str, - version: str, - font_id: str, - subset: str, - weight: int, - style: str, - expected_url: str, -) -> None: - """_cdn_url formats the CDN URL template correctly.""" - result = sphinx_fonts._cdn_url(package, version, font_id, subset, weight, style) - assert result == expected_url - - -def test_cdn_url_matches_template() -> None: - """_cdn_url produces URLs matching CDN_TEMPLATE structure.""" - url = sphinx_fonts._cdn_url( - "@fontsource/inter", "5.1.0", "inter", "latin", 400, "normal" - ) - assert url.startswith("https://cdn.jsdelivr.net/npm/") - assert "@fontsource/inter@5.1.0" in url - assert url.endswith(".woff2") - - -# --- _download_font tests --- - - -def test_download_font_cached( - tmp_path: pathlib.Path, - caplog: pytest.LogCaptureFixture, -) -> None: - """_download_font returns True and logs debug when file exists.""" - dest = tmp_path / "font.woff2" - dest.write_bytes(b"cached-data") - - with caplog.at_level(logging.DEBUG, logger="sphinx_fonts"): - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is True - debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG] - assert any("cached" in r.message for r in debug_records) - - -def test_download_font_success( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - """_download_font downloads and returns True on success.""" - dest = tmp_path / "subdir" / "font.woff2" - - def fake_urlretrieve(url: str, filename: t.Any) -> tuple[str, t.Any]: - pathlib.Path(filename).write_bytes(b"font-data") - return (str(filename), None) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - with caplog.at_level(logging.INFO, logger="sphinx_fonts"): - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is True - info_records = [r for r in caplog.records if r.levelno == logging.INFO] - assert any("downloaded" in r.message for r in info_records) - - -def test_download_font_url_error( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - """_download_font returns False and warns on URLError.""" - dest = tmp_path / "font.woff2" - - msg = "network error" - - def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: - raise urllib.error.URLError(msg) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is False - warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] - assert any("failed" in r.message for r in warning_records) - - -def test_download_font_os_error( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - """_download_font returns False and warns on OSError.""" - dest = tmp_path / "font.woff2" - - msg = "disk full" - - def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: - raise OSError(msg) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is False - warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] - assert any("failed" in r.message for r in warning_records) - - -def test_download_font_partial_file_cleanup( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_download_font removes partial file on failure.""" - dest = tmp_path / "cache" / "partial.woff2" - - msg = "disk full" - - def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: - pathlib.Path(filename).write_bytes(b"partial") - raise OSError(msg) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is False - assert not dest.exists() - - -# --- _on_builder_inited tests --- - - -def _make_app( - tmp_path: pathlib.Path, - *, - builder_format: str = "html", - fonts: list[dict[str, t.Any]] | None = None, - preload: list[tuple[str, int, str]] | None = None, - fallbacks: list[dict[str, str]] | None = None, - variables: dict[str, str] | None = None, -) -> types.SimpleNamespace: - """Create a fake Sphinx app namespace for testing.""" - config = types.SimpleNamespace( - sphinx_fonts=fonts if fonts is not None else [], - sphinx_font_preload=preload if preload is not None else [], - sphinx_font_fallbacks=fallbacks if fallbacks is not None else [], - sphinx_font_css_variables=variables if variables is not None else {}, - ) - builder = types.SimpleNamespace(format=builder_format) - return types.SimpleNamespace( - builder=builder, - config=config, - outdir=str(tmp_path / "output"), - ) - - -def test_on_builder_inited_non_html(tmp_path: pathlib.Path) -> None: - """_on_builder_inited returns early for non-HTML builders.""" - app = _make_app(tmp_path, builder_format="latex") - sphinx_fonts._on_builder_inited(app) - assert not hasattr(app, "_font_faces") - - -def test_on_builder_inited_empty_fonts(tmp_path: pathlib.Path) -> None: - """_on_builder_inited returns early when no fonts configured.""" - app = _make_app(tmp_path, fonts=[]) - sphinx_fonts._on_builder_inited(app) - assert not hasattr(app, "_font_faces") - - -def test_on_builder_inited_with_fonts( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited processes fonts and stores results on app.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/open-sans", - "version": "5.2.5", - "family": "Open Sans", - "weights": [400, 700], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - for weight in [400, 700]: - (cache / f"open-sans-latin-{weight}-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert len(app._font_faces) == 2 - assert app._font_faces[0]["family"] == "Open Sans" - assert app._font_faces[0]["weight"] == "400" - assert app._font_faces[1]["weight"] == "700" - assert app._font_preload_hrefs == [] - assert app._font_fallbacks == [] - assert app._font_css_variables == {} - - -def test_on_builder_inited_download_failure( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited skips font_faces entry on download failure.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - msg = "offline" - - def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: - raise urllib.error.URLError(msg) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - fonts = [ - { - "package": "@fontsource/inter", - "version": "5.0.0", - "family": "Inter", - "weights": [400], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - sphinx_fonts._on_builder_inited(app) - - assert len(app._font_faces) == 0 - - -def test_on_builder_inited_explicit_subset( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited respects explicit subset in font config.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/noto-sans", - "version": "5.0.0", - "family": "Noto Sans", - "subset": "latin-ext", - "weights": [400], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "noto-sans-latin-ext-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert app._font_faces[0]["filename"] == "noto-sans-latin-ext-400-normal.woff2" - - -def test_on_builder_inited_preload_uses_primary_subset( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Preload uses the first (primary) subset when multiple are configured.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/ibm-plex-sans", - "version": "5.2.8", - "family": "IBM Plex Sans", - "subsets": ["latin", "latin-ext"], - "weights": [400], - "styles": ["normal"], - }, - ] - preload = [("IBM Plex Sans", 400, "normal")] - app = _make_app(tmp_path, fonts=fonts, preload=preload) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "ibm-plex-sans-latin-400-normal.woff2").write_bytes(b"data") - (cache / "ibm-plex-sans-latin-ext-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - # Preload should only include the primary (first) subset - assert app._font_preload_hrefs == ["ibm-plex-sans-latin-400-normal.woff2"] - - -def test_on_builder_inited_preload_match( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited builds preload_hrefs for matching preload specs.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/open-sans", - "version": "5.2.5", - "family": "Open Sans", - "weights": [400], - "styles": ["normal"], - }, - ] - preload = [("Open Sans", 400, "normal")] - app = _make_app(tmp_path, fonts=fonts, preload=preload) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert app._font_preload_hrefs == ["open-sans-latin-400-normal.woff2"] - - -def test_on_builder_inited_preload_no_match( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited produces empty preload when family doesn't match.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/open-sans", - "version": "5.2.5", - "family": "Open Sans", - "weights": [400], - "styles": ["normal"], - }, - ] - preload = [("Nonexistent Font", 400, "normal")] - app = _make_app(tmp_path, fonts=fonts, preload=preload) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert app._font_preload_hrefs == [] - - -def test_on_builder_inited_fallbacks_and_variables( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited stores fallbacks and CSS variables on app.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/inter", - "version": "5.0.0", - "family": "Inter", - "weights": [400], - "styles": ["normal"], - }, - ] - fallbacks = [{"family": "system-ui", "style": "normal", "weight": "400"}] - variables = {"--font-body": "Inter, system-ui"} - app = _make_app(tmp_path, fonts=fonts, fallbacks=fallbacks, variables=variables) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "inter-latin-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert app._font_fallbacks == fallbacks - assert app._font_css_variables == variables - - -# --- _on_html_page_context tests --- - - -def test_on_html_page_context_with_attrs() -> None: - """_on_html_page_context injects font data from app attributes.""" - app = types.SimpleNamespace( - _font_preload_hrefs=["font-400.woff2"], - _font_faces=[ - { - "family": "Inter", - "weight": "400", - "style": "normal", - "filename": "font-400.woff2", - }, - ], - _font_fallbacks=[{"family": "system-ui"}], - _font_css_variables={"--font-body": "Inter"}, - ) - context: dict[str, t.Any] = {} - - sphinx_fonts._on_html_page_context( - app, - "index", - "page.html", - context, - None, - ) - - assert context["font_preload_hrefs"] == ["font-400.woff2"] - assert context["font_faces"] == app._font_faces - assert context["font_fallbacks"] == [{"family": "system-ui"}] - assert context["font_css_variables"] == {"--font-body": "Inter"} - - -def test_on_html_page_context_without_attrs() -> None: - """_on_html_page_context uses defaults when app attrs are missing.""" - app = types.SimpleNamespace() - context: dict[str, t.Any] = {} - - sphinx_fonts._on_html_page_context( - app, - "index", - "page.html", - context, - None, - ) - - assert context["font_preload_hrefs"] == [] - assert context["font_faces"] == [] - assert context["font_fallbacks"] == [] - assert context["font_css_variables"] == {} - - -# --- setup tests --- - - -def test_setup_return_value() -> None: - """Verify setup() returns correct metadata dict.""" - config_values: list[tuple[str, t.Any, str]] = [] - connections: list[tuple[str, t.Any]] = [] - - app = types.SimpleNamespace( - add_config_value=lambda name, default, rebuild: config_values.append( - (name, default, rebuild) - ), - connect=lambda event, handler: connections.append((event, handler)), - ) - - result = sphinx_fonts.setup(app) - - assert result == { - "version": sphinx_fonts.__version__, - "parallel_read_safe": True, - "parallel_write_safe": True, - } - - -def test_setup_config_values() -> None: - """Verify setup() registers all expected config values.""" - config_values: list[tuple[str, t.Any, str]] = [] - connections: list[tuple[str, t.Any]] = [] - - app = types.SimpleNamespace( - add_config_value=lambda name, default, rebuild: config_values.append( - (name, default, rebuild) - ), - connect=lambda event, handler: connections.append((event, handler)), - ) - - sphinx_fonts.setup(app) - - config_names = [c[0] for c in config_values] - assert "sphinx_fonts" in config_names - assert "sphinx_font_fallbacks" in config_names - assert "sphinx_font_css_variables" in config_names - assert "sphinx_font_preload" in config_names - assert all(c[2] == "html" for c in config_values) - - -def test_setup_event_connections() -> None: - """Verify setup() connects to builder-inited and html-page-context events.""" - config_values: list[tuple[str, t.Any, str]] = [] - connections: list[tuple[str, t.Any]] = [] - - app = types.SimpleNamespace( - add_config_value=lambda name, default, rebuild: config_values.append( - (name, default, rebuild) - ), - connect=lambda event, handler: connections.append((event, handler)), - ) - - sphinx_fonts.setup(app) - - event_names = [c[0] for c in connections] - assert "builder-inited" in event_names - assert "html-page-context" in event_names - - handlers = {c[0]: c[1] for c in connections} - assert handlers["builder-inited"] is sphinx_fonts._on_builder_inited - assert handlers["html-page-context"] is sphinx_fonts._on_html_page_context From 090ac1a5a639a3b5be91d0f2d6a20759cde4fc0a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 06:00:29 -0500 Subject: [PATCH 89/89] chore(mypy): Remove tests/docs/ exclude now that directory is deleted why: mypy exclude was added specifically to silence type errors in the upstream extension tests. With tests/docs/ removed, the exclusion is unnecessary. what: - Remove exclude = ["tests/docs/"] from [tool.mypy] --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a2ac7eb53e..38b552781f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,9 +144,6 @@ files = [ "src/", "tests/", ] -exclude = [ - "tests/docs/", -] enable_incomplete_feature = [] [[tool.mypy.overrides]]