From 2770390f23770ef5ed65a7253b951b7b01090210 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 23:10:40 +0000 Subject: [PATCH 1/4] Modernize tools, drop __future__ annotations, fix lint CI The lint CI pointed mypy at directories that don't exist (src/packages/tests) while the real code lives in tools/, the code carried no annotations (so mypy --strict and ruff's ANN/D rules failed), the mypy config loaded a pydantic plugin that isn't a dependency, and coverage_diff's sphobjinv import was unresolved for the type checkers. Tooling: - Point mypy at tools/ + noxfile.py in the workflow and the noxfile - Declare sphobjinv as a dependency so `uv sync` resolves it; tell mypy it ships no py.typed marker - Drop the unused pydantic.mypy plugin - Curate a ruff ignore list for select=ALL: formatter conflicts, the TC family (it assumes stringized annotations), and per-file allowances for CLI print / boolean-trap / arg-count / broad-except / url-open Code: - Remove `from __future__ import annotations` from all three tools - Add full type annotations; PEP 695 `type` aliases for the record and version-key shapes - pathlib over os.path/glob/open; itertools.pairwise over zip(x, x[1:]); a dict comprehension for the union build - Name the magic comparison values and add docstrings - Drop the now-dead sys.stdlib_module_names guard and an unused _markdown_summary parameter ruff (format + check), mypy --strict, ty, and prek all pass; the three tools still run end-to-end (introspect -> merge -> coverage diff). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rh5G5LSDPMWLgX3cYLWUnk --- .github/workflows/python-ci.yaml | 2 +- noxfile.py | 2 +- pyproject.toml | 24 +++++- tools/coverage_diff.py | 124 +++++++++++++++++--------- tools/merge_summary.py | 102 ++++++++++++++-------- tools/stdlib_introspect.py | 144 +++++++++++++++++++------------ uv.lock | 133 ++++++++++++++++++++++++++++ 7 files changed, 393 insertions(+), 138 deletions(-) mode change 100644 => 100755 tools/coverage_diff.py mode change 100644 => 100755 tools/merge_summary.py mode change 100644 => 100755 tools/stdlib_introspect.py diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 29053bc..721684e 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -42,7 +42,7 @@ jobs: run: uv run ruff check --output-format=github . - name: Run mypy - run: uv run mypy --strict src/ packages/ tests/ + run: uv run mypy --strict tools/ noxfile.py - name: Run ty run: uv run ty check --output-format=github . diff --git a/noxfile.py b/noxfile.py index 541686d..bf42e3d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -25,7 +25,7 @@ def lints(session: nox.Session) -> None: session.run("prek", "run", "--all-files") session.run("ruff", "format", ".") session.run("ruff", "check", "--fix", ".") - session.run("mypy", "--strict", "src/", "packages/", "tests/") + session.run("mypy", "--strict", "tools/", "noxfile.py") session.run("ty", "check", ".") diff --git a/pyproject.toml b/pyproject.toml index fce3860..27d15a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,9 @@ authors = [ { name = "Bradley Reynolds", email = "bradley.reynolds@tailstory.dev" }, ] requires-python = ">=3.14" +dependencies = [ + "sphobjinv>=2.4", +] [dependency-groups] dev = [ @@ -30,6 +33,22 @@ cache-dir = ".cache/ruff" select = [ "ALL", ] +ignore = [ + "COM812", # conflicts with the formatter + "ISC001", # conflicts with the formatter + "TC001", # no `from __future__ import annotations`: annotations are evaluated at runtime + "TC002", # no `from __future__ import annotations`: annotations are evaluated at runtime + "TC003", # no `from __future__ import annotations`: annotations are evaluated at runtime +] + +[tool.ruff.lint.per-file-ignores] +"tools/*.py" = [ + "T201", # CLI tools: the printed report is the deliverable + "FBT", # internal recursion helpers; keyword-only bools add churn, not safety + "PLR0913", # record/report builders take one parameter per output column by design +] +"tools/stdlib_introspect.py" = ["BLE001"] # importing arbitrary stdlib modules; broad except is the point +"tools/coverage_diff.py" = ["S310"] # fetches a fixed https://docs.python.org inventory URL [tool.ruff.lint.pydocstyle] convention = "numpy" @@ -38,9 +57,12 @@ convention = "numpy" pretty = true num_workers = 4 native_parser = true # required by num_workers -plugins = ["pydantic.mypy"] cache_dir = ".cache/mypy" +[[tool.mypy.overrides]] +module = ["sphobjinv.*"] +ignore_missing_imports = true # sphobjinv ships no py.typed marker + # Still need to tell Mypy to ignore these [tool.ty.rules] unused-ignore-comment = "ignore" diff --git a/tools/coverage_diff.py b/tools/coverage_diff.py old mode 100644 new mode 100755 index 07c610b..16ffa5a --- a/tools/coverage_diff.py +++ b/tools/coverage_diff.py @@ -15,8 +15,6 @@ added/removed -- those deltas come from the matrix (merge_summary.py), not here. """ -from __future__ import annotations - import argparse import json import os @@ -25,11 +23,16 @@ import urllib.error import urllib.request from collections import Counter +from pathlib import Path +from typing import Any import sphobjinv as soi +type Record = dict[str, Any] + INVENTORY_URL = "https://docs.python.org/{version}/objects.inv" DEV_INVENTORY_URL = "https://docs.python.org/dev/objects.inv" +HTTP_NOT_FOUND = 404 # Introspected prefix -> docs-canonical prefix. The docs index posix|nt as os.*, # posixpath|ntpath|genericpath as os.path.*, and builtins members unprefixed. @@ -55,21 +58,25 @@ TOP_MODULES = 15 -def version_key(version): +def version_key(version: str) -> tuple[int, ...]: + """Sort key for a version string: ``'3.14'`` -> ``(3, 14)``.""" numbers = re.findall(r"\d+", version) return tuple(int(number) for number in numbers[:2]) if numbers else (0,) -def version_label(version): +def version_label(version: str) -> str: + """Canonical ``X.Y`` label for a version string.""" return ".".join(str(part) for part in version_key(version)) -def cell_version(cell): +def cell_version(cell: str) -> str | None: + """Extract the ``X.Y`` minor from a ``...-py3.14`` cell id, or ``None``.""" match = CELL_VERSION.search(cell) return version_label(match.group(1)) if match else None -def normalize(qualname): +def normalize(qualname: str) -> str: + """Map an introspected qualname onto its docs-canonical spelling.""" if qualname in MODULE_ALIASES: return MODULE_ALIASES[qualname] for prefix, replacement in PREFIX_ALIASES.items(): @@ -78,62 +85,66 @@ def normalize(qualname): return qualname -def normalize_module(module): +def normalize_module(module: str) -> str: + """Map an introspected module name onto its docs-canonical spelling.""" return MODULE_ALIASES.get(module, module) -def percent(part, whole): +def percent(part: int, whole: int) -> float: + """Return ``part / whole`` as a percentage rounded to one decimal.""" return round(100 * part / whole, 1) if whole else 0.0 -def load_union(path): +def load_union(path: str) -> list[Record]: + """Read the union JSONL into a list of records.""" records = [] - with open(path, encoding="utf-8") as source_file: + with Path(path).open(encoding="utf-8") as source_file: for line in source_file: - line = line.strip() - if line: - records.append(json.loads(line)) + stripped = line.strip() + if stripped: + records.append(json.loads(stripped)) return records -def http_get(url, attempts=4): +def http_get(url: str, attempts: int = 4) -> bytes: + """GET ``url``, retrying transient URL errors with exponential backoff.""" for attempt in range(attempts): try: request = urllib.request.Request(url, headers={"User-Agent": "coverage-diff"}) with urllib.request.urlopen(request, timeout=30) as response: - return response.read() + body: bytes = response.read() + return body except urllib.error.HTTPError: raise # 4xx/5xx: caller decides (404 -> dev fallback) except urllib.error.URLError: if attempt == attempts - 1: raise time.sleep(2**attempt) - raise ValueError("attempts must be >= 1") + raise AssertionError # unreachable while attempts >= 1 -def documented_names(version, inventory_dir=None): +def documented_names(version: str, inventory_dir: str | None = None) -> tuple[set[str], bool]: """Return (set of py-domain names, used_dev_fallback) for one minor.""" if inventory_dir: - local = os.path.join(inventory_dir, f"{version}.inv") - if os.path.exists(local): - with open(local, "rb") as handle: - inventory = soi.Inventory(zlib=handle.read()) + local = Path(inventory_dir) / f"{version}.inv" + if local.exists(): + inventory = soi.Inventory(zlib=local.read_bytes()) # ty: ignore[unknown-argument] return {obj.name for obj in inventory.objects if obj.domain == "py"}, False used_dev = False try: data = http_get(INVENTORY_URL.format(version=version)) except urllib.error.HTTPError as error: - if error.code != 404: + if error.code != HTTP_NOT_FOUND: raise data = http_get(DEV_INVENTORY_URL) # in-dev minor with no numbered inventory yet used_dev = True - inventory = soi.Inventory(zlib=data) + inventory = soi.Inventory(zlib=data) # ty: ignore[unknown-argument] return {obj.name for obj in inventory.objects if obj.domain == "py"}, used_dev -def build_surface(union, version): +def build_surface(union: list[Record], version: str) -> dict[str, Record]: """norm-qualname -> representative record, for one minor, OS-unioned.""" - surface = {} + surface: dict[str, Record] = {} for record in union: if record.get("is_dunder"): continue @@ -146,7 +157,12 @@ def build_surface(union, version): return surface -def diff_version(union, version, inventory_dir): +def diff_version( + union: list[Record], + version: str, + inventory_dir: str | None, +) -> tuple[Record, dict[str, Record], set[str], set[str]]: + """Split one minor's surface into (summary, surface, missing, docs-only).""" surface = build_surface(union, version) documented_upstream, used_dev = documented_names(version, inventory_dir) surface_names = set(surface) @@ -154,7 +170,7 @@ def diff_version(union, version, inventory_dir): missing_from_official_docs = surface_names - documented_upstream docs_only = documented_upstream - surface_names - by_module = {} + by_module: dict[str, Record] = {} for name, record in surface.items(): module = normalize_module(record["module"]) bucket = by_module.setdefault(module, {"surface": 0, "covered": 0}) @@ -177,7 +193,8 @@ def diff_version(union, version, inventory_dir): return summary, surface, missing_from_official_docs, docs_only -def gap_records(surface, missing_from_official_docs): +def gap_records(surface: dict[str, Record], missing_from_official_docs: set[str]) -> list[Record]: + """Build the sorted gap rows for the entities missing from the official docs.""" rows = [] for name in missing_from_official_docs: record = surface[name] @@ -195,11 +212,14 @@ def gap_records(surface, missing_from_official_docs): return rows -def default_target(versions): - return versions[-2] if len(versions) >= 2 else versions[-1] +def default_target(versions: list[str]) -> str: + """Latest stable minor: the penultimate entry (the highest is the in-dev branch).""" + stable = versions[:-1] or versions # drop the in-dev highest, unless it is all we have + return stable[-1] -def main(): +def main() -> None: + """Parse arguments, diff every minor in the union, and write the outputs.""" parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("union", help="stdlib_api_union.jsonl from the aggregate job") parser.add_argument( @@ -209,20 +229,27 @@ def main(): parser.add_argument("-o", "--output", default="official_docs_coverage_by_version.json") parser.add_argument("--gap-out", metavar="PATH", help="default: official_docs_gap_.jsonl") parser.add_argument( - "--inventory-dir", metavar="DIR", help="read .inv from here before fetching (offline/cached runs)", + "--inventory-dir", + metavar="DIR", + help="read .inv from here before fetching (offline/cached runs)", ) parser.add_argument( - "--md-summary", metavar="PATH", help="write the Markdown report here (defaults to $GITHUB_STEP_SUMMARY)", + "--md-summary", + metavar="PATH", + help="write the Markdown report here (defaults to $GITHUB_STEP_SUMMARY)", ) args = parser.parse_args() union = load_union(args.union) versions = sorted( - {cell_version(cell) for record in union for cell in record.get("cells", []) if cell_version(cell)}, + {minor for record in union for cell in record.get("cells", []) if (minor := cell_version(cell))}, key=version_key, ) - results, surfaces, gaps, docs_onlys = {}, {}, {}, {} + results: dict[str, Record] = {} + surfaces: dict[str, dict[str, Record]] = {} + gaps: dict[str, set[str]] = {} + docs_onlys: dict[str, set[str]] = {} for version in versions: summary, surface, missing, docs_only = diff_version(union, version, args.inventory_dir) results[version] = summary @@ -235,21 +262,34 @@ def main(): ) target = args.target_version or (default_target(versions) if versions else None) + target_surface = surfaces.get(target, {}) if target else {} + target_missing = gaps.get(target, set()) if target else set() + target_docs_only = docs_onlys.get(target, set()) if target else set() coverage = {"target_version": target, "versions": results} - with open(args.output, "w", encoding="utf-8", newline="\n") as out_file: + with Path(args.output).open("w", encoding="utf-8", newline="\n") as out_file: json.dump(coverage, out_file, indent=2) out_file.write("\n") gap_path = args.gap_out or f"official_docs_gap_{target}.jsonl" - rows = gap_records(surfaces.get(target, {}), gaps.get(target, set())) - with open(gap_path, "w", encoding="utf-8", newline="\n") as out_file: + rows = gap_records(target_surface, target_missing) + with Path(gap_path).open("w", encoding="utf-8", newline="\n") as out_file: out_file.writelines(json.dumps(row) + "\n" for row in rows) - report(versions, results, target, rows, docs_onlys.get(target, set()), args.output, gap_path, args) + report(versions, results, target, rows, target_docs_only, args.output, gap_path, args) -def report(versions, results, target, gap_rows, docs_only, output_path, gap_path, args): +def report( + versions: list[str], + results: dict[str, Record], + target: str | None, + gap_rows: list[Record], + docs_only: set[str], + output_path: str, + gap_path: str, + args: argparse.Namespace, +) -> None: + """Render the run as a Markdown report and a console summary.""" data_entries = sum(1 for row in gap_rows if row["is_data"]) lines = ["# docs.python.org coverage — stdlib API missing from the official reference", ""] if not versions: @@ -297,7 +337,7 @@ def report(versions, results, target, gap_rows, docs_only, output_path, gap_path lines.append(f"| {kind} | {count} |") lines.append("") - module_stats = results.get(target, {}).get("by_module", {}) + module_stats = results.get(target, {}).get("by_module", {}) if target else {} per_module = Counter(row["module"] for row in gap_rows) lines += [ f"## Top {TOP_MODULES} modules by undocumented count ({target})", @@ -320,7 +360,7 @@ def report(versions, results, target, gap_rows, docs_only, output_path, gap_path markdown_path = args.md_summary or os.environ.get("GITHUB_STEP_SUMMARY") if markdown_path: - with open(markdown_path, "a", encoding="utf-8", newline="\n") as summary_file: + with Path(markdown_path).open("a", encoding="utf-8", newline="\n") as summary_file: summary_file.write("\n".join(lines) + "\n") print("\n=== docs.python.org coverage summary =====================") diff --git a/tools/merge_summary.py b/tools/merge_summary.py old mode 100644 new mode 100755 index b77bef6..8f335fb --- a/tools/merge_summary.py +++ b/tools/merge_summary.py @@ -22,23 +22,29 @@ python merge_summary.py CELLS_DIR [-o stdlib_api_union.jsonl] [--md-summary PATH] """ -from __future__ import annotations - import argparse -import glob import json import os import re import sys from collections import defaultdict +from itertools import pairwise +from pathlib import Path +from typing import Any + +type Record = dict[str, Any] +type VersionKey = tuple[int, ...] +type Transition = tuple[VersionKey, VersionKey, list[str], list[str]] # Filenames look like stdlib_api_ubuntu-latest_py3.14.jsonl. The os has no "_py" and # the version no underscore, so a non-greedy split on the single "_py" is unambiguous. CELL_PATTERN = re.compile(r"^stdlib_api_(?P.+?)_py(?P[^_]+)\.jsonl$") FAMILY_ORDER = {"linux": 0, "macos": 1, "windows": 2} +UNRANKED_FAMILY = 99 -def os_family(label): +def os_family(label: str) -> str: + """Collapse a runner label (``ubuntu-latest``) to an OS family (``linux``).""" lowered = label.lower() if lowered.startswith(("ubuntu", "linux")): return "linux" @@ -49,17 +55,18 @@ def os_family(label): return lowered -def version_key(version): +def version_key(version: str) -> VersionKey: """'3.14' -> (3, 14); tolerant of '3.15.0a1' and junk.""" numbers = re.findall(r"\d+", version) return tuple(int(number) for number in numbers[:2]) if numbers else (0,) -def format_version(version_tuple): +def format_version(version_tuple: VersionKey) -> str: + """Render a version key back as a dotted ``X.Y`` string.""" return ".".join(str(part) for part in version_tuple) -def version_span(present_keys, matrix_keys): +def version_span(present_keys: set[VersionKey], matrix_keys: list[VersionKey]) -> tuple[str, str | None]: """(added_in, removed_in) for one entity from its OS-collapsed version set. added_in is floored at the matrix minimum: present at the floor means it was @@ -69,7 +76,7 @@ def version_span(present_keys, matrix_keys): floor = matrix_keys[0] added_in = "<=" + format_version(floor) if floor in present_keys else format_version(min(present_keys)) removed_in = None - for earlier, later in zip(matrix_keys, matrix_keys[1:]): + for earlier, later in pairwise(matrix_keys): if earlier in present_keys and later not in present_keys: removed_in = format_version(later) break @@ -77,33 +84,37 @@ def version_span(present_keys, matrix_keys): class Cell: - def __init__(self, path, os_label, version): + """One matrix cell's records, tagged with its OS family and Python version.""" + + def __init__(self, path: Path, os_label: str, version: str) -> None: self.path = path self.os_label = os_label self.family = os_family(os_label) self.version = version self.version_key = version_key(version) self.cell_id = f"{self.family}-py{version}" - self.records = {} # qualname -> record (last wins within a cell) + self.records: dict[str, Record] = {} # qualname -> record (last wins within a cell) self.malformed = 0 - def load(self): - with open(self.path, encoding="utf-8") as source_file: + def load(self) -> None: + """Parse the cell's JSONL file, tolerating malformed lines from a crashed cell.""" + with self.path.open(encoding="utf-8") as source_file: for line in source_file: - line = line.strip() - if not line: + stripped = line.strip() + if not stripped: continue try: - record = json.loads(line) + record = json.loads(stripped) self.records[record["qualname"]] = record except json.JSONDecodeError, KeyError, TypeError: self.malformed += 1 # tolerate a half-written dump from a crashed cell -def discover(cells_dir): +def discover(cells_dir: str) -> list[Cell]: + """Load every recognized ``stdlib_api__py.jsonl`` cell under ``cells_dir``.""" cells = [] - for path in sorted(glob.glob(os.path.join(cells_dir, "stdlib_api_*_py*.jsonl"))): - match = CELL_PATTERN.match(os.path.basename(path)) + for path in sorted(Path(cells_dir).glob("stdlib_api_*_py*.jsonl")): + match = CELL_PATTERN.match(path.name) if not match: print(f" ! unrecognized file name, skipping: {path}", file=sys.stderr) continue @@ -113,10 +124,13 @@ def discover(cells_dir): return cells -def aggregate(cells): - present_cells = defaultdict(set) # qualname -> {cell_id} - present_families = defaultdict(set) # qualname -> {family} - present_version_keys = defaultdict(set) # qualname -> {version_key} +def aggregate( + cells: list[Cell], +) -> tuple[list[str], dict[str, Record], dict[str, set[str]], dict[str, set[VersionKey]]]: + """Union the cells' records and annotate each with its cell/version presence.""" + present_cells: defaultdict[str, set[str]] = defaultdict(set) # qualname -> {cell_id} + present_families: defaultdict[str, set[str]] = defaultdict(set) # qualname -> {family} + present_version_keys: defaultdict[str, set[VersionKey]] = defaultdict(set) # qualname -> {version_key} for cell in cells: for qualname in cell.records: present_cells[qualname].add(cell.cell_id) @@ -127,10 +141,11 @@ def aggregate(cells): # Walk cells oldest -> newest and let the newer cell overwrite, so the union's base # record carries the newest minor's signature/docstring. - union_records = {} - for cell in sorted(cells, key=lambda cell: (cell.version_key, FAMILY_ORDER.get(cell.family, 99))): - for qualname, record in cell.records.items(): - union_records[qualname] = record + union_records: dict[str, Record] = { + qualname: record + for cell in sorted(cells, key=lambda cell: (cell.version_key, FAMILY_ORDER.get(cell.family, UNRANKED_FAMILY))) + for qualname, record in cell.records.items() + } matrix_keys = sorted({cell.version_key for cell in cells}) for qualname in union: record = dict(union_records[qualname]) @@ -142,12 +157,15 @@ def aggregate(cells): return union, union_records, present_families, present_version_keys -def main(): +def main() -> None: + """Parse arguments, build the union, write it out, and report.""" parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("cells_dir", help="directory of stdlib_api__py.jsonl files") parser.add_argument("-o", "--output", default="stdlib_api_union.jsonl") parser.add_argument( - "--md-summary", metavar="PATH", help="write the Markdown report here (defaults to $GITHUB_STEP_SUMMARY)", + "--md-summary", + metavar="PATH", + help="write the Markdown report here (defaults to $GITHUB_STEP_SUMMARY)", ) args = parser.parse_args() @@ -156,12 +174,12 @@ def main(): # Always write the union file (even empty) so the upload step has an artifact; # newline="\n" so the artifact is byte-identical regardless of which runner ran us. - with open(args.output, "w", encoding="utf-8", newline="\n") as out_file: + with Path(args.output).open("w", encoding="utf-8", newline="\n") as out_file: out_file.writelines(json.dumps(union_records[qualname]) + "\n" for qualname in union) # Platform-exclusive: present on exactly one OS family across the matrix. - families = sorted({cell.family for cell in cells}, key=lambda family: FAMILY_ORDER.get(family, 99)) - exclusive = {family: [] for family in families} + families = sorted({cell.family for cell in cells}, key=lambda family: FAMILY_ORDER.get(family, UNRANKED_FAMILY)) + exclusive: dict[str, list[str]] = {family: [] for family in families} for qualname in union: families_present = present_families[qualname] if len(families_present) == 1: @@ -169,8 +187,8 @@ def main(): # Per-adjacent-minor deltas, OS-collapsed (present in a minor == present in any OS cell). version_keys = sorted({cell.version_key for cell in cells}) - transitions = [] - for earlier, later in zip(version_keys, version_keys[1:]): + transitions: list[Transition] = [] + for earlier, later in pairwise(version_keys): added = [ qualname for qualname in union @@ -186,13 +204,23 @@ def main(): report(cells, union, exclusive, families, transitions, version_keys, args) -def report(cells, union, exclusive, families, transitions, version_keys, args): - rows = sorted(cells, key=lambda cell: (cell.version_key, FAMILY_ORDER.get(cell.family, 99))) +def report( + cells: list[Cell], + union: list[str], + exclusive: dict[str, list[str]], + families: list[str], + transitions: list[Transition], + version_keys: list[VersionKey], + args: argparse.Namespace, +) -> None: + """Render the union as a Markdown report and a console summary.""" + rows = sorted(cells, key=lambda cell: (cell.version_key, FAMILY_ORDER.get(cell.family, UNRANKED_FAMILY))) lines = ["# stdlib introspection — cross-platform union", ""] if not cells: lines += [ - "> **No cell artifacts were found.** Every matrix cell failed to produce a dump, or the download step pulled nothing.", + "> **No cell artifacts were found.** Every matrix cell failed to produce " + "a dump, or the download step pulled nothing.", "", ] else: @@ -247,7 +275,7 @@ def report(cells, union, exclusive, families, transitions, version_keys, args): markdown_path = args.md_summary or os.environ.get("GITHUB_STEP_SUMMARY") if markdown_path: - with open(markdown_path, "a", encoding="utf-8", newline="\n") as summary_file: + with Path(markdown_path).open("a", encoding="utf-8", newline="\n") as summary_file: summary_file.write("\n".join(lines) + "\n") print("\n=== union summary ========================================") diff --git a/tools/stdlib_introspect.py b/tools/stdlib_introspect.py old mode 100644 new mode 100755 index 66ebecf..9b36ce7 --- a/tools/stdlib_introspect.py +++ b/tools/stdlib_introspect.py @@ -17,8 +17,6 @@ section); --include-dunders keeps them, flagged with is_dunder=True. """ -from __future__ import annotations - import argparse import contextlib import importlib @@ -30,6 +28,13 @@ import platform import sys import warnings +from collections import Counter +from collections.abc import Iterator +from pathlib import Path +from types import ModuleType +from typing import Any + +type Record = dict[str, Any] # Modules with import-time side effects (browser/print) or that we never document. SKIP_MODULES = { @@ -43,18 +48,23 @@ "lib2to3", # grammar/test heavy; removed in 3.13 } TEST_PARTS = {"test", "tests"} +TOP_MODULES = 12 +FAILED_PREVIEW = 18 +DOC_FIRSTLINE_LIMIT = 100 -def is_dunder(name): - return len(name) > 4 and name.startswith("__") and name.endswith("__") +def is_dunder(name: str) -> bool: + """Return whether ``name`` is a ``__dunder__`` (leading/trailing ``__`` around a non-empty body).""" + return len(name) > len("____") and name.startswith("__") and name.endswith("__") -def is_private(name): +def is_private(name: str) -> bool: + """Return whether ``name`` is private (``_``-prefixed and not a dunder).""" return name.startswith("_") and not is_dunder(name) @contextlib.contextmanager -def _silenced(): +def _silenced() -> Iterator[None]: """Swallow stdout/stderr/warnings during risky imports.""" sink = io.StringIO() with warnings.catch_warnings(): @@ -63,8 +73,9 @@ def _silenced(): yield -def safe_import(name): - short_name = name.split(".")[-1] +def safe_import(name: str) -> ModuleType | None: + """Import ``name``, returning the module or ``None`` if it cannot be imported here.""" + short_name = name.rpartition(".")[-1] if name in SKIP_MODULES or short_name in SKIP_MODULES: return None print(f" importing {name}", flush=True) @@ -77,20 +88,19 @@ def safe_import(name): return None -def roster(include_private): - names = getattr(sys, "stdlib_module_names", None) - if not names: - sys.exit("Requires Python 3.10+ (needs sys.stdlib_module_names).") - seen = set() - for top_level in sorted(name for name in names if include_private or not name.startswith("_")): +def roster(include_private: bool) -> Iterator[tuple[str, ModuleType | None]]: + """Yield (name, imported-module-or-None) for every stdlib module to document.""" + seen: set[str] = set() + names = sorted(name for name in sys.stdlib_module_names if include_private or not name.startswith("_")) + for top_level in names: yield from _emit(top_level, include_private, seen) -def _emit(name, include_private, seen): +def _emit(name: str, include_private: bool, seen: set[str]) -> Iterator[tuple[str, ModuleType | None]]: if name in seen: return seen.add(name) - short_name = name.split(".")[-1] + short_name = name.rpartition(".")[-1] if name in SKIP_MODULES or short_name in SKIP_MODULES or short_name in TEST_PARTS: return module = safe_import(name) @@ -102,7 +112,7 @@ def _emit(name, include_private, seen): except Exception: submodules = [] for submodule in sorted(submodules): - submodule_short = submodule.split(".")[-1] + submodule_short = submodule.rpartition(".")[-1] # __main__ submodules are `python -m pkg` entry points, not API surface, and # importing them RUNS code (tkinter opens a Tk mainloop, asyncio starts a stdin # REPL). Never import them -- nor any other dunder-named submodule. @@ -115,12 +125,13 @@ def _emit(name, include_private, seen): yield from _emit(submodule, include_private, seen) -SEEN = {} # id(obj) -> canonical qualname (first sighting) -RECORDS = {} -PENDING = {} # id(obj) -> [alias qualnames seen before the canonical one] +SEEN: dict[int, str] = {} # id(obj) -> canonical qualname (first sighting) +RECORDS: dict[str, Record] = {} +PENDING: dict[int, list[str]] = {} # id(obj) -> [alias qualnames seen before the canonical one] -def kind_of(entity, in_class): +def kind_of(entity: object, in_class: bool) -> str: + """Classify an entity as module/class/exception/property/descriptor/method/function/data.""" if inspect.ismodule(entity): return "module" if isinstance(entity, type): @@ -134,30 +145,36 @@ def kind_of(entity, in_class): return "data" -def get_signature(entity): - try: - return str(inspect.signature(entity)), "inspect" - except ValueError, TypeError: - text_signature = getattr(entity, "__text_signature__", None) - return (text_signature, "text_signature") if text_signature else (None, "none") +def get_signature(entity: object) -> tuple[str | None, str]: + """Return (signature_text, source) where source is ``inspect``/``text_signature``/``none``.""" + if callable(entity): + try: + return str(inspect.signature(entity)), "inspect" + except ValueError, TypeError: + pass + text_signature = getattr(entity, "__text_signature__", None) + return (text_signature, "text_signature") if text_signature else (None, "none") -def doc_info(entity): +def doc_info(entity: object) -> tuple[bool, bool, str]: + """Return (has_own_doc, has_resolved_doc, first_line) for an entity.""" has_own_doc = bool(getattr(entity, "__doc__", None)) resolved_doc = inspect.getdoc(entity) first_line = "" if resolved_doc and resolved_doc.strip(): - first_line = resolved_doc.strip().splitlines()[0][:100] + first_line = resolved_doc.strip().splitlines()[0][:DOC_FIRSTLINE_LIMIT] return has_own_doc, bool(resolved_doc), first_line -def attach(canonical, alias): +def attach(canonical: str, alias: str) -> None: + """Record ``alias`` as another name for the entity canonically known as ``canonical``.""" existing = RECORDS.get(canonical) if existing is not None and alias != canonical and alias not in existing["aliases"]: existing["aliases"].append(alias) -def note_alias(entity, qualname): +def note_alias(entity: object, qualname: str) -> None: + """Remember ``qualname`` as an alias, attaching it once the canonical record exists.""" try: object_id = id(entity) except Exception: @@ -168,7 +185,8 @@ def note_alias(entity, qualname): PENDING.setdefault(object_id, []).append(qualname) -def record(qualname, kind, module, parent, entity, short_name): +def record(qualname: str, kind: str, module: str, parent: str, entity: object, short_name: str) -> None: + """Build and store the JSON record for one entity.""" signature, signature_source = ( get_signature(entity) if kind in {"function", "method", "class", "exception"} else (None, "n/a") ) @@ -188,7 +206,17 @@ def record(qualname, kind, module, parent, entity, short_name): } -def process(entity, qualname, parent, module_name, short_name, in_class, include_dunders, include_private): +def process( + entity: object, + qualname: str, + parent: str, + module_name: str, + short_name: str, + in_class: bool, + include_dunders: bool, + include_private: bool, +) -> None: + """Record an entity (deduping re-exports by identity) and recurse into a class's members.""" kind = kind_of(entity, in_class) if kind == "data": # Value-typed: identity is NOT meaningful (small ints / interned strings @@ -201,7 +229,6 @@ def process(entity, qualname, parent, module_name, short_name, in_class, include attach(SEEN[object_id], qualname) # re-export under another name return SEEN[object_id] = qualname - kind = kind_of(entity, in_class) record(qualname, kind, module_name, parent, entity, short_name) for alias in PENDING.pop(object_id, []): attach(qualname, alias) @@ -222,7 +249,8 @@ def process(entity, qualname, parent, module_name, short_name, in_class, include ) -def walk_module(module, include_dunders, include_private): +def walk_module(module: ModuleType, include_dunders: bool, include_private: bool) -> None: + """Record every own, non-imported member of a module.""" module_name = module.__name__ # vars() (not dir()+getattr) so we don't trigger lazy __getattr__ / property side effects. for name, value in sorted(vars(module).items()): @@ -237,7 +265,8 @@ def walk_module(module, include_dunders, include_private): process(value, f"{module_name}.{name}", module_name, module_name, name, False, include_dunders, include_private) -def main(): +def main() -> None: + """Parse arguments, introspect the stdlib, write the JSONL dump, and gate on size.""" parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("-o", "--output", default="stdlib_api.jsonl") parser.add_argument("--include-dunders", action="store_true") @@ -256,7 +285,8 @@ def main(): ) args = parser.parse_args() - scanned, failed = 0, [] + scanned = 0 + failed: list[str] = [] for name, module in roster(args.include_private): scanned += 1 if module is None: @@ -284,14 +314,14 @@ def main(): records = sorted(RECORDS.values(), key=lambda entry: entry["qualname"]) # Default encoding is cp1252 on Windows (crashes on non-ASCII docstrings) and text # mode there translates \n -> \r\n; pin UTF-8 + LF so every cell emits identical bytes. - with open(args.output, "w", encoding="utf-8", newline="\n") as out_file: + with Path(args.output).open("w", encoding="utf-8", newline="\n") as out_file: out_file.writelines(json.dumps(entry) + "\n" for entry in records) stats = _stats(records, scanned, failed) _text_summary(stats, args.output) markdown_path = args.md_summary or os.environ.get("GITHUB_STEP_SUMMARY") if markdown_path: - _markdown_summary(stats, args.output, markdown_path) + _markdown_summary(stats, markdown_path) # Gate last, after the output and summaries are written, so a broken cell still # uploads its dump and renders a summary before the non-zero exit fails the job. @@ -302,9 +332,7 @@ def main(): ) -def _stats(records, scanned, failed): - from collections import Counter - +def _stats(records: list[Record], scanned: int, failed: list[str]) -> dict[str, Any]: callables = [entry for entry in records if entry["kind"] in {"function", "method"}] return { "total_records": len(records), @@ -314,15 +342,15 @@ def _stats(records, scanned, failed): "total_callables": len(callables), "with_signature": sum(1 for entry in callables if entry["signature"]), "with_docstring": sum(1 for entry in records if entry["doc_resolved"]), - "by_module": Counter(entry["qualname"].split(".")[0] for entry in records), + "by_module": Counter(entry["qualname"].partition(".")[0] for entry in records), } -def _percent(part, whole): +def _percent(part: int, whole: int) -> str: return f"{100 * part // whole}%" if whole else "n/a" -def _text_summary(stats, output_path): +def _text_summary(stats: dict[str, Any], output_path: str) -> None: print("\n=== stdlib introspection summary =========================") print(f"Python {sys.version.split()[0]} on {sys.platform}") print(f"modules scanned : {stats['scanned']} ({len(stats['failed'])} not introspectable here)") @@ -330,24 +358,26 @@ def _text_summary(stats, output_path): print(" by kind : " + ", ".join(f"{kind}={count}" for kind, count in stats["kinds"].most_common())) if stats["total_callables"]: print( - f"callables w/ signature : {stats['with_signature']}/{stats['total_callables']} ({_percent(stats['with_signature'], stats['total_callables'])})", + f"callables w/ signature : {stats['with_signature']}/{stats['total_callables']} " + f"({_percent(stats['with_signature'], stats['total_callables'])})", ) if stats["total_records"]: print( - f"entities w/ docstring : {stats['with_docstring']}/{stats['total_records']} ({_percent(stats['with_docstring'], stats['total_records'])})", + f"entities w/ docstring : {stats['with_docstring']}/{stats['total_records']} " + f"({_percent(stats['with_docstring'], stats['total_records'])})", ) - print("\ntop 12 modules by entity count:") - for module_name, count in stats["by_module"].most_common(12): + print(f"\ntop {TOP_MODULES} modules by entity count:") + for module_name, count in stats["by_module"].most_common(TOP_MODULES): print(f" {count:5d} {module_name}") if stats["failed"]: - shown = ", ".join(stats["failed"][:18]) - more = f" (+{len(stats['failed']) - 18} more)" if len(stats["failed"]) > 18 else "" + shown = ", ".join(stats["failed"][:FAILED_PREVIEW]) + more = f" (+{len(stats['failed']) - FAILED_PREVIEW} more)" if len(stats["failed"]) > FAILED_PREVIEW else "" print(f"\nnot introspectable on this build ({len(stats['failed'])}): {shown}{more}") print(f"\nwrote {stats['total_records']} records -> {output_path}") print("=" * 58) -def _markdown_summary(stats, output_path, summary_path): +def _markdown_summary(stats: dict[str, Any], summary_path: str) -> None: python_version = sys.version.split()[0] lines = [ f"## stdlib introspection — Python {python_version} on `{sys.platform}`", @@ -361,17 +391,19 @@ def _markdown_summary(stats, output_path, summary_path): ] if stats["total_callables"]: lines.append( - f"| callables w/ signature | {stats['with_signature']}/{stats['total_callables']} ({_percent(stats['with_signature'], stats['total_callables'])}) |", + f"| callables w/ signature | {stats['with_signature']}/{stats['total_callables']} " + f"({_percent(stats['with_signature'], stats['total_callables'])}) |", ) lines.append( - f"| entities w/ docstring | {stats['with_docstring']}/{stats['total_records']} ({_percent(stats['with_docstring'], stats['total_records'])}) |", + f"| entities w/ docstring | {stats['with_docstring']}/{stats['total_records']} " + f"({_percent(stats['with_docstring'], stats['total_records'])}) |", ) lines += ["", "### Entities by kind", "", "| kind | count |", "| --- | --- |"] lines += [f"| {kind} | {count} |" for kind, count in stats["kinds"].most_common()] lines += ["", "### Top modules by entity count", "", "| module | entities |", "| --- | --- |"] - lines += [f"| `{module_name}` | {count} |" for module_name, count in stats["by_module"].most_common(12)] + lines += [f"| `{module_name}` | {count} |" for module_name, count in stats["by_module"].most_common(TOP_MODULES)] failed = stats["failed"] lines += ["", f"### Not introspectable on this build ({len(failed)})", ""] @@ -380,7 +412,7 @@ def _markdown_summary(stats, output_path, summary_path): # Append: $GITHUB_STEP_SUMMARY is an append target, and the file is fresh per step. # newline="\n" keeps the summary byte-identical on the Windows runner. - with open(summary_path, "a", encoding="utf-8", newline="\n") as summary_file: + with Path(summary_path).open("a", encoding="utf-8", newline="\n") as summary_file: summary_file.write("\n".join(lines) + "\n") diff --git a/uv.lock b/uv.lock index 98ea7f7..faf7fea 100644 --- a/uv.lock +++ b/uv.lock @@ -68,6 +68,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -93,6 +102,9 @@ wheels = [ name = "cpython-docs-compendium" version = "2026.6.20" source = { virtual = "." } +dependencies = [ + { name = "sphobjinv" }, +] [package.dev-dependencies] dev = [ @@ -104,6 +116,7 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "sphobjinv", specifier = ">=2.4" }] [package.metadata.requires-dev] dev = [ @@ -153,6 +166,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -308,6 +348,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + [[package]] name = "ruff" version = "0.15.17" @@ -333,6 +452,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] +[[package]] +name = "sphobjinv" +version = "2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "certifi" }, + { name = "jsonschema" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/67/19f33eeb4ffff0e2fd39afd4783a6ee38a6d58bcc1ddade779712a7e2e49/sphobjinv-2.4.tar.gz", hash = "sha256:44d57e7e87e17d8c7b053c853dcc36f80cbf7d1fc152d57634fd7bcae38ca48a", size = 249997, upload-time = "2026-03-23T05:04:54.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/97/840be4a99531292f8f41069bfcd0654f68296ef4089bfe828607d63ee0ff/sphobjinv-2.4-py3-none-any.whl", hash = "sha256:35f3239e9a6161c20d60146c16645687d06d43e5875d2bda71010c3ee7fd54bc", size = 51315, upload-time = "2026-03-23T05:04:42.434Z" }, +] + [[package]] name = "ty" version = "0.0.49" From 8394abec49252a9cd39633d6d7f3a5cdefbdf4cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 23:24:03 +0000 Subject: [PATCH 2/4] Keep tools/ on the 3.10 matrix floor; scope Ruff target there The stdlib-introspect matrix runs these scripts on Python 3.10-3.15, but the previous commit (and #8) left them using newer syntax that 3.10-3.13 cannot parse: - PEP 695 `type X = ...` aliases -> `TypeAlias` - PEP 758 parenthesis-free `except A, B:` -> parenthesized Add tools/ruff.toml pinning target-version = py310 (extending the root config) so Ruff lints these files at the matrix floor rather than the repo's dev requirement -- otherwise UP040 demands the `type` statement back. That lower target re-enables PERF203 (a no-op once exceptions are zero-cost), so ignore it for the tools' I/O retry/parse loops. The rest of the modernization (annotations, pathlib, itertools.pairwise, docstrings, dropped __future__ import) is already 3.10-compatible and stays. Verified: ruff/mypy/ty/prek pass on the 3.14 dev env; all three tools compile on 3.10, and stdlib_introspect + merge_summary run there. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rh5G5LSDPMWLgX3cYLWUnk --- pyproject.toml | 9 ++++++--- tools/coverage_diff.py | 4 ++-- tools/merge_summary.py | 10 +++++----- tools/ruff.toml | 5 +++++ tools/stdlib_introspect.py | 6 +++--- 5 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 tools/ruff.toml diff --git a/pyproject.toml b/pyproject.toml index 27d15a7..b380dae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,11 @@ select = [ ignore = [ "COM812", # conflicts with the formatter "ISC001", # conflicts with the formatter - "TC001", # no `from __future__ import annotations`: annotations are evaluated at runtime - "TC002", # no `from __future__ import annotations`: annotations are evaluated at runtime - "TC003", # no `from __future__ import annotations`: annotations are evaluated at runtime + # No `from __future__ import annotations`, so annotation imports are evaluated at + # runtime and cannot move into a TYPE_CHECKING block. + "TC001", + "TC002", + "TC003", ] [tool.ruff.lint.per-file-ignores] @@ -46,6 +48,7 @@ ignore = [ "T201", # CLI tools: the printed report is the deliverable "FBT", # internal recursion helpers; keyword-only bools add churn, not safety "PLR0913", # record/report builders take one parameter per output column by design + "PERF203", # the I/O retry/parse loops are not hot; try/except overhead is negligible ] "tools/stdlib_introspect.py" = ["BLE001"] # importing arbitrary stdlib modules; broad except is the point "tools/coverage_diff.py" = ["S310"] # fetches a fixed https://docs.python.org inventory URL diff --git a/tools/coverage_diff.py b/tools/coverage_diff.py index 16ffa5a..d0da30b 100755 --- a/tools/coverage_diff.py +++ b/tools/coverage_diff.py @@ -24,11 +24,11 @@ import urllib.request from collections import Counter from pathlib import Path -from typing import Any +from typing import Any, TypeAlias import sphobjinv as soi -type Record = dict[str, Any] +Record: TypeAlias = dict[str, Any] INVENTORY_URL = "https://docs.python.org/{version}/objects.inv" DEV_INVENTORY_URL = "https://docs.python.org/dev/objects.inv" diff --git a/tools/merge_summary.py b/tools/merge_summary.py index 8f335fb..410fe14 100755 --- a/tools/merge_summary.py +++ b/tools/merge_summary.py @@ -30,11 +30,11 @@ from collections import defaultdict from itertools import pairwise from pathlib import Path -from typing import Any +from typing import Any, TypeAlias -type Record = dict[str, Any] -type VersionKey = tuple[int, ...] -type Transition = tuple[VersionKey, VersionKey, list[str], list[str]] +Record: TypeAlias = dict[str, Any] +VersionKey: TypeAlias = tuple[int, ...] +Transition: TypeAlias = tuple[VersionKey, VersionKey, list[str], list[str]] # Filenames look like stdlib_api_ubuntu-latest_py3.14.jsonl. The os has no "_py" and # the version no underscore, so a non-greedy split on the single "_py" is unambiguous. @@ -106,7 +106,7 @@ def load(self) -> None: try: record = json.loads(stripped) self.records[record["qualname"]] = record - except json.JSONDecodeError, KeyError, TypeError: + except (json.JSONDecodeError, KeyError, TypeError): self.malformed += 1 # tolerate a half-written dump from a crashed cell diff --git a/tools/ruff.toml b/tools/ruff.toml new file mode 100644 index 0000000..5f8ea39 --- /dev/null +++ b/tools/ruff.toml @@ -0,0 +1,5 @@ +# These scripts run across the stdlib-introspect matrix, whose floor predates the +# repo's dev requirement -- so lint them at that floor. Otherwise Ruff would push +# idioms the oldest matrix interpreters cannot parse (e.g. UP040's `type` statement). +extend = "../pyproject.toml" +target-version = "py310" diff --git a/tools/stdlib_introspect.py b/tools/stdlib_introspect.py index 9b36ce7..25ebda9 100755 --- a/tools/stdlib_introspect.py +++ b/tools/stdlib_introspect.py @@ -32,9 +32,9 @@ from collections.abc import Iterator from pathlib import Path from types import ModuleType -from typing import Any +from typing import Any, TypeAlias -type Record = dict[str, Any] +Record: TypeAlias = dict[str, Any] # Modules with import-time side effects (browser/print) or that we never document. SKIP_MODULES = { @@ -150,7 +150,7 @@ def get_signature(entity: object) -> tuple[str | None, str]: if callable(entity): try: return str(inspect.signature(entity)), "inspect" - except ValueError, TypeError: + except (ValueError, TypeError): pass text_signature = getattr(entity, "__text_signature__", None) return (text_signature, "text_signature") if text_signature else (None, "none") From 1bd33280024a39de52f85a396948efba43dda5e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 23:34:22 +0000 Subject: [PATCH 3/4] Limit 3.10 support to stdlib_introspect; modernize the other two Only stdlib_introspect.py runs across the introspect matrix (3.10-3.15); merge_summary.py and coverage_diff.py run on the aggregate/coverage jobs' "3.x", so they don't need legacy support. The previous commit pinned all of tools/ to py310 via tools/ruff.toml, which over-applied the floor. Ruff's target-version is directory-scoped, not per-file, so instead of a directory config this scopes the one genuine conflict precisely: - stdlib_introspect.py keeps `TypeAlias` and ignores UP040 (which would demand the 3.12 `type` statement) for that file only. Its `except (ValueError, TypeError)` becomes `contextlib.suppress(...)` -- 3.10-safe, idiomatic (SIM105), and not something the py314 formatter rewrites into the parenthesis-free PEP 758 form. - merge_summary.py and coverage_diff.py go back to PEP 695 `type` aliases and lint at the repo's py314 default. Drop tools/ruff.toml and the PERF203 ignore (a no-op once try/except is zero-cost, which is where these two now lint). Verified: ruff/mypy/ty/prek pass; stdlib_introspect compiles and runs on 3.10, while the other two use modern-only syntax and run on 3.14. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rh5G5LSDPMWLgX3cYLWUnk --- pyproject.toml | 9 ++++++--- tools/coverage_diff.py | 4 ++-- tools/merge_summary.py | 10 +++++----- tools/ruff.toml | 5 ----- tools/stdlib_introspect.py | 4 +--- 5 files changed, 14 insertions(+), 18 deletions(-) delete mode 100644 tools/ruff.toml diff --git a/pyproject.toml b/pyproject.toml index b380dae..59e8bca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,10 +48,13 @@ ignore = [ "T201", # CLI tools: the printed report is the deliverable "FBT", # internal recursion helpers; keyword-only bools add churn, not safety "PLR0913", # record/report builders take one parameter per output column by design - "PERF203", # the I/O retry/parse loops are not hot; try/except overhead is negligible ] -"tools/stdlib_introspect.py" = ["BLE001"] # importing arbitrary stdlib modules; broad except is the point -"tools/coverage_diff.py" = ["S310"] # fetches a fixed https://docs.python.org inventory URL +# stdlib_introspect runs across the whole introspect matrix (older interpreters +# included), so it keeps back-compatible idioms: TypeAlias over the newer `type` +# statement (UP040). It also imports arbitrary modules, where a broad except is +# intentional (BLE001). +"tools/stdlib_introspect.py" = ["BLE001", "UP040"] +"tools/coverage_diff.py" = ["S310"] # fetches a fixed https://docs.python.org inventory URL [tool.ruff.lint.pydocstyle] convention = "numpy" diff --git a/tools/coverage_diff.py b/tools/coverage_diff.py index d0da30b..16ffa5a 100755 --- a/tools/coverage_diff.py +++ b/tools/coverage_diff.py @@ -24,11 +24,11 @@ import urllib.request from collections import Counter from pathlib import Path -from typing import Any, TypeAlias +from typing import Any import sphobjinv as soi -Record: TypeAlias = dict[str, Any] +type Record = dict[str, Any] INVENTORY_URL = "https://docs.python.org/{version}/objects.inv" DEV_INVENTORY_URL = "https://docs.python.org/dev/objects.inv" diff --git a/tools/merge_summary.py b/tools/merge_summary.py index 410fe14..8f335fb 100755 --- a/tools/merge_summary.py +++ b/tools/merge_summary.py @@ -30,11 +30,11 @@ from collections import defaultdict from itertools import pairwise from pathlib import Path -from typing import Any, TypeAlias +from typing import Any -Record: TypeAlias = dict[str, Any] -VersionKey: TypeAlias = tuple[int, ...] -Transition: TypeAlias = tuple[VersionKey, VersionKey, list[str], list[str]] +type Record = dict[str, Any] +type VersionKey = tuple[int, ...] +type Transition = tuple[VersionKey, VersionKey, list[str], list[str]] # Filenames look like stdlib_api_ubuntu-latest_py3.14.jsonl. The os has no "_py" and # the version no underscore, so a non-greedy split on the single "_py" is unambiguous. @@ -106,7 +106,7 @@ def load(self) -> None: try: record = json.loads(stripped) self.records[record["qualname"]] = record - except (json.JSONDecodeError, KeyError, TypeError): + except json.JSONDecodeError, KeyError, TypeError: self.malformed += 1 # tolerate a half-written dump from a crashed cell diff --git a/tools/ruff.toml b/tools/ruff.toml deleted file mode 100644 index 5f8ea39..0000000 --- a/tools/ruff.toml +++ /dev/null @@ -1,5 +0,0 @@ -# These scripts run across the stdlib-introspect matrix, whose floor predates the -# repo's dev requirement -- so lint them at that floor. Otherwise Ruff would push -# idioms the oldest matrix interpreters cannot parse (e.g. UP040's `type` statement). -extend = "../pyproject.toml" -target-version = "py310" diff --git a/tools/stdlib_introspect.py b/tools/stdlib_introspect.py index 25ebda9..00049b4 100755 --- a/tools/stdlib_introspect.py +++ b/tools/stdlib_introspect.py @@ -148,10 +148,8 @@ def kind_of(entity: object, in_class: bool) -> str: def get_signature(entity: object) -> tuple[str | None, str]: """Return (signature_text, source) where source is ``inspect``/``text_signature``/``none``.""" if callable(entity): - try: + with contextlib.suppress(ValueError, TypeError): return str(inspect.signature(entity)), "inspect" - except (ValueError, TypeError): - pass text_signature = getattr(entity, "__text_signature__", None) return (text_signature, "text_signature") if text_signature else (None, "none") From ea2b107d4ff6e744ebff25c62525df106fd1db29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 23:44:29 +0000 Subject: [PATCH 4/4] Address PR review: implicit string concat + http_get guard - CodeQL (implicit-string-concatenation-in-list): the line-wrapped prose strings in the report() blocks are now extracted to named locals (no_cells, deltas_intro, core_breakdown), matching the no_versions / intro style already used elsewhere. No behavior change; clears the alert and the missing-comma ambiguity. - Copilot (coverage_diff.http_get): guard `attempts < 1` with a ValueError so a bad argument no longer falls through to the end-of-function AssertionError, which is now genuinely unreachable. ruff/mypy/ty pass; merge_summary and coverage_diff still run end-to-end. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rh5G5LSDPMWLgX3cYLWUnk --- tools/coverage_diff.py | 12 +++++++++--- tools/merge_summary.py | 17 ++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/tools/coverage_diff.py b/tools/coverage_diff.py index 16ffa5a..8df47bc 100755 --- a/tools/coverage_diff.py +++ b/tools/coverage_diff.py @@ -108,6 +108,9 @@ def load_union(path: str) -> list[Record]: def http_get(url: str, attempts: int = 4) -> bytes: """GET ``url``, retrying transient URL errors with exponential backoff.""" + if attempts < 1: + message = "attempts must be >= 1" + raise ValueError(message) for attempt in range(attempts): try: request = urllib.request.Request(url, headers={"User-Agent": "coverage-diff"}) @@ -120,7 +123,7 @@ def http_get(url: str, attempts: int = 4) -> bytes: if attempt == attempts - 1: raise time.sleep(2**attempt) - raise AssertionError # unreachable while attempts >= 1 + raise AssertionError # unreachable: the guard forces attempts >= 1, so the last attempt returns or raises def documented_names(version: str, inventory_dir: str | None = None) -> tuple[set[str], bool]: @@ -324,11 +327,14 @@ def report( ) lines.append("") + core_breakdown = ( + f"Reference-entry core (callables/classes/etc.): **{len(gap_rows) - data_entries}**; " + f"`data` entries: **{data_entries}**." + ) lines += [ f"## Missing from the official {target} docs — {len(gap_rows)} undocumented", "", - f"Reference-entry core (callables/classes/etc.): **{len(gap_rows) - data_entries}**; " - f"`data` entries: **{data_entries}**.", + core_breakdown, "", "| kind | count |", "| --- | ---: |", diff --git a/tools/merge_summary.py b/tools/merge_summary.py index 8f335fb..6140627 100755 --- a/tools/merge_summary.py +++ b/tools/merge_summary.py @@ -218,11 +218,11 @@ def report( lines = ["# stdlib introspection — cross-platform union", ""] if not cells: - lines += [ + no_cells = ( "> **No cell artifacts were found.** Every matrix cell failed to produce " - "a dump, or the download step pulled nothing.", - "", - ] + "a dump, or the download step pulled nothing." + ) + lines += [no_cells, ""] else: versions = sorted({format_version(cell.version_key) for cell in cells}, key=version_key) lines += [ @@ -252,12 +252,15 @@ def report( if transitions: floor_label = format_version(version_keys[0]) + deltas_intro = ( + f"An entity is present in a minor if it appears in **any** OS cell for it. " + f"`added_in` for entities already present in {floor_label} is recorded as " + f"`<={floor_label}` — the matrix floor bounds it." + ) lines += [ "## Per-version deltas (OS-collapsed, adjacent minors)", "", - f"An entity is present in a minor if it appears in **any** OS cell for it. " - f"`added_in` for entities already present in {floor_label} is recorded as " - f"`<={floor_label}` — the matrix floor bounds it.", + deltas_intro, "", "| transition | added | removed |", "| --- | ---: | ---: |",