From 83e8e91dfaaa4f6bd2727fe1c5f4b3e4eecf0516 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Tue, 14 Jul 2026 07:49:55 -0400 Subject: [PATCH] audit fixes: checked types + curated lint; install-mcp drift guard; strict policy keys; transport credential guard Three fixes from the 2026-07-14 module-by-module audit. 1. Annotations become a CHECKED contract. [tool.ruff.lint] curated select (I/B/UP/C4/SIM/RET/PLE on top of the defaults) + a lenient [tool.mypy] baseline, both in CI. Burn-down: 114 ruff findings (96 auto), 17 mypy errors - several real: pump()'s src annotation claimed TextIO but receives an Iterator from HttpTransport; answer_retrieve indexed the drop store with a possibly-None handle; text_diff reused a loop variable across int/ int|None types; an invalid 'type: ignore' comment in _emit_audit was silently not suppressing anything. 2. install-mcp drift guard. A re-wrap used to rebuild the entry purely from the stashed original, silently reverting hand-edits to the WRAPPED entry (reverted codegraph's node@22 env.PATH pin in production, 2026-07-13). wrap() now carries live non-terse-owned keys forward (command/args still rebuilt; url/headers never resurrected), do_install reports what it preserved, and the CLI prints it with the uninstall caveat. USAGE updated. 3. Hardening pair. - load_policy rejects unknown keys at every level ('_'-prefixed annotations exempt - the policy_gen convention). Immediately caught a latent specimen: a multiproxy test's policy file used 'rules' (schema says 'policies') and the old loader silently ignored it. - HttpTransport refuses credential-bearing headers (authorization/token/ key/secret/cookie) over cleartext http to a non-loopback host - parity with fluency.openai_answerer's TLS guard. Loopback gateways unaffected. 476 tests pass (+5), ruff clean, mypy clean, live policy file verified against the strict loader. --- .github/workflows/tests.yml | 1 + .gitignore | 1 + .runechoguardignore | 3 + USAGE.md | 8 +- pyproject.toml | 26 ++++- src/terse/capture.py | 5 +- src/terse/cli.py | 24 +++-- src/terse/dropeval.py | 11 +- src/terse/fluency.py | 30 ++++-- src/terse/html_report.py | 5 +- src/terse/install_mcp.py | 32 +++++- src/terse/lossy.py | 14 +-- src/terse/measure.py | 7 +- src/terse/multiproxy.py | 41 +++---- src/terse/policy.py | 33 +++++- src/terse/probes.py | 4 +- src/terse/proxy.py | 67 ++++++------ src/terse/terminal_report.py | 18 +++- src/terse/text_diff.py | 10 +- src/terse/tokenize.py | 7 +- src/terse/transforms.py | 2 +- src/terse/transport.py | 34 ++++-- tests/test_fluency.py | 6 +- tests/test_install_mcp.py | 67 ++++++++++++ tests/test_multiproxy.py | 8 +- tests/test_policy.py | 34 ++++++ tests/test_transport.py | 22 +++- uv.lock | 201 +++++++++++++++++++++++++++++++++++ 28 files changed, 588 insertions(+), 133 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d25338..73f67cb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,6 +17,7 @@ jobs: enable-cache: true - run: uv sync --dev # no anthropic extra: keeps CI keyless, matches design - run: uv run ruff check . + - run: uv run mypy # checked annotations (audit fix #1); config in pyproject pytest: runs-on: ubuntu-latest strategy: diff --git a/.gitignore b/.gitignore index 5f87094..a7eb885 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ reports/ # Ad-hoc experiment harnesses (e.g. the fidelity probe) — not part of the lib scratch_fidelity/ .codegraph +.coverage diff --git a/.runechoguardignore b/.runechoguardignore index 3292e3c..19ef642 100644 --- a/.runechoguardignore +++ b/.runechoguardignore @@ -81,3 +81,6 @@ DEFAULT_PROBE_REPORT # probes.py cross_call_overlap docstring: "When `idf` is supplied (from `token_idf`)" — # "supplied (from" reads as a bare call to the guard's pattern match. Not code. supplied +# proxy.py _emit_audit: `audit = self.audit` narrowed local (mypy baseline, audit +# fix #1), called bare — same local-variable false-positive class as real/orig_send. +audit diff --git a/USAGE.md b/USAGE.md index 322756d..d70057a 100644 --- a/USAGE.md +++ b/USAGE.md @@ -137,9 +137,11 @@ install invocation. It honors `$CLAUDE_CONFIG` if your config isn't at `~/.claude.json`. Start with one high-win, read-only server (e.g. `runecho`) and confirm it works before wrapping more. -One sharp edge worth knowing: a re-wrap rebuilds the entry from the **stashed -original**, so any hand-edit you made to the *wrapped* entry (say, an `env.PATH` -pin) is dropped — apply such edits to the stash original too. +Hand-edits to a wrapped entry (say, an `env.PATH` pin) survive re-installs: the +re-wrap rebuilds `command`/`args` from the stashed original but keeps every other +live key, and reports what it carried (`kept hand-edited key(s) …`). One edge +remains: `uninstall-mcp` restores the **pre-terse original**, which does not carry +such edits — put them on the original entry too if they must survive an uninstall. Claude Code has three MCP scopes, and `--scope` targets any of them (default `user`, i.e. today's behavior): diff --git a/pyproject.toml b/pyproject.toml index fd5bd3d..bbaca35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ terse = "terse.cli:main" [dependency-groups] -dev = ["pytest", "ruff"] +dev = ["pytest", "ruff", "mypy"] [build-system] requires = ["hatchling"] @@ -22,3 +22,27 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/terse"] + +[tool.ruff.lint] +# Curated beyond ruff's minimal default (E4/E7/E9/F): import order, real bug +# patterns, modern-syntax upgrades, comprehension and simplification wins. Style-only +# packs are deliberately left out — the gate is for defects, not formatting taste. +select = ["E4", "E7", "E9", "F", "I", "B", "UP", "C4", "SIM", "RET", "PLE"] +ignore = [ + "SIM108", # ternary-vs-if is a readability call, not a defect + "SIM105", # contextlib.suppress: try/except-pass is clearer at fail-open sites +] + +[tool.mypy] +# Lenient baseline (audit fix #1): the goal is to make the existing annotations a +# CHECKED contract, not to win strictness awards on day one. Ratchet later. +python_version = "3.11" +files = ["src/terse"] +warn_unused_ignores = true +warn_redundant_casts = true +no_implicit_optional = true +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = "tiktoken" +ignore_missing_imports = true diff --git a/src/terse/capture.py b/src/terse/capture.py index 60707b4..6444378 100644 --- a/src/terse/capture.py +++ b/src/terse/capture.py @@ -21,8 +21,9 @@ from typing import Any from ._secure_io import append_restricted, write_restricted - -from .transforms import _uniform_dict_list # the one canonical "what tabularize folds" rule +from .transforms import ( + _uniform_dict_list, # the one canonical "what tabularize folds" rule +) # Shape buckets. classify_shape returns one of these. PRETTY_JSON = "pretty-json" diff --git a/src/terse/cli.py b/src/terse/cli.py index e98408c..f708348 100644 --- a/src/terse/cli.py +++ b/src/terse/cli.py @@ -15,15 +15,22 @@ import argparse import json +import json as _json import re import sys +from datetime import UTC from pathlib import Path -import json as _json - from . import transforms from ._secure_io import write_restricted -from .capture import capture_payload, classify_shape, coverage, extract_records, load_corpus +from .capture import ( + capture_payload, + classify_shape, + coverage, + extract_records, + load_corpus, +) +from .html_report import build_html_report from .measure import cross_tokenizer_savings, measure_corpus from .probes import ( cross_call_overlap, @@ -32,7 +39,6 @@ server_of_tool, value_redundancy, ) -from .html_report import build_html_report from .report import ( build_cross_server_probe_report, build_probe_report, @@ -81,14 +87,14 @@ def _record_and_print_trend(history_path: str, rows: list, label: str) -> None: in this command that reads the real clock (principle #31: inject nondeterminism at the edge, not inside the pure summarize_run/build_trend_report/ trend_sparkline_lines core).""" - from datetime import datetime, timezone + from datetime import datetime from .history import append_run, load_history, summarize_run from .report import build_trend_report from .terminal_report import trend_sparkline_lines path = Path(history_path) - ts = datetime.now(timezone.utc).isoformat(timespec="seconds") + ts = datetime.now(UTC).isoformat(timespec="seconds") run = summarize_run(rows, ts, label=label) all_runs = load_history(path) + [run] append_run(path, run) @@ -302,7 +308,7 @@ def _cmd_probe(args: argparse.Namespace) -> int: by_tool.setdefault(env["tool"], []).append(env) for tool, envs in by_tool.items(): envs = sorted(envs, key=lambda e: e.get("sha", "")) - for prev, curr in zip(envs, envs[1:]): + for prev, curr in zip(envs, envs[1:], strict=False): # sliding pairs res = cross_call_overlap(prev["raw"], curr["raw"]) if res.get("available"): overlap_rows.append({"tool": tool, "prev_sha": prev.get("sha", "?"), @@ -604,6 +610,10 @@ def _cmd_install_mcp(args: argparse.Namespace) -> int: print(f"{tag} {c['server']}:") print(f" before: {_short_cmd(c['before'])}") print(f" after: {_short_cmd(c['after'])}") + if c.get("preserved"): + print(f" kept hand-edited key(s) from the live entry: " + f"{', '.join(c['preserved'])} (note: uninstall restores the " + f"pre-terse original, which does NOT carry them)") print(f"config: {res['config']} scope: {res['scope']} policy: {res['policy']}") if res.get("capture_dir"): print(f"capture: raw tool results → {res['capture_dir']}") diff --git a/src/terse/dropeval.py b/src/terse/dropeval.py index e5614e9..a8c428a 100644 --- a/src/terse/dropeval.py +++ b/src/terse/dropeval.py @@ -26,15 +26,16 @@ import json import urllib.request +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Callable, Optional +from typing import Any -from . import capture -from . import fluency +from . import capture, fluency from . import lossy as lossy_mod from . import policy as policy_mod from .proxy import TERSE_PRIMER + # --------------------------------------------------------------------------- # # Tool-loop answerer protocol — the existing fluency.Answerer (system, user) -> str # can't express a tool call; this is a provider-neutral running conversation instead. @@ -94,7 +95,7 @@ def _staged_apply(obj: Any, rule: Any, tool: str) -> tuple[policy_mod.Applied, d def _questions_and_staging( obj: Any, rule: Any, tool: str -) -> tuple[list[DropQuestion], Optional[policy_mod.Applied], Optional[dict[str, Any]]]: +) -> tuple[list[DropQuestion], policy_mod.Applied | None, dict[str, Any] | None]: """Shared core of `gen_drop_questions`: generates the (recall, precision) question pair AND returns the `(applied, staging)` that `_staged_apply` computed along the way, so `run_drop_payload` can reuse them instead of a second `policy.apply()` pass @@ -190,7 +191,7 @@ def gen_drop_questions(obj: Any, rule: Any, tool: str) -> list[DropQuestion]: # --------------------------------------------------------------------------- # # The 2-turn tool-loop driver — mirrors the real proxy's retrieve protocol exactly # --------------------------------------------------------------------------- # -def _miss_text(handle: str) -> str: +def _miss_text(handle: Any) -> str: """The exact miss string `proxy.Interceptor.answer_retrieve` emits for an unresolved handle, copied verbatim so this eval's miss-handling matches production behavior — a model that has learned to recover from a real miss must see the same words here.""" diff --git a/src/terse/fluency.py b/src/terse/fluency.py index 048650b..1cff295 100644 --- a/src/terse/fluency.py +++ b/src/terse/fluency.py @@ -34,15 +34,23 @@ import json import re import urllib.request -from urllib.parse import urlsplit +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any +from urllib.parse import urlsplit from . import text_diff from .capture import LONG_TEXT, OTHER, classify_shape, extract_records from .tokenize import count_cl100k -from .transforms import (compress, compress_structure, dict_encode, diff_wire, - has_terse_marker, minify, _uniform_dict_list) +from .transforms import ( + _uniform_dict_list, + compress, + compress_structure, + dict_encode, + diff_wire, + has_terse_marker, + minify, +) # Loopback hosts where cleartext http is safe (never leaves the machine), so a Bearer # key over http to one of these is fine — a local LiteLLM/CCR gateway is a common setup. @@ -187,7 +195,7 @@ def _records_of(lst: Any) -> list[str] | None: # dict-map of parent records -> the first parent (map order) with a child record list if isinstance(v, dict) and v and all(isinstance(pv, dict) for pv in v.values()): for pkey, parent in v.items(): - for ck, cv in parent.items(): + for _ck, cv in parent.items(): if _records_of(cv): return f"{k}[{json.dumps(pkey, ensure_ascii=False)}]", cv, _intersection_cols(cv) # a NON-uniform top-level list of dicts (uniform ones are extract_records' domain) @@ -561,7 +569,7 @@ def _iter_consecutive_pairs(envelopes: list[dict]): by_tool.setdefault(env["tool"], []).append(env) for tool, envs in by_tool.items(): envs = sorted(envs, key=lambda e: e.get("sha", "")) - for prev_env, curr_env in zip(envs, envs[1:]): + for prev_env, curr_env in zip(envs, envs[1:], strict=False): # sliding pairs yield tool, curr_env.get("sha", "?"), prev_env["raw"], curr_env["raw"] @@ -627,7 +635,7 @@ def build_chain_windows(envelopes: list[dict], max_depth: int = 5, except (json.JSONDecodeError, TypeError): seq.append((e.get("sha", "?"), None)) cur: list[tuple] = [] - for (psha, p), (csha, c) in zip(seq, seq[1:]): + for (psha, p), (csha, c) in zip(seq, seq[1:], strict=False): # sliding pairs ok = (p is not None and c is not None and diff_wire(p, c, tool) is not None) if ok: @@ -643,9 +651,9 @@ def build_chain_windows(envelopes: list[dict], max_depth: int = 5, windows: list[tuple] = [] for depth in range(1, max_depth + 1): # every eligible start per tool, spread over its runs; then round-robin - candidates: dict[str, list[tuple]] = {} + candidates: dict[str, list[list[tuple]]] = {} for tool, tool_runs in sorted(runs.items()): - starts: list[tuple] = [] + starts: list[list[tuple]] = [] for run in tool_runs: for i in range(0, len(run) - depth, depth): # non-overlapping window = run[i:i + depth + 1] @@ -656,7 +664,7 @@ def build_chain_windows(envelopes: list[dict], max_depth: int = 5, picked = 0 idx = 0 while picked < per_depth_cap and candidates: - for tool in sorted(list(candidates)): + for tool in sorted(candidates): starts = candidates[tool] if idx >= len(starts): del candidates[tool] @@ -682,7 +690,7 @@ def run_chain_payload(objs: list, answerer: Answerer, tool: str = "", if not questions: return [] wires: list[str] = [] - for prev, curr in zip(objs, objs[1:]): + for prev, curr in zip(objs, objs[1:], strict=False): # sliding pairs wire = diff_wire(prev, curr, tool) if wire is None: return [] diff --git a/src/terse/html_report.py b/src/terse/html_report.py index 6fc18fa..9b21c5d 100644 --- a/src/terse/html_report.py +++ b/src/terse/html_report.py @@ -17,6 +17,7 @@ from __future__ import annotations import html as _html +from collections.abc import Sequence from typing import Any from .report import _ci, _form_stats, _pct, _sum, _worst_case_gap @@ -147,7 +148,7 @@ def diverging_bar_chart(items: list[tuple[str, float]], unit: str = "%") -> str: return "".join(out) -def stacked_bar_chart(items: list[tuple[str, list[float]]], +def stacked_bar_chart(items: Sequence[tuple[str, Sequence[float]]], series_labels: tuple[str, ...], series_colors: tuple[str, ...] = ("var(--series-1)", "var(--series-2)", "var(--series-3)") ) -> str: @@ -187,7 +188,7 @@ def sx(v: float) -> float: zero_x = sx(0.0) out = [_svg_open(width, height)] lx, ly = label_w, 14 - for name, color in zip(series_labels, series_colors): + for name, color in zip(series_labels, series_colors, strict=True): out.append(f'') out.append(f'{_esc(name)}') lx += 18 + 9 * len(name) diff --git a/src/terse/install_mcp.py b/src/terse/install_mcp.py index 9fd728c..14e7e74 100644 --- a/src/terse/install_mcp.py +++ b/src/terse/install_mcp.py @@ -149,7 +149,9 @@ def wrap(config: dict, stash: dict, server: str, policy: str, """Wrap `server`'s entry with the terse proxy. Idempotent: if already managed (present in stash), re-wrap from the stashed original so policy/cmd updates apply cleanly without nesting proxies. Preserves all non-command/args (and, for a - URL entry, non-url/headers) keys (env, cwd, type, …) of the original entry. With + URL entry, non-url/headers) keys (env, cwd, type, …) of the original entry — and, + on a re-wrap, hand-edits made to those keys on the LIVE wrapped entry win over the + stashed original's values (the drift guard below). With `capture_dir`, the wrapped proxy tees raw tool results into that corpus for later measurement (#32). `diff` is tri-state: None writes no flag (the entry inherits the proxy default — ON since #75), True/False bake `--diff`/`--no-diff` into the @@ -161,10 +163,11 @@ def wrap(config: dict, stash: dict, server: str, policy: str, forwarded as repeated `--header k=v` (see `transport.HttpTransport`). Anything with neither key is not a valid MCP server entry and can't be wrapped.""" servers = config.setdefault("mcpServers", {}) + live = servers.get(server) if server in stash: original = stash[server] - elif server in servers: - original = servers[server] + elif live is not None: + original = live stash[server] = original else: raise KeyError(server) @@ -199,6 +202,18 @@ def wrap(config: dict, stash: dict, server: str, policy: str, if k not in ("command", "args", "url", "headers")} new_entry["command"] = terse_cmd[0] new_entry["args"] = [*terse_cmd[1:], "proxy", *proxy_opts, *header_opts, "--", orig_url] + + if live is not None and live is not original: + # Drift guard: a re-wrap used to rebuild the entry purely from the stashed + # original, silently reverting any hand-edit made to the WRAPPED entry since + # the last install (a scoped env.PATH pin, a cwd) — that reverted codegraph's + # node pin in production on 2026-07-13. command/args are terse-owned and always + # rebuilt (flags must reflect this invocation); url/headers never appear on a + # wrapped entry (they're folded into args), so a drifted live copy of them must + # not be resurrected either. Everything else on the live entry wins. + for k, v in live.items(): + if k not in ("command", "args", "url", "headers"): + new_entry[k] = v servers[server] = new_entry return config, stash @@ -306,10 +321,19 @@ def do_install(servers: list[str], policy: str, *, dry_run: bool = False, changes = [] for s in servers: before = (node.get("mcpServers") or {}).get(s) + # Hand-edits = non-terse-owned keys on the live WRAPPED entry that differ from + # the stashed original — the drift guard in wrap() carries them forward; name + # them in the result so the operator sees what survived (and what to move into + # the original entry if it should also survive an uninstall). + preserved = sorted( + k for k in (before or {}) + if k not in ("command", "args", "url", "headers") + and s in stash and (before or {}).get(k) != stash[s].get(k) + ) wrap(node, stash, s, policy_abs, terse_cmd, capture_dir=capture_abs, diff=diff, diff_keyframe_interval=diff_keyframe_interval) changes.append({"server": s, "before": before, - "after": node["mcpServers"][s]}) + "after": node["mcpServers"][s], "preserved": preserved}) result = {"config": str(target.cfg), "scope": scope, "policy": policy_abs, "available": available, "changes": changes, "dry_run": dry_run, diff --git a/src/terse/lossy.py b/src/terse/lossy.py index 29bd63d..b9f25cd 100644 --- a/src/terse/lossy.py +++ b/src/terse/lossy.py @@ -25,7 +25,9 @@ import hashlib import json -from typing import Any, Callable, Iterator +from collections.abc import Callable, Iterator +from functools import partial +from typing import Any from .transforms import DROPPED_MARKER as DROP_KEY @@ -103,7 +105,7 @@ def _copy_at(dst: Any, src: Any, tokens: list[str]) -> Any: if head == "[]": if not (isinstance(dst, list) and isinstance(src, list) and len(dst) == len(src)): raise PathError("list length/shape mismatch at '[]'") - return [_copy_at(d, s, rest) for d, s in zip(dst, src)] + return [_copy_at(d, s, rest) for d, s in zip(dst, src, strict=True)] # len-checked above if not (isinstance(dst, dict) and isinstance(src, dict)): raise PathError(f"object mismatch at {head!r}") if head not in dst: @@ -122,7 +124,7 @@ def _leaf_pairs(orig: Any, out: Any, tokens: list[str]) -> Iterator[tuple[Any, A if head == "[]": if not (isinstance(orig, list) and isinstance(out, list) and len(orig) == len(out)): raise PathError("list length/shape mismatch at '[]'") - for o, t in zip(orig, out): + for o, t in zip(orig, out, strict=True): # len-checked above yield from _leaf_pairs(o, t, rest) return if not (isinstance(orig, dict) and isinstance(out, dict)): @@ -149,7 +151,7 @@ def apply_lossy(obj: Any, rule: Any) -> Any: out = obj for path, spec in _truncate_specs(rule): max_len = int(spec.get("max", DEFAULT_MAX)) - out = _apply_at(out, _parse_path(path), lambda v, m=max_len: _truncate(v, m)) + out = _apply_at(out, _parse_path(path), partial(_truncate, max_len=max_len)) return out @@ -188,7 +190,7 @@ def _handle(tool: str, path: str, serialized: str) -> str: """Content-addressed handle for a dropped value. Includes tool+path so identical bytes under different fields get distinct handles (clearer provenance), and is stable across runs (no RNG) so the same value dropped twice reuses one store slot.""" - digest = hashlib.sha1(f"{tool}\x00{path}\x00{serialized}".encode("utf-8")).hexdigest() + digest = hashlib.sha1(f"{tool}\x00{path}\x00{serialized}".encode()).hexdigest() return digest[:HANDLE_LEN] @@ -223,7 +225,7 @@ def apply_drops(obj: Any, rule: Any, tool: str, sink: Callable[[str, Any], None] for path, spec in _drop_specs(rule): min_len = int(spec.get("min", DEFAULT_DROP_MIN)) out = _apply_at(out, _parse_path(path), - lambda v, _p=path, _m=min_len: _drop(v, tool, _p, _m, sink)) + partial(_drop, tool=tool, path=path, min_len=min_len, sink=sink)) return out diff --git a/src/terse/measure.py b/src/terse/measure.py index 95ff606..b87bc04 100644 --- a/src/terse/measure.py +++ b/src/terse/measure.py @@ -19,7 +19,7 @@ from __future__ import annotations import json -from typing import Any, Optional +from typing import Any from . import transforms from .capture import classify_shape @@ -54,10 +54,10 @@ def measure_payload(raw: str) -> dict[str, Any]: tab_tok = count_cl100k(tabular) cmp_tok = count_cl100k(compressed) - def _saved(a: Optional[int], b: Optional[int]) -> Optional[int]: + def _saved(a: int | None, b: int | None) -> int | None: return None if a is None or b is None else a - b - row = { + return { "shape": shape, "applicable": True, "roundtrip_ok": gate, @@ -69,7 +69,6 @@ def _saved(a: Optional[int], b: Optional[int]) -> Optional[int]: "tier_total": _saved(raw_tok, cmp_tok), }, } - return row def cross_tokenizer_savings(envelopes: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/src/terse/multiproxy.py b/src/terse/multiproxy.py index 2fb9724..0bf8b2e 100644 --- a/src/terse/multiproxy.py +++ b/src/terse/multiproxy.py @@ -56,10 +56,11 @@ import threading import time from collections import OrderedDict +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from threading import Lock, Thread -from typing import Any, Callable, Optional, TextIO +from typing import Any, TextIO from . import lossy as lossy_mod from . import policy as policy_mod @@ -150,7 +151,7 @@ class DownstreamSpec: name: str target: list[str] # a stdio command, or a single-element [url] headers: dict[str, str] - policy_path: Optional[str] # resolved relative to the config file; None = use the default + policy_path: str | None # resolved relative to the config file; None = use the default def load_multi_config(path: str) -> list[DownstreamSpec]: @@ -244,7 +245,7 @@ class _PendingBroadcast: client_id: Any remaining: set[int] parts: dict[int, dict] = field(default_factory=dict) - timer: Optional[threading.Timer] = None + timer: threading.Timer | None = None done: bool = False @@ -268,7 +269,7 @@ class _PeerSender: def __init__(self, transport: Transport, debug: bool = False): self._transport = transport self._debug = debug - self._q: "queue.Queue[Any]" = queue.Queue() + self._q: queue.Queue[Any] = queue.Queue() self._thread = Thread(target=self._run, daemon=True) self._thread.start() @@ -338,13 +339,13 @@ def __init__(self, peers: list[Peer], out: TextIO, out_lock: Lock, *, # went out must still resolve here and be swallowed (see _maybe_collect), not # fall through to a peer's Interceptor as an unsolicited message. Bounded by # _LOCAL_ID_MAP_MAX eviction instead of eager per-broadcast cleanup. - self._local_id_map: "OrderedDict[Any, int]" = OrderedDict() + self._local_id_map: OrderedDict[Any, int] = OrderedDict() # router-local id -> (peer_idx, original id), for a server-initiated request # forwarded to the client (see _rewrite_server_request) so the client's eventual # reply can be routed back to the peer that actually asked, with its original id # restored — instead of the prior v1 gap (misdelivered to peer 0). Bounded by # _SERVER_REQ_MAX eviction. - self._server_requests: "OrderedDict[str, tuple[int, Any]]" = OrderedDict() + self._server_requests: OrderedDict[str, tuple[int, Any]] = OrderedDict() self._server_req_seq = 0 # client id -> Timer, for a routed (single-peer) tools/call awaiting reply — # mirrors _broadcast's BROADCAST_TIMEOUT guarantee ("a dead peer can't wedge @@ -357,7 +358,7 @@ def __init__(self, peers: list[Peer], out: TextIO, out_lock: Lock, *, # monotonic time that happened — the peer's eventual real (late) reply must be # swallowed here, not double-delivered. Aged out by `_routed_timed_out_ttl`, not # by population count (see `_ROUTED_TIMED_OUT_MAX`'s comment above). - self._routed_timed_out: "OrderedDict[Any, float]" = OrderedDict() + self._routed_timed_out: OrderedDict[Any, float] = OrderedDict() self._routed_timed_out_ttl = broadcast_timeout * 4 # ---------- client -> server ---------- @@ -846,7 +847,7 @@ def _merge_initialize(self, pb: _PendingBroadcast) -> dict: meaningful). `instructions` = ONE `TERSE_PRIMER`, first, then each peer's own non-empty instructions (skipping one that already carries the primer, so a peer that is ITSELF a terse proxy doesn't duplicate it).""" - protocol_version: Optional[str] = None + protocol_version: str | None = None capabilities: dict = {} instructions_parts: list[str] = [] # Iterate pb.parts in ITS OWN (insertion) order, not range(len(self.peers)) @@ -929,11 +930,11 @@ def _scatter_first_success(self, pb: _PendingBroadcast) -> dict: def _build_peers(specs: list[DownstreamSpec], default_policy: policy_mod.Policy, *, - debug: bool, capture: Optional[Callable[[str, str], None]], - audit: Optional[Callable[[dict], None]], - store: "OrderedDict[str, Any]", store_lock: Lock, - dropped_bytes: list[int], diff_override: Optional[bool] = None, - diff_keyframe_override: Optional[int] = None) -> list[Peer]: + debug: bool, capture: Callable[[str, str], None] | None, + audit: Callable[[dict], None] | None, + store: OrderedDict[str, Any], store_lock: Lock, + dropped_bytes: list[int], diff_override: bool | None = None, + diff_keyframe_override: int | None = None) -> list[Peer]: """Build every `Peer`: its own `Transport` (stdio or HTTP, via `build_transport`) and its own `Interceptor` (per-peer diff/compress state, but the drop store — including its byte-eviction counter — is injected shared). Raises on a bad spec — @@ -973,13 +974,13 @@ def run_multi_proxy( default_policy: policy_mod.Policy, *, debug: bool = False, - stdin: Optional[TextIO] = None, - stdout: Optional[TextIO] = None, - capture_dir: Optional[str] = None, - debug_log: Optional[str] = None, + stdin: TextIO | None = None, + stdout: TextIO | None = None, + capture_dir: str | None = None, + debug_log: str | None = None, broadcast_timeout: float = BROADCAST_TIMEOUT, - diff_override: Optional[bool] = None, - diff_keyframe_override: Optional[int] = None, + diff_override: bool | None = None, + diff_keyframe_override: int | None = None, ) -> int: """Load `config_path`, build one `Peer` per downstream (own `Transport` + own `Interceptor`, all sharing one drop store), spawn one `pump()` reader thread per @@ -1008,7 +1009,7 @@ def run_multi_proxy( capture, audit = _build_capture_and_audit(capture_dir, debug_log, debug, "[terse-multiproxy]") - store: "OrderedDict[str, Any]" = OrderedDict() + store: OrderedDict[str, Any] = OrderedDict() store_lock = Lock() dropped_bytes: list[int] = [0] diff --git a/src/terse/policy.py b/src/terse/policy.py index 9571ed2..fa166e5 100644 --- a/src/terse/policy.py +++ b/src/terse/policy.py @@ -110,17 +110,42 @@ def _coerce_tiers(raw: Any, where: str) -> tuple[str, ...]: return tuple(raw) +# The keys load_policy understands, per level. Anything else (except an "_"-prefixed +# comment/annotation key, the convention policy_gen's `_comment`/`_suggested_fields*` +# already use) is rejected loudly: this file governs what gets rewritten on the wire, +# so a typo'd key ("polices", "diff_keyframe_intervall") silently reverting to default +# behavior is a trap, not a convenience. +_TOP_KEYS = frozenset({"version", "defaults", "policies", "diff", "diff_keyframe_interval"}) +_DEFAULTS_KEYS = frozenset({"tiers"}) +_RULE_KEYS = frozenset({"match", "tiers", "fields"}) +_MATCH_KEYS = frozenset({"tool"}) + + +def _reject_unknown_keys(obj: Any, allowed: frozenset[str], where: str) -> None: + if not isinstance(obj, dict): + raise ValueError(f"{where}: must be an object, got {type(obj).__name__}") + unknown = sorted(k for k in obj if k not in allowed and not k.startswith("_")) + if unknown: + raise ValueError(f"{where}: unknown key(s) {unknown}; allowed: {sorted(allowed)} " + "(prefix a key with '_' for a comment/annotation)") + + def load_policy(path: str | Path) -> Policy: - """Parse + validate a JSON policy file. Raises ValueError on a malformed policy.""" + """Parse + validate a JSON policy file. Raises ValueError on a malformed policy — + including any unknown key (see `_reject_unknown_keys`): fail loudly on a typo + rather than silently running with default behavior.""" doc = json.loads(Path(path).read_text(encoding="utf-8")) + _reject_unknown_keys(doc, _TOP_KEYS, str(path)) if doc.get("version") != 1: raise ValueError(f"unsupported policy version: {doc.get('version')!r} (expected 1)") - default_tiers = _coerce_tiers( - doc.get("defaults", {}).get("tiers", list(VALID_TIERS)), "defaults" - ) + defaults = doc.get("defaults", {}) + _reject_unknown_keys(defaults, _DEFAULTS_KEYS, "defaults") + default_tiers = _coerce_tiers(defaults.get("tiers", list(VALID_TIERS)), "defaults") rules: list[Rule] = [] for i, r in enumerate(doc.get("policies", [])): + _reject_unknown_keys(r, _RULE_KEYS, f"policies[{i}]") match = r.get("match", {}) + _reject_unknown_keys(match, _MATCH_KEYS, f"policies[{i}].match") glob = match.get("tool", "*") rules.append(Rule(tool_glob=glob, tiers=_coerce_tiers(r.get("tiers", []), f"policies[{i}]"), fields=r.get("fields", {}))) diff --git a/src/terse/probes.py b/src/terse/probes.py index 18916be..83e7a7a 100644 --- a/src/terse/probes.py +++ b/src/terse/probes.py @@ -23,8 +23,8 @@ from itertools import combinations from typing import Any -from .transforms import minify from .tokenize import count_cl100k, encode_cl100k +from .transforms import minify # Reverse-map a captured tool name to its origin server. The capture envelope stores # only `tool` (#64 Phase 0): the corpus was captured by separate per-server proxies @@ -234,7 +234,7 @@ def cross_server_overlap( capped = capped or len(la) > cap_per_pair or len(lb) > cap_per_pair sa = sorted(la, key=lambda t: t[0])[:cap_per_pair] sb = sorted(lb, key=lambda t: t[0])[:cap_per_pair] - for (sha_a, raw_a), (sha_b, raw_b) in zip(sa, sb): + for (sha_a, raw_a), (sha_b, raw_b) in zip(sa, sb, strict=False): # pair up to shorter res = cross_call_overlap(raw_a, raw_b, idf=idf) if res.get("available"): rows.append({"server_a": a, "server_b": b, "sha_a": sha_a, "sha_b": sha_b, **res}) diff --git a/src/terse/proxy.py b/src/terse/proxy.py index d87a5f8..63d9fe2 100644 --- a/src/terse/proxy.py +++ b/src/terse/proxy.py @@ -27,13 +27,13 @@ import subprocess import sys from collections import OrderedDict +from collections.abc import Callable, Iterable from threading import Lock, Thread -from typing import Any, Callable, Optional, TextIO +from typing import Any, TextIO from . import lossy as lossy_mod from . import policy as policy_mod -from . import text_diff -from . import transforms +from . import text_diff, transforms from .tokenize import count_cl100k from .transport import HttpTransport, build_transport @@ -117,11 +117,11 @@ class Interceptor: DROPPED_MAX_BYTES = 8 << 20 # 8 MiB def __init__(self, pol: policy_mod.Policy, debug: bool = False, - capture: Optional[Callable[[str, str], None]] = None, - audit: Optional[Callable[[dict], None]] = None, - store: Optional["OrderedDict[str, Any]"] = None, - store_lock: Optional[Lock] = None, - dropped_bytes: Optional[list[int]] = None): + capture: Callable[[str, str], None] | None = None, + audit: Callable[[dict], None] | None = None, + store: OrderedDict[str, Any] | None = None, + store_lock: Lock | None = None, + dropped_bytes: list[int] | None = None): self.policy = pol # id -> (policy_tool, capture_tool): policy_tool drives compression/policy-tier # lookup and MUST be the bare name the policy's rules match against; capture_tool @@ -164,7 +164,7 @@ def __init__(self, pol: policy_mod.Policy, debug: bool = False, # which peer answers it. Default (None) is 100% behavior-preserving for every # existing single-peer caller: a fresh private OrderedDict + Lock, exactly as # before this parameter existed. - self.dropped: "OrderedDict[str, Any]" = store if store is not None else OrderedDict() + self.dropped: OrderedDict[str, Any] = store if store is not None else OrderedDict() # `dropped_bytes` (#5 Half B): a 1-element box, not a plain int, specifically so # it can be SHARED the same way `store` is. `self.dropped` can be one dict shared # across N Interceptors, but a plain `self._dropped_bytes = 0` would still be @@ -203,7 +203,7 @@ def __init__(self, pol: policy_mod.Policy, debug: bool = False, # for `_local_lock` while still holding `_store_lock`. self._store_lock = store_lock if store_lock is not None else Lock() - def note_request(self, line: str, *, tool_name: Optional[str] = None) -> None: + def note_request(self, line: str, *, tool_name: str | None = None) -> None: """Record id -> tool name for tools/call requests, and the initialize request id (so its reply can carry the format primer). Side-effect only. @@ -415,7 +415,7 @@ def _compress_or_diff(self, block: dict, tool: str) -> bool: return True return False - def _augment_initialize(self, msg: dict) -> Optional[str]: + def _augment_initialize(self, msg: dict) -> str | None: """Prepend the terse format primer to the initialize result's `instructions` (#13), preserving any the downstream server set. Idempotent. Returns the reserialized line, or None to forward unchanged.""" @@ -432,7 +432,7 @@ def _augment_initialize(self, msg: dict) -> Optional[str]: "initialize.instructions\n") return json.dumps(msg, separators=(",", ":"), ensure_ascii=False) - def _diff_wire(self, prev: Any, curr: Any, tool: str) -> Optional[str]: + def _diff_wire(self, prev: Any, curr: Any, tool: str) -> str | None: """The on-the-wire diff envelope, or None if no lossless diff applies. Self- describing: it names the prior result (already in the model's context) and carries the changes inline, so the model reconstructs without an out-of-band @@ -466,7 +466,7 @@ def _text_diff_or_store(self, block: dict, tool: str, text: str) -> bool: self.last_text[tool] = text return changed - def _text_diff_wire(self, prev: str, curr: str, tool: str) -> Optional[str]: + def _text_diff_wire(self, prev: str, curr: str, tool: str) -> str | None: """Fail-open wrapper mirroring `_diff_wire`, for the CDC text-diff codec.""" try: return text_diff.text_diff_wire(prev, curr, tool) @@ -507,7 +507,7 @@ def _drop_put(self, handle: str, value: Any) -> None: _, evicted = self.dropped.popitem(last=False) self._dropped_bytes_box[0] -= len(lossy_mod._serialize(evicted)) - def _inject_retrieve_tool(self, msg: dict) -> Optional[str]: + def _inject_retrieve_tool(self, msg: dict) -> str | None: """If `msg` is a tools/list result, append the synthetic terse.retrieve tool so the model can fetch a drop-to-retrieve field back by handle (#10). Idempotent. Returns the reserialized line, or None to forward unchanged (not a tools/list, or already @@ -525,7 +525,7 @@ def _inject_retrieve_tool(self, msg: dict) -> Optional[str]: sys.stderr.write(f"[terse-proxy] injected {lossy_mod.RETRIEVE_TOOL} into tools/list\n") return json.dumps(msg, separators=(",", ":"), ensure_ascii=False) - def answer_retrieve(self, line: str) -> Optional[str]: + def answer_retrieve(self, line: str) -> str | None: """If `line` is a client tools/call for the synthetic terse.retrieve tool, produce the JSON-RPC reply here — from the drop store — instead of forwarding it downstream, which has no such tool (#10). Returns the reply line to write back to the client, or None if @@ -542,6 +542,8 @@ def answer_retrieve(self, line: str) -> Optional[str]: return None mid = msg.get("id") handle = (params.get("arguments") or {}).get("handle") + if not isinstance(handle, str): + handle = "" # a malformed/absent handle can only ever be a miss below value = None with self._store_lock: hit = handle in self.dropped @@ -565,7 +567,7 @@ def answer_retrieve(self, line: str) -> Optional[str]: def _emit_audit(self, tool: str, mid: Any, raw_texts: list[str], text_blocks: list[dict], changed: bool, *, - display_tool: Optional[str] = None) -> None: + display_tool: str | None = None) -> None: """Hand the audit callback one replay record per result (#23). Strictly a side effect: any error is swallowed so an audit-log write can never change what the client receives — same fail-open contract as capture. @@ -582,10 +584,13 @@ def _emit_audit(self, tool: str, mid: Any, raw_texts: list[str], "tiers": list(self.policy.select(tool).tiers), "changed": changed, "blocks": [{"raw": raw, "emitted": b["text"]} - for raw, b in zip(raw_texts, text_blocks)], + for raw, b in zip(raw_texts, text_blocks, strict=True)], } + audit = self.audit + if audit is None: + return # caller already gates on this; kept for local type-narrowing too try: - self.audit(record) # type: ignore[misc] — only called when set + audit(record) except Exception as exc: # noqa: BLE001 — audit is never load-bearing if self.debug: sys.stderr.write(f"[terse-proxy] {shown_tool}: audit skipped: {exc}\n") @@ -597,8 +602,8 @@ def _emit_audit(self, tool: str, mid: Any, raw_texts: list[str], SWALLOW: Any = object() -def pump(src: TextIO, dst: TextIO, transform: Callable[[str], Any], - lock: "Optional[Lock]" = None) -> None: +def pump(src: Iterable[str], dst: Any, transform: Callable[[str], Any], + lock: Lock | None = None) -> None: """Read lines from src, apply transform, write to dst with a single trailing newline. transform returns: a string to write, None to forward the line unchanged, or SWALLOW to write nothing (the transform handled it out-of-band). Stops at EOF. With `lock`, each @@ -622,7 +627,7 @@ def pump(src: TextIO, dst: TextIO, transform: Callable[[str], Any], dst.flush() -def stdio_transport_error(cmd: list[str]) -> Optional[str]: +def stdio_transport_error(cmd: list[str]) -> str | None: """Return a clear error if `cmd` can't be a proxy downstream target at all, else None (#19). Currently the only such case is nothing given after `--`. A URL is no longer rejected here — `transport.build_transport` dispatches a single `"://"` @@ -633,7 +638,7 @@ def stdio_transport_error(cmd: list[str]) -> Optional[str]: return None -def _terminate_child(proc: "subprocess.Popen[Any]", timeout: float = 2.0) -> None: +def _terminate_child(proc: subprocess.Popen[Any], timeout: float = 2.0) -> None: """Reap the downstream server if it is still running, so it shares the proxy's lifecycle and is never orphaned (#21). SIGTERM first, then SIGKILL on timeout.""" if proc.poll() is not None: @@ -700,15 +705,15 @@ def _restore_sigterm(token: Any) -> None: def _build_capture_and_audit( - capture_dir: Optional[str], debug_log: Optional[str], debug: bool, log_prefix: str -) -> tuple[Optional[Callable[[str, str], None]], Optional[Callable[[dict], None]]]: + capture_dir: str | None, debug_log: str | None, debug: bool, log_prefix: str +) -> tuple[Callable[[str, str], None] | None, Callable[[dict], None] | None]: """Build the (capture, audit) callback pair from --capture-dir/--debug-log, shared by `run_proxy` and `multiproxy.run_multi_proxy` (identical logic, differing only in which process's downstream target they're wired to). Both callbacks are strictly side effects — a read-only or full disk must never break the proxy — so a failure is swallowed with only a --debug-gated stderr line, tagged with `log_prefix` (e.g. `"[terse-proxy]"` vs `"[terse-multiproxy]"`) so the failure's origin stays legible.""" - capture: Optional[Callable[[str, str], None]] = None + capture: Callable[[str, str], None] | None = None if capture_dir is not None: from .capture import capture_payload @@ -719,7 +724,7 @@ def capture(tool: str, raw: str) -> None: if debug: sys.stderr.write(f"{log_prefix} capture_payload failed: {exc}\n") - audit: Optional[Callable[[dict], None]] = None + audit: Callable[[dict], None] | None = None if debug_log is not None: from .capture import append_audit @@ -737,11 +742,11 @@ def run_proxy( cmd: list[str], pol: policy_mod.Policy, debug: bool = False, - stdin: Optional[TextIO] = None, - stdout: Optional[TextIO] = None, - capture_dir: Optional[str] = None, - debug_log: Optional[str] = None, - headers: Optional[dict[str, str]] = None, + stdin: TextIO | None = None, + stdout: TextIO | None = None, + capture_dir: str | None = None, + debug_log: str | None = None, + headers: dict[str, str] | None = None, ) -> int: """Launch the downstream MCP peer `cmd` and proxy JSON-RPC through `Interceptor`. `cmd` is either a stdio launch command, or a single-element list holding a URL — in diff --git a/src/terse/terminal_report.py b/src/terse/terminal_report.py index 365b609..0d279f1 100644 --- a/src/terse/terminal_report.py +++ b/src/terse/terminal_report.py @@ -13,9 +13,17 @@ import os import sys +from collections.abc import Sequence from typing import Any -from .report import _GAP_TOLERANCE, _ci, _sum, diff_gap_rows, dropeval_gap_rows, fluency_gap_rows +from .report import ( + _GAP_TOLERANCE, + _ci, + _sum, + diff_gap_rows, + dropeval_gap_rows, + fluency_gap_rows, +) _BAR_WIDTH = 24 _BLOCK = "█" @@ -55,7 +63,7 @@ def diverging_bar_lines(items: list[tuple[str, float]], unit: str = "%", return "\n".join(lines) -def stacked_bar_lines(items: list[tuple[str, list[float]]], series_labels: tuple[str, ...], +def stacked_bar_lines(items: Sequence[tuple[str, Sequence[float]]], series_labels: tuple[str, ...], series_sgr: tuple[str, ...] = ("34", "32", "33"), color: bool | None = None) -> str: """One row per item: proportional multi-color bar across series_labels, sized by @@ -69,13 +77,13 @@ def stacked_bar_lines(items: list[tuple[str, list[float]]], series_labels: tuple enabled = _color_enabled() if color is None else color label_w = min(max((len(label) for label, _ in items), default=0), 28) legend = " " + " ".join( - f"{_c(sgr, _BLOCK, enabled)} {name}" for sgr, name in zip(series_sgr, series_labels) + f"{_c(sgr, _BLOCK, enabled)} {name}" for sgr, name in zip(series_sgr, series_labels, strict=True) ) lines = [legend] for label, vals in items: denom = sum(abs(v) for v in vals) or 1 segs, used = [], 0 - for sgr, v in zip(series_sgr, vals): + for sgr, v in zip(series_sgr, vals, strict=True): n = round(abs(v) / denom * _BAR_WIDTH) used += n glyph = _BLOCK if v >= 0 else _NEG_BLOCK @@ -136,7 +144,7 @@ def trend_sparkline_lines(runs: list[dict[str, Any]]) -> str: regressing" without reading report.build_trend_report's full table. A flat reading (all bars level) with real historical data is itself a legitimate, useful signal (a stable win), not a sign something's broken.""" - pcts = [r.get("saved_pct") for r in runs if r.get("saved_pct") is not None] + pcts = [float(r["saved_pct"]) for r in runs if r.get("saved_pct") is not None] if len(pcts) < 2: return " (need at least two --history runs to show a trend)" lo, hi = min(pcts), max(pcts) diff --git a/src/terse/text_diff.py b/src/terse/text_diff.py index a4c1bcc..1f6792a 100644 --- a/src/terse/text_diff.py +++ b/src/terse/text_diff.py @@ -107,13 +107,13 @@ def _flush() -> None: literal.clear() for ch in curr_chunks: - idx = by_fp.get(_fingerprint(ch)) - if idx is not None and prev_chunks[idx] == ch: + ref = by_fp.get(_fingerprint(ch)) + if ref is not None and prev_chunks[ref] == ch: _flush() - if ops and ops[-1][0] == "=" and ops[-1][2] == idx - 1: - ops[-1][2] = idx + if ops and ops[-1][0] == "=" and ops[-1][2] == ref - 1: + ops[-1][2] = ref else: - ops.append(["=", idx, idx]) + ops.append(["=", ref, ref]) continue literal.append(ch) _flush() diff --git a/src/terse/tokenize.py b/src/terse/tokenize.py index f26ebd3..c044dc5 100644 --- a/src/terse/tokenize.py +++ b/src/terse/tokenize.py @@ -8,7 +8,6 @@ from __future__ import annotations from functools import lru_cache -from typing import Optional CL100K = "cl100k_base" # GPT-3.5/4 — headroom-eval parity O200K = "o200k_base" # GPT-4o — second, very different vocab for invariance checks @@ -24,19 +23,19 @@ def _enc(name: str): return None -def count(text: str, encoding: str = CL100K) -> Optional[int]: +def count(text: str, encoding: str = CL100K) -> int | None: """Token count under a named tiktoken encoding, or None if unavailable.""" enc = _enc(encoding) return len(enc.encode(text)) if enc is not None else None -def count_cl100k(text: str) -> Optional[int]: +def count_cl100k(text: str) -> int | None: """cl100k_base token count. NOT the consumer tokenizer (no public Claude tokenizer exists); see the cross-tokenizer invariance check for robustness.""" return count(text, CL100K) -def encode_cl100k(text: str) -> Optional[list[int]]: +def encode_cl100k(text: str) -> list[int] | None: """cl100k_base token ids, or None if unavailable. Used by the probes to reason about token-level overlap/redundancy, not just counts.""" enc = _enc(CL100K) diff --git a/src/terse/transforms.py b/src/terse/transforms.py index 2f3f1fd..509bad8 100644 --- a/src/terse/transforms.py +++ b/src/terse/transforms.py @@ -360,7 +360,7 @@ def _locate_records(obj: Any) -> tuple[Any, list[dict]] | None: def _diff_id_col(prev_recs: list[dict], curr_recs: list[dict]) -> str | None: """A column present in every record of both lists whose values are scalar (str/int) and unique within each list — usable to align rows across the two calls.""" - for c in prev_recs[0].keys(): + for c in prev_recs[0]: if not (all(c in r for r in prev_recs) and all(c in r for r in curr_recs)): continue pv = [r[c] for r in prev_recs] diff --git a/src/terse/transport.py b/src/terse/transport.py index ef4c2fe..33ab740 100644 --- a/src/terse/transport.py +++ b/src/terse/transport.py @@ -19,8 +19,9 @@ import subprocess import urllib.error import urllib.request +from collections.abc import Iterator +from typing import Any, Protocol, TextIO from urllib.parse import urlsplit -from typing import Any, Iterator, Optional, Protocol, TextIO # The only downstream URL schemes terse will dial. `urllib.request.urlopen` also # honors `file://`, `ftp://`, `data:` and more — so an unrestricted scheme turns a @@ -30,6 +31,19 @@ # project-scoped `.mcp.json` (see install_mcp.py), so this is not purely operator input. _ALLOWED_URL_SCHEMES = ("http", "https") +# Hosts where cleartext http is safe (it never leaves the machine) — the same set +# fluency.openai_answerer's TLS guard exempts, for the same local-gateway use case. +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + +# Header names that carry credentials. A downstream with one of these over cleartext +# http to a REMOTE host would put the secret on the wire unencrypted — refuse at +# construction, mirroring fluency's answerer guard (parity noted in the 07-14 audit). +_SENSITIVE_HEADER_TOKENS = ("authorization", "token", "secret", "key", "cookie") + + +def _has_sensitive_header(headers: dict[str, str]) -> bool: + return any(t in name.lower() for name in headers for t in _SENSITIVE_HEADER_TOKENS) + class Transport(Protocol): """One downstream MCP peer, abstracted over its wire transport. @@ -122,7 +136,7 @@ class _HttpSendWriter: differently-behaved caller still gets one POST per JSON-RPC line rather than one POST of concatenated lines.""" - def __init__(self, transport: "HttpTransport"): + def __init__(self, transport: HttpTransport): self._transport = transport self._buf = "" @@ -207,7 +221,7 @@ class HttpTransport: in-flight request's id and enqueues THAT instead, so the client always gets a reply rather than silence.""" - def __init__(self, url: str, headers: Optional[dict[str, str]] = None, timeout: int = 60): + def __init__(self, url: str, headers: dict[str, str] | None = None, timeout: int = 60): scheme = urlsplit(url).scheme.lower() if scheme not in _ALLOWED_URL_SCHEMES: # Raised at construction (before any I/O), so build_transport's callers @@ -220,13 +234,21 @@ def __init__(self, url: str, headers: Optional[dict[str, str]] = None, timeout: "read or SSRF vector)") self.url = url self.headers = dict(headers or {}) + split = urlsplit(url) + if (scheme == "http" and split.hostname not in _LOOPBACK_HOSTS + and _has_sensitive_header(self.headers)): + # Same construction-time, before-any-I/O contract as the scheme check above. + raise ValueError( + f"terse: refusing to send credential header(s) over cleartext http to " + f"{split.hostname!r} — use https, or a loopback gateway " + f"({'/'.join(sorted(_LOOPBACK_HOSTS))})") self.timeout = timeout - self._q: "queue.Queue[Any]" = queue.Queue() + self._q: queue.Queue[Any] = queue.Queue() # MCP Streamable HTTP session affinity: some servers pin a client to # server-side state via this header, set on a prior response. Captured # opportunistically and echoed back on every subsequent POST — never # required, since plenty of servers don't use it at all. - self.session: Optional[str] = None + self.session: str | None = None def inbound(self) -> Iterator[str]: return iter(self._q.get, _SENTINEL) @@ -339,7 +361,7 @@ def _error_reply(mid: Any, message: str) -> str: ) -def build_transport(target: list[str], *, headers: Optional[dict[str, str]] = None) -> Transport: +def build_transport(target: list[str], *, headers: dict[str, str] | None = None) -> Transport: """Build the right `Transport` for a proxy `cmd`/downstream target. A single element containing `"://"` is a URL -> `HttpTransport`; anything diff --git a/tests/test_fluency.py b/tests/test_fluency.py index 9eb41e9..9b27c18 100644 --- a/tests/test_fluency.py +++ b/tests/test_fluency.py @@ -554,9 +554,9 @@ def test_build_chain_windows_yields_every_depth_and_valid_hops(): windows = fluency.build_chain_windows(_soak_envs(n=8), max_depth=3, per_depth_cap=4) depths = {d for _, _, d, _ in windows} assert depths == {1, 2, 3} - for tool, sha, depth, objs in windows: + for tool, _sha, depth, objs in windows: assert len(objs) == depth + 1 - for prev, curr in zip(objs, objs[1:]): + for prev, curr in zip(objs, objs[1:], strict=False): assert diff_wire(prev, curr, tool) is not None # every hop truly chains assert fluency.gen_questions(objs[-1]) # final state is askable @@ -570,7 +570,7 @@ def test_build_chain_windows_never_spans_a_diff_break(): windows = fluency.build_chain_windows(envs, max_depth=3, per_depth_cap=8) from terse.transforms import diff_wire for tool, _sha, _depth, objs in windows: - for prev, curr in zip(objs, objs[1:]): + for prev, curr in zip(objs, objs[1:], strict=False): assert diff_wire(prev, curr, tool) is not None diff --git a/tests/test_install_mcp.py b/tests/test_install_mcp.py index a33a3fa..4ded1ea 100644 --- a/tests/test_install_mcp.py +++ b/tests/test_install_mcp.py @@ -587,3 +587,70 @@ def test_scan_scopes_is_read_only(tmp_path, monkeypatch): assert cfg.read_text() == before assert not im.stash_path(cfg).exists() assert list(tmp_path.glob("*.bak-*")) == [] + + +def test_rewrap_preserves_hand_edits_on_wrapped_entry(): + # The 2026-07-13 production incident: a scoped env.PATH pin hand-added to the + # WRAPPED entry was silently reverted by a re-install, because wrap() rebuilt + # purely from the stashed (pre-pin) original. The drift guard keeps live + # non-terse-owned keys on a re-wrap. + config = _cfg(codegraph={"command": "/usr/local/bin/codegraph", + "args": ["serve", "--mcp"], "type": "stdio"}) + stash: dict = {} + im.wrap(config, stash, "codegraph", "/p/policy.json", TERSE_CMD) + # operator pins node@22 on the wrapped entry by hand + config["mcpServers"]["codegraph"]["env"] = {"PATH": "/opt/node22/bin:/usr/bin"} + + im.wrap(config, stash, "codegraph", "/p/policy.json", TERSE_CMD, diff=False) + entry = config["mcpServers"]["codegraph"] + assert entry["env"] == {"PATH": "/opt/node22/bin:/usr/bin"} # pin survived + assert "--no-diff" in entry["args"] # flags still rebuilt + assert entry["command"] == "/abs/python" # command still terse's + + # a live hand-edit also WINS over the stashed original's value for the same key + config["mcpServers"]["codegraph"]["type"] = "http" # hand-changed + im.wrap(config, stash, "codegraph", "/p/policy.json", TERSE_CMD) + assert config["mcpServers"]["codegraph"]["type"] == "http" + + # the guard never leaks the hand-edit into the stash: uninstall restores pristine + im.unwrap(config, stash, "codegraph") + assert config["mcpServers"]["codegraph"] == { + "command": "/usr/local/bin/codegraph", "args": ["serve", "--mcp"], "type": "stdio"} + + +def test_rewrap_never_resurrects_url_headers_from_a_drifted_live_entry(): + # If someone hand-replaces a managed server's live entry with a raw url entry, + # a re-wrap must not copy url/headers onto the wrapped shape (an entry with both + # args and url is broken) — those keys are always folded into args from the stash. + original = {"url": "https://example.com/mcp", "headers": {"X": "1"}} + config = _cfg(remote=dict(original)) + stash: dict = {} + im.wrap(config, stash, "remote", "/p/policy.json", TERSE_CMD) + config["mcpServers"]["remote"] = dict(original) # hand-reverted + im.wrap(config, stash, "remote", "/p/policy.json", TERSE_CMD) + entry = config["mcpServers"]["remote"] + assert "url" not in entry and "headers" not in entry + assert entry["args"][-1] == "https://example.com/mcp" + + +def test_do_install_reports_preserved_hand_edits(tmp_path, monkeypatch): + cfg = tmp_path / ".claude.json" + cfg.write_text(json.dumps(_cfg(runecho={"command": "uvx", "args": ["runecho-mcp"]}))) + policy = tmp_path / "policy.json" + policy.write_text("{}") + monkeypatch.setattr(im, "terse_invocation", lambda: TERSE_CMD) + + im.do_install(["runecho"], str(policy), cfg=cfg) + written = json.loads(cfg.read_text()) + written["mcpServers"]["runecho"]["env"] = {"PATH": "/pin"} # hand-edit + cfg.write_text(json.dumps(written)) + + res = im.do_install(["runecho"], str(policy), cfg=cfg) + change = res["changes"][0] + assert change["preserved"] == ["env"] + assert json.loads(cfg.read_text())["mcpServers"]["runecho"]["env"] == {"PATH": "/pin"} + # the edit stays live-only (never leaks into the stash), so EVERY later re-wrap + # keeps carrying — and keeps reporting — it; that persistence is the guard working + res = im.do_install(["runecho"], str(policy), cfg=cfg) + assert res["changes"][0]["preserved"] == ["env"] + assert json.loads(cfg.read_text())["mcpServers"]["runecho"]["env"] == {"PATH": "/pin"} diff --git a/tests/test_multiproxy.py b/tests/test_multiproxy.py index e1da232..bb1bc7c 100644 --- a/tests/test_multiproxy.py +++ b/tests/test_multiproxy.py @@ -242,7 +242,7 @@ def test_shared_drop_store_across_peers(tmp_path): gh_transport = build_transport([sys.executable, str(FAKE)]) http_transport = build_transport([_url(srv)]) try: - store: "OrderedDict[str, object]" = OrderedDict() + store: OrderedDict[str, object] = OrderedDict() store_lock = Lock() gh_inter = Interceptor(DROP_POLICY, store=store, store_lock=store_lock) http_inter = Interceptor(DROP_POLICY, store=store, store_lock=store_lock) @@ -384,7 +384,7 @@ def test_interceptor_default_store_is_private_and_unaffected(): def test_interceptor_injected_store_is_actually_shared(): - store: "OrderedDict[str, object]" = OrderedDict() + store: OrderedDict[str, object] = OrderedDict() lock = Lock() a = Interceptor(DROP_POLICY, store=store, store_lock=lock) b = Interceptor(DROP_POLICY, store=store, store_lock=lock) @@ -401,7 +401,7 @@ def test_shared_dropped_bytes_evicts_over_combined_cap_across_peers(): # DICT was shared (multiproxy._build_peers), so the DROPPED_MAX_BYTES cap never saw # the true combined size — two peers each individually under-cap could jointly blow # way past it. A shared `dropped_bytes` box fixes that. - store: "OrderedDict[str, object]" = OrderedDict() + store: OrderedDict[str, object] = OrderedDict() lock = Lock() dropped_bytes: list[int] = [0] a = Interceptor(DROP_POLICY, store=store, store_lock=lock, dropped_bytes=dropped_bytes) @@ -500,7 +500,7 @@ def test_build_peers_diff_override_reaches_peer_with_own_policy_path(monkeypatch monkeypatch.setattr(mp, "build_transport", lambda target, headers=None: _FakePeerTransport()) own_policy = tmp_path / "own.json" - own_policy.write_text(json.dumps({"version": 1, "rules": []}), encoding="utf-8") + own_policy.write_text(json.dumps({"version": 1, "policies": []}), encoding="utf-8") # ("rules" was a schema typo the loader used to swallow — now rejected) specs = [ DownstreamSpec(name="a", target=["a"], headers={}, policy_path=None), DownstreamSpec(name="b", target=["b"], headers={}, policy_path=str(own_policy)), diff --git a/tests/test_policy.py b/tests/test_policy.py index d672781..ecb7e54 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -145,3 +145,37 @@ def test_unsupported_version_rejected(tmp_path): bad.write_text(json.dumps({"version": 2})) with pytest.raises(ValueError): load_policy(bad) + + +def test_load_policy_rejects_unknown_keys_at_every_level(tmp_path): + # A typo'd key silently reverting to default behavior is a trap — the loader + # rejects unknown keys loudly at every level (audit fix #3). "_"-prefixed + # annotation keys (policy_gen's _comment/_suggested_fields*) stay exempt. + import pytest + + from terse.policy import load_policy + + def _write(doc): + p = tmp_path / "p.json" + p.write_text(json.dumps(doc)) + return p + + base = {"version": 1, "defaults": {"tiers": ["minify"]}, "policies": []} + + for doc, needle in [ + ({**base, "polices": []}, "polices"), # top-level typo + ({**base, "diff_keyframe_intervall": 3}, "intervall"), # top-level typo + ({**base, "defaults": {"tiers": ["minify"], "teirs": []}}, "teirs"), + ({**base, "policies": [{"match": {"tool": "x"}, "tiers": [], "feilds": {}}]}, "feilds"), + ({**base, "policies": [{"match": {"tool": "x", "name": "y"}, "tiers": []}]}, "name"), + ]: + with pytest.raises(ValueError, match=needle): + load_policy(_write(doc)) + + # underscore-prefixed annotations pass at every level (the policy_gen convention) + ok = {"version": 1, "_comment": "hi", + "defaults": {"tiers": ["minify"], "_note": "x"}, + "policies": [{"_comment": "c", "match": {"tool": "x", "_why": "w"}, + "tiers": [], "_suggested_fields": {"a": {}}}]} + pol = load_policy(_write(ok)) + assert pol.rules[0].tool_glob == "x" diff --git a/tests/test_transport.py b/tests/test_transport.py index 2fab8ba..42ba72a 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -13,11 +13,11 @@ import json import threading +import pytest + from terse import transforms from terse.lossy import _handle, _serialize from terse.policy import Policy, Rule -import pytest - from terse.proxy import Interceptor, run_proxy from terse.transport import HttpTransport, build_transport @@ -113,7 +113,7 @@ def _send_sse(self, mid) -> None: "result": {"content": [{"type": "text", "text": "first"}]}}) ev2 = json.dumps({"jsonrpc": "2.0", "method": "notifications/progress", "params": {"pct": 50}}) - payload = f"data: {ev1}\n\ndata: {ev2}\n\n".encode("utf-8") + payload = f"data: {ev1}\n\ndata: {ev2}\n\n".encode() self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Content-Length", str(len(payload))) @@ -376,3 +376,19 @@ def test_run_proxy_rejects_file_url_scheme_as_config_error(capsys): rc = run_proxy(["file:///etc/passwd"], FULL, stdin=io.StringIO(""), stdout=io.StringIO()) assert rc == 2 assert "not allowed" in capsys.readouterr().err + + +def test_http_transport_refuses_credential_headers_over_remote_cleartext(): + # Parity with fluency.openai_answerer's TLS guard (audit fix #3): a Bearer/token + # header over http to a remote host puts the credential on the wire unencrypted. + import pytest + + for name in ("Authorization", "X-Api-Key", "Proxy-Token", "Cookie", "client-secret"): + with pytest.raises(ValueError, match="cleartext http"): + HttpTransport("http://api.example.com/mcp", headers={name: "v"}) + # https, loopback http, and non-sensitive headers over http all still construct + assert HttpTransport("https://api.example.com/mcp", headers={"Authorization": "v"}) + assert HttpTransport("http://127.0.0.1:4000/mcp", headers={"Authorization": "v"}) + assert HttpTransport("http://localhost:4000/mcp", headers={"X-Api-Key": "v"}) + assert HttpTransport("http://api.example.com/mcp", headers={"X-Trace-Id": "v"}) + assert HttpTransport("http://api.example.com/mcp") diff --git a/uv.lock b/uv.lock index 92dd750..ef034ab 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,51 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] [[package]] name = "certifi" @@ -127,6 +172,142 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -136,6 +317,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -324,6 +514,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, ] @@ -333,6 +524,7 @@ requires-dist = [{ name = "tiktoken", specifier = ">=0.7" }] [package.metadata.requires-dev] dev = [ + { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, ] @@ -391,6 +583,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"