diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c73dc047e..e60100a6c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,11 @@ jobs: matrix: python-version: ['3.14'] tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', 'master'] + backend: ['default'] + include: + - python-version: '3.14' + tmux-version: '3.6' + backend: 'rust-control' steps: - uses: actions/checkout@v6 @@ -78,6 +83,24 @@ jobs: export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH ls $HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin tmux -V + backend="${{ matrix.backend }}" + if [ "$backend" != "default" ]; then + if ! uv run python - <<'PY' +import importlib.util +import sys +sys.exit(0 if importlib.util.find_spec("vibe_tmux") else 1) +PY + then + echo "vibe_tmux not installed; skipping rust backend tests" + exit 0 + fi + export LIBTMUX_BACKEND=rust + export LIBTMUX_RUST_CONNECTION_KIND=protocol + if [ "$backend" = "rust-control" ]; then + export LIBTMUX_RUST_CONTROL_MODE=1 + export LIBTMUX_RUST_CONTROL_AUTOSTART=1 + fi + fi uv run py.test --cov=./ --cov-append --cov-report=xml -n auto --verbose env: COV_CORE_SOURCE: . diff --git a/conftest.py b/conftest.py index ada5aae3f..089df053b 100644 --- a/conftest.py +++ b/conftest.py @@ -16,6 +16,7 @@ import pytest from _pytest.doctest import DoctestItem +from libtmux._internal import trace as libtmux_trace from libtmux.pane import Pane from libtmux.pytest_plugin import USING_ZSH from libtmux.server import Server @@ -28,6 +29,34 @@ pytest_plugins = ["pytester"] +@pytest.fixture(autouse=True, scope="session") +def trace_session() -> None: + """Initialize trace collection when enabled.""" + if not libtmux_trace.TRACE_ENABLED: + return + if libtmux_trace.TRACE_RESET: + libtmux_trace.reset_trace() + + +def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter) -> None: + """Print trace summary in pytest's terminal summary.""" + if not libtmux_trace.TRACE_ENABLED: + return + terminalreporter.section("libtmux trace") + terminalreporter.write_line(libtmux_trace.summarize()) + + +@pytest.fixture(autouse=True) +def trace_test_context(request: pytest.FixtureRequest) -> t.Iterator[None]: + """Attach the current pytest node id to trace events.""" + if not libtmux_trace.TRACE_ENABLED: + yield + return + libtmux_trace.set_test_context(request.node.nodeid) + yield + libtmux_trace.set_test_context(None) + + @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, diff --git a/pyproject.toml b/pyproject.toml index 6b22739f8..8ce38c495 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,12 @@ dev = [ "mypy", ] +otel = [ + "opentelemetry-api>=1.20.0", + "opentelemetry-sdk>=1.20.0", + "opentelemetry-exporter-otlp>=1.20.0", +] + docs = [ "sphinx<9", "furo", diff --git a/src/libtmux/_internal/trace.py b/src/libtmux/_internal/trace.py new file mode 100644 index 000000000..0eafee55e --- /dev/null +++ b/src/libtmux/_internal/trace.py @@ -0,0 +1,147 @@ +"""Lightweight tracing for libtmux timing audits.""" + +from __future__ import annotations + +import contextlib +import contextvars +import itertools +import json +import os +import pathlib +import threading +import time +import typing as t + +TRACE_PATH = os.getenv("LIBTMUX_TRACE_PATH", "/tmp/libtmux-trace.jsonl") + + +def _env_flag(name: str) -> bool: + value = os.getenv(name) + if value is None: + return False + return value not in {"", "0", "false", "False", "no", "NO"} + + +TRACE_ENABLED = _env_flag("LIBTMUX_TRACE") +TRACE_RESET = _env_flag("LIBTMUX_TRACE_RESET") + +_TRACE_TEST: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "libtmux_trace_test", default=None +) +_TRACE_STACK: contextvars.ContextVar[tuple[int, ...]] = contextvars.ContextVar( + "libtmux_trace_stack", default=() +) +_TRACE_COUNTER = itertools.count(1) + + +def set_test_context(name: str | None) -> None: + _TRACE_TEST.set(name) + + +def reset_trace(path: str | None = None) -> None: + if not TRACE_ENABLED: + return + target = path or TRACE_PATH + with pathlib.Path(target).open("w", encoding="utf-8") as handle: + handle.write("") + + +def _write_event(event: dict[str, t.Any]) -> None: + if not TRACE_ENABLED: + return + event["pid"] = os.getpid() + event["thread"] = threading.get_ident() + test_name = _TRACE_TEST.get() + if test_name: + event["test"] = test_name + with pathlib.Path(TRACE_PATH).open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event, sort_keys=False)) + handle.write("\n") + + +@contextlib.contextmanager +def span(name: str, **fields: t.Any) -> t.Iterator[None]: + if not TRACE_ENABLED: + yield + return + span_id = next(_TRACE_COUNTER) + stack = _TRACE_STACK.get() + parent_id = stack[-1] if stack else None + _TRACE_STACK.set((*stack, span_id)) + start_ns = time.perf_counter_ns() + try: + yield + finally: + duration_ns = time.perf_counter_ns() - start_ns + _TRACE_STACK.set(stack) + event = { + "event": name, + "span_id": span_id, + "parent_id": parent_id, + "depth": len(stack), + "start_ns": start_ns, + "duration_ns": duration_ns, + } + event.update(fields) + _write_event(event) + + +def point(name: str, **fields: t.Any) -> None: + if not TRACE_ENABLED: + return + event = {"event": name, "point": True, "ts_ns": time.perf_counter_ns()} + event.update(fields) + _write_event(event) + + +def summarize(path: str | None = None, limit: int = 20) -> str: + target = path or TRACE_PATH + if not pathlib.Path(target).exists(): + return "libtmux trace: no data collected" + + totals: dict[str, dict[str, int]] = {} + slowest: list[tuple[int, str]] = [] + + with pathlib.Path(target).open(encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("point"): + continue + name = str(event.get("event", "unknown")) + duration = int(event.get("duration_ns", 0)) + entry = totals.setdefault(name, {"count": 0, "total_ns": 0, "max_ns": 0}) + entry["count"] += 1 + entry["total_ns"] += duration + if duration > entry["max_ns"]: + entry["max_ns"] = duration + slowest.append((duration, json.dumps(event, sort_keys=False))) + + if not totals: + return "libtmux trace: no span data collected" + + sorted_totals = sorted( + totals.items(), key=lambda item: item[1]["total_ns"], reverse=True + ) + sorted_slowest = sorted(slowest, key=lambda item: item[0], reverse=True)[:limit] + + lines = ["libtmux trace summary (ns):"] + for name, stats in sorted_totals[:limit]: + avg = stats["total_ns"] // max(stats["count"], 1) + lines.append( + f"- {name}: count={stats['count']} total={stats['total_ns']} avg={avg} " + f"max={stats['max_ns']}" + ) + lines.append("libtmux trace slowest spans:") + for duration, payload in sorted_slowest: + lines.append(f"- {duration} {payload}") + return "\n".join(lines) + + +if TRACE_ENABLED and TRACE_RESET: + reset_trace() diff --git a/src/libtmux/_rust.py b/src/libtmux/_rust.py new file mode 100644 index 000000000..cc173235f --- /dev/null +++ b/src/libtmux/_rust.py @@ -0,0 +1,46 @@ +"""Rust-backed libtmux bindings. + +This module is intentionally thin: it re-exports Rust types from the +vibe-tmux extension so libtmux can opt into the Rust backend. +""" + +from __future__ import annotations + +import importlib +from typing import Any + +_EXPORTS = ("Server",) +_NATIVE: Any | None = None + + +class RustBackendImportError(ImportError): + """Raise when the Rust backend cannot be imported.""" + + def __init__(self) -> None: + super().__init__( + "libtmux rust backend requires the vibe_tmux extension to be installed" + ) + + +def _load_native() -> Any: + global _NATIVE + if _NATIVE is None: + try: + _NATIVE = importlib.import_module("vibe_tmux") + except Exception as exc: # pragma: no cover - import path is env-dependent + raise RustBackendImportError() from exc + return _NATIVE + + +def __getattr__(name: str) -> Any: + if name in _EXPORTS: + native = _load_native() + return getattr(native, name) + raise AttributeError(name) + + +def __dir__() -> list[str]: + return sorted(list(globals().keys()) + list(_EXPORTS)) + + +__all__ = _EXPORTS diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 60a3b49c7..2decab072 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,8 +7,12 @@ from __future__ import annotations +import contextlib import logging +import os +import pathlib import re +import shlex import shutil import subprocess import sys @@ -16,6 +20,13 @@ from . import exc from ._compat import LooseVersion +from ._internal import trace as libtmux_trace + +try: + from .otel import start_span +except Exception: # pragma: no cover - optional dependency + def start_span(name: str, **fields): + return libtmux_trace.span(name, **fields) if t.TYPE_CHECKING: from collections.abc import Callable @@ -34,6 +45,336 @@ WindowOptionDict = dict[str, t.Any] PaneDict = dict[str, t.Any] +_RUST_BACKEND = os.getenv("LIBTMUX_BACKEND") == "rust" +_RUST_SERVER_CACHE: dict[ + tuple[ + str | None, + str | None, + int | None, + str | None, + str | None, + bool | None, + str | None, + ], + t.Any, +] = {} +_RUST_SERVER_CONFIG: dict[ + tuple[ + str | None, + str | None, + int | None, + str | None, + str | None, + bool | None, + str | None, + ], + set[str], +] = {} + + +def _resolve_rust_socket_path(socket_path: str | None, socket_name: str | None) -> str: + if socket_path: + return socket_path + uid = os.geteuid() + name = socket_name or "default" + base = ( + os.getenv("TMUX_TMPDIR") + or os.getenv("XDG_RUNTIME_DIR") + or f"/run/user/{uid}" + or "/tmp" + ) + base_path = pathlib.Path(base) + socket_dir = base_path / f"tmux-{uid}" + socket_dir.mkdir(parents=True, exist_ok=True) + with contextlib.suppress(OSError): + socket_dir.chmod(0o700) + return str(socket_dir / name) + + +def _env_bool(name: str) -> bool | None: + value = os.getenv(name) + if value is None: + return None + value = value.strip().lower() + if value in {"1", "true", "yes", "on"}: + return True + if value in {"0", "false", "no", "off"}: + return False + return None + + +def _rust_run_with_config( + socket_path: str | None, + socket_name: str | None, + config_file: str, + cmd_parts: list[str], + cmd_list: list[str], +) -> tuple[list[str], list[str], int, list[str]]: + with start_span( + "rust_run_with_config", + layer="tmux-bin", + cmd=" ".join(cmd_parts), + socket_name=socket_name, + socket_path=socket_path, + config_file=config_file, + ): + tmux_bin = shutil.which("tmux") + if not tmux_bin: + raise exc.TmuxCommandNotFound + resolved_socket = _resolve_rust_socket_path(socket_path, socket_name) + process = subprocess.Popen( + [tmux_bin, "-S", resolved_socket, "-f", config_file, *cmd_parts], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="backslashreplace", + ) + stdout_raw, stderr_raw = process.communicate() + stdout_lines = stdout_raw.split("\n") if stdout_raw else [] + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + stderr_lines = list(filter(None, stderr_raw.split("\n"))) if stderr_raw else [] + if "has-session" in cmd_list and stderr_lines and not stdout_lines: + stdout_lines = [stderr_lines[0]] + return stdout_lines, stderr_lines, process.returncode, cmd_list + + +def _parse_tmux_args(args: tuple[t.Any, ...]) -> tuple[ + str | None, str | None, str | None, int | None, list[str] +]: + socket_name: str | None = None + socket_path: str | None = None + config_file: str | None = None + colors: int | None = None + cmd_parts: list[str] = [] + parsing_globals = True + + idx = 0 + while idx < len(args): + arg = str(args[idx]) + if parsing_globals and arg == "--": + parsing_globals = False + idx += 1 + continue + if parsing_globals and arg.startswith("-L") and len(arg) > 2: + socket_name = arg[2:] + idx += 1 + continue + if parsing_globals and arg == "-L" and idx + 1 < len(args): + socket_name = str(args[idx + 1]) + idx += 2 + continue + if parsing_globals and arg.startswith("-S") and len(arg) > 2: + socket_path = arg[2:] + idx += 1 + continue + if parsing_globals and arg == "-S" and idx + 1 < len(args): + socket_path = str(args[idx + 1]) + idx += 2 + continue + if parsing_globals and arg.startswith("-f") and len(arg) > 2: + config_file = arg[2:] + idx += 1 + continue + if parsing_globals and arg == "-f" and idx + 1 < len(args): + config_file = str(args[idx + 1]) + idx += 2 + continue + if parsing_globals and arg == "-2": + colors = 256 + idx += 1 + continue + if parsing_globals and arg == "-8": + colors = 88 + idx += 1 + continue + if parsing_globals: + parsing_globals = False + cmd_parts.append(arg) + idx += 1 + + return socket_name, socket_path, config_file, colors, cmd_parts + + +def _rust_server( + socket_name: str | None, + socket_path: str | None, + colors: int | None, +) -> t.Any: + connection_kind = os.getenv("LIBTMUX_RUST_CONNECTION_KIND") + server_kind = os.getenv("LIBTMUX_RUST_SERVER_KIND") + control_autostart = _env_bool("LIBTMUX_RUST_CONTROL_AUTOSTART") + mux_server_bin = os.getenv("LIBTMUX_RUST_MUX_SERVER_BIN") or os.getenv( + "MUX_SERVER_BIN" + ) + key = ( + socket_name, + socket_path, + colors, + connection_kind, + server_kind, + control_autostart, + mux_server_bin, + ) + server = _RUST_SERVER_CACHE.get(key) + if server is None: + from libtmux import _rust as rust_backend + + kwargs: dict[str, t.Any] = {} + if connection_kind: + kwargs["connection_kind"] = connection_kind + if server_kind: + kwargs["server_kind"] = server_kind + if control_autostart is not None: + kwargs["control_autostart"] = control_autostart + if mux_server_bin: + kwargs["mux_server_bin"] = mux_server_bin + with start_span( + "rust_server_init", + layer="python", + socket_name=socket_name, + socket_path=socket_path, + connection_kind=connection_kind, + server_kind=server_kind, + control_autostart=control_autostart, + mux_server_bin=mux_server_bin, + ): + server = rust_backend.Server( + socket_path=socket_path, + socket_name=socket_name, + **kwargs, + ) + _RUST_SERVER_CACHE[key] = server + _RUST_SERVER_CONFIG[key] = set() + + return server + + +def _rust_cmd_result( + args: tuple[t.Any, ...], +) -> tuple[list[str], list[str], int, list[str]]: + socket_name, socket_path, config_file, colors, cmd_parts = _parse_tmux_args(args) + cmd_list = [str(c) for c in args] + if not cmd_parts: + return [], [], 0, cmd_list + if cmd_parts == ["-V"]: + tmux_bin = shutil.which("tmux") + if not tmux_bin: + raise exc.TmuxCommandNotFound + process = subprocess.Popen( + [tmux_bin, "-V"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="backslashreplace", + ) + stdout_raw, stderr_raw = process.communicate() + stdout = stdout_raw.split("\n") if stdout_raw else [] + while stdout and stdout[-1] == "": + stdout.pop() + stderr = list(filter(None, stderr_raw.split("\n"))) if stderr_raw else [] + return stdout, stderr, process.returncode, cmd_list + + connection_kind = os.getenv("LIBTMUX_RUST_CONNECTION_KIND") + server_kind = os.getenv("LIBTMUX_RUST_SERVER_KIND") + control_autostart = _env_bool("LIBTMUX_RUST_CONTROL_AUTOSTART") + mux_server_bin = os.getenv("LIBTMUX_RUST_MUX_SERVER_BIN") or os.getenv( + "MUX_SERVER_BIN" + ) + with start_span( + "rust_cmd_result", + layer="python", + cmd=" ".join(cmd_parts), + socket_name=socket_name, + socket_path=socket_path, + config_file=config_file, + connection_kind=connection_kind, + server_kind=server_kind, + control_autostart=control_autostart, + mux_server_bin=mux_server_bin, + ): + if connection_kind in {"bin", "tmux-bin"} and config_file: + cmd_parts = ["-f", config_file, *cmd_parts] + config_file = None + + server = _rust_server(socket_name, socket_path, colors) + key = ( + socket_name, + socket_path, + colors, + connection_kind, + server_kind, + control_autostart, + mux_server_bin, + ) + if config_file: + loaded = _RUST_SERVER_CONFIG.setdefault(key, set()) + if config_file not in loaded: + with start_span("rust_server_is_alive", layer="rust"): + server_alive = bool(server.is_alive()) + if not server_alive: + stdout_lines, stderr_lines, exit_code, cmd_args = ( + _rust_run_with_config( + socket_path, + socket_name, + config_file, + cmd_parts, + cmd_list, + ) + ) + if exit_code == 0: + loaded.add(config_file) + return stdout_lines, stderr_lines, exit_code, cmd_args + quoted = shlex.quote(config_file) + try: + with start_span( + "rust_server_source_file", + layer="rust", + config_file=config_file, + ): + server.cmd(f"source-file {quoted}") + except Exception as err: + message = str(err) + error_stdout: list[str] = [] + error_stderr = [message] if message else [] + if "has-session" in cmd_list and error_stderr and not error_stdout: + error_stdout = [error_stderr[0]] + return error_stdout, error_stderr, 1, cmd_list + loaded.add(config_file) + + cmd_line = " ".join(shlex.quote(part) for part in cmd_parts) + try: + with start_span( + "rust_server_cmd", + layer="rust", + cmd=cmd_line, + socket_name=socket_name, + socket_path=socket_path, + config_file=config_file, + connection_kind=connection_kind, + ): + result = server.cmd(cmd_line) + except Exception as err: + message = str(err) + error_stdout_lines: list[str] = [] + error_stderr_lines = [message] if message else [] + if ( + "has-session" in cmd_list + and error_stderr_lines + and not error_stdout_lines + ): + error_stdout_lines = [error_stderr_lines[0]] + return error_stdout_lines, error_stderr_lines, 1, cmd_list + + stdout_lines = result.stdout.split("\n") if result.stdout else [] + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + stderr_raw = getattr(result, "exit_message", None) or "" + stderr_lines = list(filter(None, stderr_raw.split("\n"))) if stderr_raw else [] + if "has-session" in cmd_list and stderr_lines and not stdout_lines: + stdout_lines = [stderr_lines[0]] + return stdout_lines, stderr_lines, result.exit_code, cmd_list + class CmdProtocol(t.Protocol): """Command protocol for tmux command.""" @@ -249,6 +590,15 @@ class tmux_cmd: """ def __init__(self, *args: t.Any) -> None: + if _RUST_BACKEND: + with start_span("tmux_cmd", layer="python", backend="rust"): + stdout, stderr, returncode, cmd = _rust_cmd_result(args) + self.cmd = cmd + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + return + tmux_bin = shutil.which("tmux") if not tmux_bin: raise exc.TmuxCommandNotFound @@ -260,27 +610,33 @@ def __init__(self, *args: t.Any) -> None: self.cmd = cmd try: - self.process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - errors="backslashreplace", - ) - stdout, stderr = self.process.communicate() - returncode = self.process.returncode + with start_span( + "tmux_cmd", + layer="tmux-bin", + backend="tmux-bin", + cmd=" ".join(cmd), + ): + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="backslashreplace", + ) + stdout_text, stderr_text = self.process.communicate() + returncode = self.process.returncode except Exception: logger.exception(f"Exception for {subprocess.list2cmdline(cmd)}") raise self.returncode = returncode - stdout_split = stdout.split("\n") + stdout_split = stdout_text.split("\n") # remove trailing newlines from stdout while stdout_split and stdout_split[-1] == "": stdout_split.pop() - stderr_split = stderr.split("\n") + stderr_split = stderr_text.split("\n") self.stderr = list(filter(None, stderr_split)) # filter empty values if "has-session" in cmd and len(self.stderr) and not stdout_split: diff --git a/src/libtmux/otel.py b/src/libtmux/otel.py new file mode 100644 index 000000000..4a2924e54 --- /dev/null +++ b/src/libtmux/otel.py @@ -0,0 +1,132 @@ +"""OpenTelemetry helpers for libtmux (optional).""" + +from __future__ import annotations + +import os +from contextlib import ExitStack +from typing import Any + +from ._internal import trace as libtmux_trace + +_initialized = False +_tracer = None +_provider = None + +DEFAULT_OTLP_ENDPOINT = "http://localhost:4318" + + +def _env_flag(name: str) -> bool: + raw = os.getenv(name) + if raw is None: + return False + value = raw.strip().lower() + return value in {"1", "true"} + + +def _otel_enabled() -> bool: + if _env_flag("VIBE_TMUX_OTEL") or _env_flag("LIBTMUX_OTEL"): + return True + return bool( + os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + or os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + ) + + +def _normalize_endpoint(endpoint: str) -> str: + if endpoint.endswith("/v1/traces"): + return endpoint + if endpoint.endswith("/"): + return f"{endpoint}v1/traces" + return f"{endpoint}/v1/traces" + + +def init_otel() -> None: + global _initialized, _tracer, _provider + if _initialized: + return + _initialized = True + + if not _otel_enabled(): + return + + try: + from opentelemetry import propagate, trace + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter, + ) + from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_NAMESPACE + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.trace.propagation.tracecontext import ( + TraceContextTextMapPropagator, + ) + from libtmux.__about__ import __version__ + except Exception: + return + + endpoint = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.getenv( + "OTEL_EXPORTER_OTLP_ENDPOINT" + ) + if endpoint is None and (_env_flag("VIBE_TMUX_OTEL") or _env_flag("LIBTMUX_OTEL")): + endpoint = DEFAULT_OTLP_ENDPOINT + os.environ.setdefault("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint) + if _env_flag("VIBE_TMUX_OTEL") or _env_flag("LIBTMUX_OTEL"): + os.environ.setdefault("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + + exporter = ( + OTLPSpanExporter(endpoint=_normalize_endpoint(endpoint)) + if endpoint + else OTLPSpanExporter() + ) + + resource = Resource.create( + { + SERVICE_NAME: "libtmux", + SERVICE_NAMESPACE: "vibe-tmux", + "service.version": __version__, + } + ) + + _provider = TracerProvider(resource=resource) + use_batch = _env_flag("VIBE_TMUX_OTEL_BATCH") or _env_flag("LIBTMUX_OTEL_BATCH") + use_sync = _env_flag("VIBE_TMUX_OTEL_SYNC") or _env_flag("LIBTMUX_OTEL_SYNC") + if use_batch or (not use_sync and not _env_flag("VIBE_TMUX_OTEL")): + _provider.add_span_processor(BatchSpanProcessor(exporter)) + else: + _provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace.set_tracer_provider(_provider) + propagate.set_global_textmap(TraceContextTextMapPropagator()) + _tracer = trace.get_tracer("libtmux") + if _provider is not None: + import atexit + + atexit.register(_provider.shutdown) + + + +def _normalize_attr(value: Any): + if value is None: + return None + if isinstance(value, (str, bytes, bool, int, float)): + return value + if isinstance(value, (list, tuple)): + items = [] + for item in value: + normalized = _normalize_attr(item) + if normalized is None: + continue + items.append(normalized) + return items + return str(value) + +def start_span(name: str, attributes: dict[str, Any] | None = None, **fields: Any): + init_otel() + stack = ExitStack() + stack.enter_context(libtmux_trace.span(name, **fields)) + if _tracer is None: + return stack + otel_attrs = attributes or fields + filtered = {k: _normalize_attr(v) for k, v in otel_attrs.items() if _normalize_attr(v) is not None} + stack.enter_context(_tracer.start_as_current_span(name, attributes=filtered)) + return stack diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index cc45ce7a9..196d032f0 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -13,6 +13,7 @@ import pytest from libtmux import exc +from libtmux.common import _rust_server from libtmux.server import Server from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.random import get_test_session_name, namer @@ -24,6 +25,13 @@ USING_ZSH = "zsh" in os.getenv("SHELL", "") +def _env_truthy(name: str) -> bool: + value = os.getenv(name) + if value is None: + return False + return value.strip().lower() in {"1", "true", "yes", "on"} + + @pytest.fixture(scope="session") def home_path(tmp_path_factory: pytest.TempPathFactory) -> pathlib.Path: """Temporary `/home/` path.""" @@ -141,8 +149,25 @@ def server( >>> result.assert_outcomes(passed=1) """ server = Server(socket_name=f"libtmux_test{next(namer)}") + rust_refresh = None + rust_server = None + + if os.getenv("LIBTMUX_BACKEND") == "rust" and _env_truthy( + "LIBTMUX_RUST_CONTROL_MODE" + ): + socket_path = ( + str(server.socket_path) + if isinstance(server.socket_path, pathlib.Path) + else server.socket_path + ) + rust_server = _rust_server(server.socket_name, socket_path, server.colors) + rust_refresh = rust_server.subscribe(10, capture_view=False) + setattr(server, "_rust_refresh", rust_refresh) def fin() -> None: + if rust_server is not None: + with contextlib.suppress(Exception): + rust_server.close() server.kill() request.addfinalizer(fin) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 71f9f84a7..b6d4a4c45 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -15,6 +15,14 @@ import typing as t from libtmux import exc, formats +from libtmux._internal import trace as libtmux_trace + +try: + from libtmux.otel import start_span +except Exception: # pragma: no cover - optional dependency + def start_span(name: str, **fields): + return libtmux_trace.span(name, **fields) + from libtmux._internal.query_list import QueryList from libtmux.common import tmux_cmd from libtmux.constants import OptionScope @@ -29,6 +37,7 @@ PaneDict, SessionDict, WindowDict, + _rust_server, session_check_name, ) from .options import OptionsMixin @@ -204,6 +213,28 @@ def is_alive(self) -> bool: >>> tmux = Server(socket_name="no_exist") >>> assert not tmux.is_alive() """ + if os.getenv("LIBTMUX_BACKEND") == "rust": + with start_span( + "server_is_alive", + layer="python", + backend="rust", + socket_name=self.socket_name, + socket_path=str(self.socket_path) if self.socket_path else None, + ): + try: + socket_path = ( + str(self.socket_path) + if isinstance(self.socket_path, pathlib.Path) + else self.socket_path + ) + server = _rust_server(self.socket_name, socket_path, self.colors) + with start_span( + "rust_server_is_alive", + layer="rust", + ): + return bool(server.is_alive()) + except Exception: + return False try: res = self.cmd("list-sessions") except Exception: @@ -220,6 +251,36 @@ def raise_if_dead(self) -> None: ... print(type(e)) """ + if os.getenv("LIBTMUX_BACKEND") == "rust": + with start_span( + "server_raise_if_dead", + layer="python", + backend="rust", + socket_name=self.socket_name, + socket_path=str(self.socket_path) if self.socket_path else None, + ): + rust_cmd_args: list[str] = ["list-sessions"] + if self.socket_name: + rust_cmd_args.insert(0, f"-L{self.socket_name}") + if self.socket_path: + rust_cmd_args.insert(0, f"-S{self.socket_path}") + if self.config_file: + rust_cmd_args.insert(0, f"-f{self.config_file}") + try: + socket_path = ( + str(self.socket_path) + if isinstance(self.socket_path, pathlib.Path) + else self.socket_path + ) + server = _rust_server(self.socket_name, socket_path, self.colors) + with start_span( + "rust_server_require", + layer="rust", + ): + server.require_server() + except Exception as err: + raise subprocess.CalledProcessError(1, rust_cmd_args) from err + return tmux_bin = shutil.which("tmux") if tmux_bin is None: raise exc.TmuxCommandNotFound diff --git a/uv.lock b/uv.lock index ae01010fc..12de86d4c 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10, <4.0" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] @@ -347,6 +348,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + [[package]] name = "gp-libs" version = "0.0.17" @@ -361,6 +374,67 @@ 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 = "grpcio" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/17/ff4795dc9a34b6aee6ec379f1b66438a3789cd1315aac0cbab60d92f74b3/grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc", size = 5840037, upload-time = "2025-10-21T16:20:25.069Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ff/35f9b96e3fa2f12e1dcd58a4513a2e2294a001d64dec81677361b7040c9a/grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde", size = 11836482, upload-time = "2025-10-21T16:20:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1c/8374990f9545e99462caacea5413ed783014b3b66ace49e35c533f07507b/grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", size = 6407178, upload-time = "2025-10-21T16:20:32.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/36fd7d7c75a6c12542c90a6d647a27935a1ecaad03e0ffdb7c42db6b04d2/grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990", size = 7075684, upload-time = "2025-10-21T16:20:35.435Z" }, + { url = "https://files.pythonhosted.org/packages/38/f7/e3cdb252492278e004722306c5a8935eae91e64ea11f0af3437a7de2e2b7/grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af", size = 6611133, upload-time = "2025-10-21T16:20:37.541Z" }, + { url = "https://files.pythonhosted.org/packages/7e/20/340db7af162ccd20a0893b5f3c4a5d676af7b71105517e62279b5b61d95a/grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2", size = 7195507, upload-time = "2025-10-21T16:20:39.643Z" }, + { url = "https://files.pythonhosted.org/packages/10/f0/b2160addc1487bd8fa4810857a27132fb4ce35c1b330c2f3ac45d697b106/grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6", size = 8160651, upload-time = "2025-10-21T16:20:42.492Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2c/ac6f98aa113c6ef111b3f347854e99ebb7fb9d8f7bb3af1491d438f62af4/grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3", size = 7620568, upload-time = "2025-10-21T16:20:45.995Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/7852f7e087285e3ac17a2703bc4129fafee52d77c6c82af97d905566857e/grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b", size = 3998879, upload-time = "2025-10-21T16:20:48.592Z" }, + { url = "https://files.pythonhosted.org/packages/10/30/d3d2adcbb6dd3ff59d6ac3df6ef830e02b437fb5c90990429fd180e52f30/grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b", size = 4706892, upload-time = "2025-10-21T16:20:50.697Z" }, + { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, + { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, + { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, + { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, + { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, + { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, + { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, + { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, + { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, + { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, + { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, + { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, + { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -388,6 +462,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -543,6 +629,11 @@ lint = [ { name = "ruff" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] +otel = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, +] testing = [ { name = "gp-libs" }, { name = "pytest" }, @@ -602,6 +693,11 @@ lint = [ { name = "ruff" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] +otel = [ + { name = "opentelemetry-api", specifier = ">=1.20.0" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.20.0" }, +] testing = [ { name = "gp-libs" }, { name = "pytest" }, @@ -643,7 +739,8 @@ name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -841,7 +938,8 @@ name = "myst-parser" version = "5.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -857,6 +955,119 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/9c/3ab1db90f32da200dba332658f2bbe602369e3d19f6aba394031a42635be/opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c", size = 6147, upload-time = "2025-12-11T13:32:40.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/6c/bdc82a066e6fb1dcf9e8cc8d4e026358fe0f8690700cc6369a6bf9bd17a7/opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe", size = 7019, upload-time = "2025-12-11T13:32:19.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/48/b329fed2c610c2c32c9366d9dc597202c9d1e58e631c137ba15248d8850f/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad", size = 24650, upload-time = "2025-12-11T13:32:41.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/a3/cc9b66575bd6597b98b886a2067eea2693408d2d5f39dad9ab7fc264f5f3/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18", size = 19766, upload-time = "2025-12-11T13:32:21.027Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -884,6 +1095,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/b8/cda15d9d46d03d4aa3a67cb6bffe05173440ccf86a9541afaf7ac59a1b6b/protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91", size = 444346, upload-time = "2026-01-12T18:33:40.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/be/24ef9f3095bacdf95b458543334d0c4908ccdaee5130420bf064492c325f/protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", size = 425612, upload-time = "2026-01-12T18:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/31/ad/e5693e1974a28869e7cd244302911955c1cebc0161eb32dfa2b25b6e96f0/protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", size = 436962, upload-time = "2026-01-12T18:33:31.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/15/6ee23553b6bfd82670207ead921f4d8ef14c107e5e11443b04caeb5ab5ec/protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", size = 427612, upload-time = "2026-01-12T18:33:32.646Z" }, + { url = "https://files.pythonhosted.org/packages/2b/48/d301907ce6d0db75f959ca74f44b475a9caa8fcba102d098d3c3dd0f2d3f/protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", size = 324484, upload-time = "2026-01-12T18:33:33.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/1c/e53078d3f7fe710572ab2dcffd993e1e3b438ae71cfc031b71bae44fcb2d/protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", size = 339256, upload-time = "2026-01-12T18:33:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8e/971c0edd084914f7ee7c23aa70ba89e8903918adca179319ee94403701d5/protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", size = 323311, upload-time = "2026-01-12T18:33:36.305Z" }, + { url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532, upload-time = "2026-01-12T18:33:39.199Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1156,7 +1382,8 @@ name = "sphinx" version = "8.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -1208,7 +1435,8 @@ name = "sphinx-autobuild" version = "2025.8.25" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -1244,7 +1472,8 @@ name = "sphinx-autodoc-typehints" version = "3.5.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -1684,3 +1913,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]