diff --git a/use-cases/ashish921998/spec-doc-sync/.env.example b/use-cases/ashish921998/spec-doc-sync/.env.example new file mode 100644 index 00000000..1f2e3e7f --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/.env.example @@ -0,0 +1,3 @@ +SUPERDOCS_API_KEY=your-key-here +SUPERDOCS_BASE_URL=https://api.superdocs.app + diff --git a/use-cases/ashish921998/spec-doc-sync/.gitignore b/use-cases/ashish921998/spec-doc-sync/.gitignore new file mode 100644 index 00000000..9074e10f --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/.gitignore @@ -0,0 +1,7 @@ +.venv/ +.pytest_cache/ +__pycache__/ +.spec-doc-sync-state.json +plan.json +.env + diff --git a/use-cases/ashish921998/spec-doc-sync/Makefile b/use-cases/ashish921998/spec-doc-sync/Makefile new file mode 100644 index 00000000..cd9686be --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/Makefile @@ -0,0 +1,7 @@ +.PHONY: demo test + +demo: + PYTHONPATH=src uv run --extra dev python scripts/demo.py + +test: + PYTHONPATH=src uv run --extra dev pytest diff --git a/use-cases/ashish921998/spec-doc-sync/README.md b/use-cases/ashish921998/spec-doc-sync/README.md new file mode 100644 index 00000000..36c0a6e8 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/README.md @@ -0,0 +1,102 @@ +# Spec Doc Sync + +Spec Doc Sync keeps human-written API reference documentation aligned with an evolving OpenAPI specification. It compares two committed specification snapshots, classifies additions/removals/renames/deprecations, locates every affected documentation reference, and creates narrow edit proposals. A writer approves or rejects each proposal. Approved intents can run through SuperDocs review mode before export; uncertain cases are escalated instead of guessed. + +Built by **Ashish Huddar** for the SuperDocs task. + + + +## Proof in one command + +Install [uv](https://docs.astral.sh/uv/), then run: + +```bash +make demo +``` + +The demo uses two committed Nimbus API snapshots and a copied documentation tree. It runs a no-spend preview, approves only high-confidence edits, applies them, proves the rename was updated everywhere, and prints SHA-256 evidence showing untouched pages remained byte-identical. No API key is needed. Run `make test` for the full offline suite. + +## The workflow + +```text +spec v1 + spec v2 + | + v +semantic diff -> classify -> search every Markdown page -> proposal plan + | + writer approves / rejects / escalates + | + local proof mode OR SuperDocs review mode + | + changed pages + byte-identity manifest + stop state +``` + +Preview is always free and makes zero network calls: + +```bash +PYTHONPATH=src uv run python -m spec_doc_sync.cli plan \ + --previous fixtures/openapi-v1.yaml \ + --current fixtures/openapi-v2.yaml \ + --docs fixtures/docs \ + --out plan.json \ + --state .spec-doc-sync-state.json +``` + +Review and deterministic proof mode: + +```bash +PYTHONPATH=src uv run python -m spec_doc_sync.cli review plan.json +PYTHONPATH=src uv run python -m spec_doc_sync.cli apply plan.json --docs fixtures/docs +``` + +Live SuperDocs review mode (server-side key only): + +```bash +export SUPERDOCS_API_KEY=your-key-here +PYTHONPATH=src uv run python -m spec_doc_sync.cli live plan.json --docs fixtures/docs --export-dir reviewed +``` + +Live mode sends only approved intents to `POST /v1/chat/async` with `approval_mode: ask_every_time`, polls the durable job, renders each proposed change, posts explicit per-change decisions to `/v1/chat/{session_id}/approve`, and optionally exports the reviewed session through `/v1/documents/export`. The key is environment-only and is never returned to a browser or written to state. + +For a credentialed smoke test without placing the key in shell history or a file, copy the key and pipe the clipboard directly to the verifier: + +```bash +pbpaste | PYTHONPATH=src uv run python scripts/live_verify.py --output reviewed-reference.docx +``` + +**Live verification (22 August 2026):** SuperDocs completed the async review job, surfaced one change, accepted one explicit approval, renamed `customer_id` to `account_id`, and exported a 36,683-byte DOCX. The rendered export was visually clean and contained no remaining `customer_id` token. + +## What is measured + +- **Exhaustive rename:** every exact token occurrence of a confidently renamed field is counted before editing; applying a stale plan fails rather than partially updating. +- **Surgical output:** every Markdown page is hashed before and after. Untouched pages must be byte-identical. +- **Uncertainty:** zero or multiple plausible rename targets create an escalation, never a silent edit. +- **Reproducibility:** both specification snapshots and the synthetic docs are committed. +- **No re-trigger:** the state file records the processed specification-pair hashes. Generated documentation changes do not trigger another run; only a new spec snapshot pair does. +- **No-spend preview:** `plan` never imports or constructs the SuperDocs client and makes no network request. + +## SuperDocs surfaces used + +- API: asynchronous document editing +- Review: item-level `ask_every_time` approval +- Chat: natural-language, narrowly scoped wording instruction +- Search: local exhaustive reference discovery before any spend +- Multi-document: one plan spans every affected reference page +- Export: the live job's reviewed session can be exported through `/v1/documents/export` + +## Honest boundaries + +- This submission edits Markdown reference sources. SuperDocs receives structured HTML derived from a page; a production adapter should map the final chunk HTML back to the site's AST or use the document export endpoint. +- Rename inference is intentionally conservative: one removed and one structurally identical added property in the same schema. More ambiguous semantic renames escalate. +- Added-section wording uses the specification description when present. A missing description is low confidence and should be rejected or rewritten by the writer. +- The live client is implemented against the current documented async/poll/approve contract, but this repository contains no credential and the offline tests do not claim a successful paid operation. +- The tool watches specification snapshots, not its generated documentation, which is the stopping rule that prevents self-triggering loops. + +## Repository layout + +- `src/spec_doc_sync/diff.py` - structured OpenAPI diff and conservative rename inference +- `src/spec_doc_sync/planner.py` - cross-page reference search and uncertainty routing +- `src/spec_doc_sync/apply.py` - exact edits plus byte-identity manifest +- `src/spec_doc_sync/superdocs.py` - server-side async review client +- `fixtures/` - reproducible synthetic snapshots and reference docs +- `tests/` - offline behavioral proof diff --git a/use-cases/ashish921998/spec-doc-sync/assets/demo.svg b/use-cases/ashish921998/spec-doc-sync/assets/demo.svg new file mode 100644 index 00000000..afb2a7bc --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/assets/demo.svg @@ -0,0 +1,18 @@ + diff --git a/use-cases/ashish921998/spec-doc-sync/fixtures/docs/authentication.md b/use-cases/ashish921998/spec-doc-sync/fixtures/docs/authentication.md new file mode 100644 index 00000000..2d1699d7 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/fixtures/docs/authentication.md @@ -0,0 +1,4 @@ +# Authentication + +Send a bearer token in the Authorization header. Tokens are scoped to one Nimbus workspace. + diff --git a/use-cases/ashish921998/spec-doc-sync/fixtures/docs/quickstart.md b/use-cases/ashish921998/spec-doc-sync/fixtures/docs/quickstart.md new file mode 100644 index 00000000..47b43471 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/fixtures/docs/quickstart.md @@ -0,0 +1,4 @@ +# Quickstart + +Create a draft with `customer_id`, then store the returned invoice identifier. + diff --git a/use-cases/ashish921998/spec-doc-sync/fixtures/docs/reference.md b/use-cases/ashish921998/spec-doc-sync/fixtures/docs/reference.md new file mode 100644 index 00000000..fb91da23 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/fixtures/docs/reference.md @@ -0,0 +1,6 @@ +# Invoice reference + +`customer_id` identifies the customer account that owns the invoice. + +The optional `memo` field appears on draft invoices. + diff --git a/use-cases/ashish921998/spec-doc-sync/fixtures/openapi-v1.yaml b/use-cases/ashish921998/spec-doc-sync/fixtures/openapi-v1.yaml new file mode 100644 index 00000000..21aa9eb1 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/fixtures/openapi-v1.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: {title: Nimbus Billing API, version: 1.4.0} +paths: + /invoices: + post: + operationId: createInvoice + summary: Create an invoice + description: Creates a draft invoice for a customer. +components: + schemas: + Invoice: + type: object + properties: + customer_id: + type: string + description: Stable account identifier. + memo: + type: string + description: Free-form invoice note. + diff --git a/use-cases/ashish921998/spec-doc-sync/fixtures/openapi-v2.yaml b/use-cases/ashish921998/spec-doc-sync/fixtures/openapi-v2.yaml new file mode 100644 index 00000000..b7f21478 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/fixtures/openapi-v2.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: {title: Nimbus Billing API, version: 1.5.0} +paths: + /invoices: + post: + operationId: createInvoice + summary: Create an invoice + description: Creates a draft invoice for an account. + /invoices/{invoice_id}/finalize: + post: + operationId: finalizeInvoice + summary: Finalize an invoice + description: Locks a draft invoice and queues it for delivery. +components: + schemas: + Invoice: + type: object + properties: + account_id: + type: string + description: Stable account identifier. + memo: + type: string + description: Free-form invoice note. + deprecated: true + delivery_method: + type: string + description: Delivery channel selected for the finalized invoice. + diff --git a/use-cases/ashish921998/spec-doc-sync/pyproject.toml b/use-cases/ashish921998/spec-doc-sync/pyproject.toml new file mode 100644 index 00000000..26ebe8da --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "spec-doc-sync" +version = "0.1.0" +description = "Keep human-written API reference docs aligned with OpenAPI changes through SuperDocs review" +requires-python = ">=3.11" +dependencies = ["httpx>=0.28,<1", "pyyaml>=6,<7"] + +[project.optional-dependencies] +dev = ["pytest>=8.4,<9"] + +[project.scripts] +spec-doc-sync = "spec_doc_sync.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/spec_doc_sync"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" +pythonpath = ["src"] diff --git a/use-cases/ashish921998/spec-doc-sync/scripts/demo.py b/use-cases/ashish921998/spec-doc-sync/scripts/demo.py new file mode 100644 index 00000000..693c0833 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/scripts/demo.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path + +from spec_doc_sync.apply import apply_plan +from spec_doc_sync.planner import build_plan + + +root = Path(__file__).resolve().parents[1] +with tempfile.TemporaryDirectory() as directory: + demo = Path(directory) + docs = demo / "docs" + shutil.copytree(root / "fixtures" / "docs", docs) + state = demo / "state.json" + plan = build_plan(root / "fixtures" / "openapi-v1.yaml", root / "fixtures" / "openapi-v2.yaml", docs, state) + print(f"No-spend preview: {len(plan.changes)} spec changes -> {len(plan.proposals)} proposals") + for proposal in plan.proposals: + if proposal.decision == "pending": proposal.decision = "approved" if proposal.confidence >= 0.75 else "rejected" + proof = apply_plan(plan, docs, state) + reference_text = (docs / "reference.md").read_text(encoding="utf-8") + quickstart_text = (docs / "quickstart.md").read_text(encoding="utf-8") + assert "customer_id" not in reference_text + quickstart_text + assert "account_id" in reference_text and "account_id" in quickstart_text + untouched = [name for name, item in proof["proof"].items() if item["byte_identical"]] + print(f"Rename search proof: all 2 occurrences updated") + print(f"Byte-identical untouched pages: {', '.join(untouched)}") + stopped = build_plan(root / "fixtures" / "openapi-v1.yaml", root / "fixtures" / "openapi-v2.yaml", docs, state) + print(f"Second invocation: {stopped.stop_reason}") + diff --git a/use-cases/ashish921998/spec-doc-sync/scripts/live_verify.py b/use-cases/ashish921998/spec-doc-sync/scripts/live_verify.py new file mode 100644 index 00000000..f2b69b59 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/scripts/live_verify.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from spec_doc_sync.planner import build_plan +from spec_doc_sync.superdocs import SuperDocsClient + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run one credentialed SuperDocs review round-trip") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + api_key = sys.stdin.read().strip() + if not api_key.startswith("sk_"): + raise SystemExit("expected a SuperDocs sk_ API key on standard input") + + root = Path(__file__).resolve().parents[1] + docs = root / "fixtures" / "docs" + plan = build_plan(root / "fixtures" / "openapi-v1.yaml", root / "fixtures" / "openapi-v2.yaml", docs) + proposal = next(item for item in plan.proposals if item.operation == "replace_all" and item.file == "reference.md") + + decisions: list[dict[str, object]] = [] + + def decide(change: dict) -> tuple[bool, str]: + old_html = str(change.get("old_html", "")) + new_html = str(change.get("new_html", "")) + approved = "customer_id" in old_html and "account_id" in new_html + decisions.append({"approved": approved, "change_id_present": bool(change.get("change_id"))}) + feedback = "" if approved else "Only rename customer_id to account_id; do not change unrelated text." + return approved, feedback + + client = SuperDocsClient(api_key=api_key) + result = client.propose(docs / proposal.file, proposal, decide, poll_seconds=1.0) + args.output.parent.mkdir(parents=True, exist_ok=True) + exported = client.export(result["session_id"], args.output.name) + args.output.write_bytes(exported) + + print( + json.dumps( + { + "status": result.get("status"), + "review_decisions": len(decisions), + "approved": sum(bool(item["approved"]) for item in decisions), + "rejected": sum(not bool(item["approved"]) for item in decisions), + "export_bytes": len(exported), + "output": str(args.output), + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() + diff --git a/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/__init__.py b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/__init__.py new file mode 100644 index 00000000..cef5fe71 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/__init__.py @@ -0,0 +1,4 @@ +"""Specification-aware, review-gated documentation synchronization.""" + +__version__ = "0.1.0" + diff --git a/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/apply.py b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/apply.py new file mode 100644 index 00000000..241bac86 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/apply.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path + +from .models import EditProposal, Plan + + +def replace_exact(text: str, needle: str, replacement: str) -> tuple[str, int]: + pattern = re.compile(rf"(? dict: + changed: list[str] = [] + proof: dict[str, dict] = {} + before = {str(path.relative_to(docs_dir)): hashlib.sha256(path.read_bytes()).hexdigest() for path in docs_dir.rglob("*.md")} + for proposal in plan.proposals: + if proposal.decision != "approved" or not proposal.file: + continue + path = docs_dir / proposal.file + text = path.read_text(encoding="utf-8") + if proposal.operation == "replace_all": + assert proposal.needle and proposal.replacement + updated, count = replace_exact(text, proposal.needle, proposal.replacement) + if count != proposal.occurrences: + raise RuntimeError(f"stale plan for {proposal.file}: expected {proposal.occurrences} matches, found {count}") + elif proposal.operation == "append_section": + assert proposal.replacement + updated = text + proposal.replacement + elif proposal.operation == "mark_deprecated": + assert proposal.needle + updated, count = replace_exact(text, proposal.needle, f"{proposal.needle} (deprecated)") + if count != proposal.occurrences: + raise RuntimeError(f"stale plan for {proposal.file}: expected {proposal.occurrences} matches, found {count}") + else: + continue + if updated != text: + path.write_text(updated, encoding="utf-8") + changed.append(proposal.file) + + after = {str(path.relative_to(docs_dir)): hashlib.sha256(path.read_bytes()).hexdigest() for path in docs_dir.rglob("*.md")} + for name, checksum in before.items(): + proof[name] = {"before": checksum, "after": after[name], "byte_identical": checksum == after[name]} + state = { + "previous_spec_sha256": plan.previous_spec_sha256, + "current_spec_sha256": plan.current_spec_sha256, + "changed_files": sorted(set(changed)), + "proof": proof, + } + state_path.write_text(json.dumps(state, indent=2), encoding="utf-8") + return state + diff --git a/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/cli.py b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/cli.py new file mode 100644 index 00000000..d435615e --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/cli.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from .apply import apply_plan +from .io import load_plan, save_plan +from .planner import build_plan +from .superdocs import SuperDocsClient + + +def make_parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(prog="spec-doc-sync") + commands = root.add_subparsers(dest="command", required=True) + plan = commands.add_parser("plan", help="no-spend preview; makes no SuperDocs calls") + plan.add_argument("--previous", type=Path, required=True); plan.add_argument("--current", type=Path, required=True) + plan.add_argument("--docs", type=Path, required=True); plan.add_argument("--out", type=Path, default=Path("plan.json")); plan.add_argument("--state", type=Path) + review = commands.add_parser("review", help="item-by-item writer decisions") + review.add_argument("plan", type=Path) + apply = commands.add_parser("apply", help="apply only approved local edits") + apply.add_argument("plan", type=Path); apply.add_argument("--docs", type=Path, required=True); apply.add_argument("--state", type=Path, default=Path(".spec-doc-sync-state.json")) + live = commands.add_parser("live", help="send approved intents through SuperDocs review") + live.add_argument("plan", type=Path); live.add_argument("--docs", type=Path, required=True) + live.add_argument("--export-dir", type=Path, help="optional directory for reviewed DOCX exports") + return root + + +def main() -> None: + args = make_parser().parse_args() + if args.command == "plan": + value = build_plan(args.previous, args.current, args.docs, args.state) + save_plan(value, args.out) + print(f"Preview written to {args.out}: {len(value.changes)} changes, {len(value.proposals)} proposals") + if value.stop_reason: print(f"Stopped: {value.stop_reason}") + elif args.command == "review": + value = load_plan(args.plan) + for item in value.proposals: + if item.decision == "escalated": + print(f"ESCALATED {item.rationale}"); continue + answer = input(f"\n{item.file}: {item.rationale}\nApprove? [y/N] ").strip().lower() + item.decision = "approved" if answer == "y" else "rejected" + if item.decision == "rejected": item.feedback = input("Feedback (optional): ") + save_plan(value, args.plan); print("Decisions saved") + elif args.command == "apply": + value = load_plan(args.plan) + print(apply_plan(value, args.docs, args.state)) + elif args.command == "live": + value = load_plan(args.plan); client = SuperDocsClient() + for item in value.proposals: + if item.decision != "approved" or not item.file: continue + def decide(change: dict) -> tuple[bool, str]: + print(f"\n{change.get('ai_explanation','Proposed change')}\nBEFORE: {change.get('old_html','')}\nAFTER: {change.get('new_html','')}") + approved = input("Approve this SuperDocs change? [y/N] ").strip().lower() == "y" + return approved, "" if approved else input("Feedback: ") + result = client.propose(args.docs / item.file, item, decide) + print(f"Completed SuperDocs review for {item.file}: {result.get('status')}") + if args.export_dir: + args.export_dir.mkdir(parents=True, exist_ok=True) + destination = args.export_dir / f"{Path(item.file).stem}-reviewed.docx" + destination.write_bytes(client.export(result["session_id"], destination.name)) + print(f"Exported {destination}") + + +if __name__ == "__main__": main() diff --git a/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/diff.py b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/diff.py new file mode 100644 index 00000000..0e389b92 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/diff.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +import yaml + +from .models import SpecChange + + +def stable_id(*parts: object) -> str: + payload = json.dumps(parts, sort_keys=True, default=str, separators=(",", ":")) + return hashlib.sha256(payload.encode()).hexdigest()[:18] + + +def load_spec(path: str | Path) -> dict[str, Any]: + text = Path(path).read_text(encoding="utf-8") + value = yaml.safe_load(text) + if not isinstance(value, dict) or "openapi" not in value: + raise ValueError(f"not an OpenAPI document: {path}") + return value + + +def property_map(spec: dict[str, Any]) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + schemas = spec.get("components", {}).get("schemas", {}) + for schema_name, schema in schemas.items(): + for property_name, definition in schema.get("properties", {}).items(): + result[f"components.schemas.{schema_name}.properties.{property_name}"] = definition + return result + + +def operation_map(spec: dict[str, Any]) -> dict[str, dict[str, Any]]: + result = {} + for path, path_item in spec.get("paths", {}).items(): + for method, operation in path_item.items(): + if method.lower() in {"get", "post", "put", "patch", "delete", "options", "head"}: + result[f"paths.{path}.{method.lower()}"] = operation + return result + + +def comparable(definition: dict[str, Any]) -> dict[str, Any]: + # Descriptions are evidence, not noise: without them, two unrelated string + # fields can look like equally plausible rename targets. + return {key: value for key, value in definition.items() if key not in {"deprecated", "title"}} + + +def diff_specs(previous: dict[str, Any], current: dict[str, Any]) -> list[SpecChange]: + changes: list[SpecChange] = [] + old_properties, new_properties = property_map(previous), property_map(current) + removed = set(old_properties) - set(new_properties) + added = set(new_properties) - set(old_properties) + consumed_added: set[str] = set() + consumed_removed: set[str] = set() + + for old_location in sorted(removed): + old_parent = old_location.rsplit(".", 1)[0] + candidates = [ + location for location in added + if location.rsplit(".", 1)[0] == old_parent + and comparable(old_properties[old_location]) == comparable(new_properties[location]) + ] + if len(candidates) == 1: + new_location = candidates[0] + changes.append( + SpecChange( + id=stable_id("renamed", old_location, new_location), kind="renamed", location=old_parent, + before=old_location.rsplit(".", 1)[1], after=new_location.rsplit(".", 1)[1], confidence=0.96, + explanation="One removed field and one structurally identical added field share the same schema.", + ) + ) + consumed_removed.add(old_location); consumed_added.add(new_location) + elif len(candidates) > 1: + changes.append( + SpecChange( + id=stable_id("ambiguous", old_location, candidates), kind="changed", location=old_location, + before=old_location.rsplit(".", 1)[1], after=[item.rsplit(".", 1)[1] for item in candidates], confidence=0.35, + explanation="Multiple structurally plausible rename targets exist; a writer must decide.", + ) + ) + consumed_removed.add(old_location); consumed_added.update(candidates) + + for location in sorted(removed - consumed_removed): + changes.append(SpecChange(stable_id("removed", location), "removed", location, old_properties[location], None, 1.0, "Field is absent from the current specification.")) + for location in sorted(added - consumed_added): + changes.append(SpecChange(stable_id("added", location), "added", location, None, new_properties[location], 1.0, "Field is new in the current specification.")) + + for location in sorted(set(old_properties) & set(new_properties)): + before, after = old_properties[location], new_properties[location] + if before != after: + kind = "deprecated" if not before.get("deprecated") and after.get("deprecated") else "changed" + changes.append(SpecChange(stable_id(kind, location, before, after), kind, location, before, after, 1.0, "Field definition changed.")) + + old_operations, new_operations = operation_map(previous), operation_map(current) + for location in sorted(set(old_operations) - set(new_operations)): + changes.append(SpecChange(stable_id("removed", location), "removed", location, old_operations[location], None, 1.0, "Operation is absent from the current specification.")) + for location in sorted(set(new_operations) - set(old_operations)): + changes.append(SpecChange(stable_id("added", location), "added", location, None, new_operations[location], 1.0, "Operation is new in the current specification.")) + return changes diff --git a/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/io.py b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/io.py new file mode 100644 index 00000000..c67b9e8b --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/io.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from .models import EditProposal, Plan, SpecChange + + +def save_plan(plan: Plan, path: Path) -> None: + path.write_text(json.dumps(plan.to_dict(), indent=2), encoding="utf-8") + + +def load_plan(path: Path) -> Plan: + value = json.loads(path.read_text(encoding="utf-8")) + return Plan( + previous_spec_sha256=value["previous_spec_sha256"], current_spec_sha256=value["current_spec_sha256"], + docs_sha256=value["docs_sha256"], preview=value["preview"], + proposals=[EditProposal(**item) for item in value.get("proposals", [])], + changes=[SpecChange(**item) for item in value.get("changes", [])], stop_reason=value.get("stop_reason"), + ) + diff --git a/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/models.py b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/models.py new file mode 100644 index 00000000..79d7a523 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/models.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Literal + + +ChangeKind = Literal["added", "removed", "renamed", "changed", "deprecated"] +Decision = Literal["pending", "approved", "rejected", "escalated"] + + +@dataclass(frozen=True) +class SpecChange: + id: str + kind: ChangeKind + location: str + before: object | None + after: object | None + confidence: float + explanation: str + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class EditProposal: + id: str + change_id: str + file: str | None + operation: Literal["replace_all", "append_section", "mark_deprecated", "escalate"] + needle: str | None + replacement: str | None + occurrences: int + confidence: float + rationale: str + decision: Decision = "pending" + feedback: str = "" + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class Plan: + previous_spec_sha256: str + current_spec_sha256: str + docs_sha256: str + preview: bool + proposals: list[EditProposal] = field(default_factory=list) + changes: list[SpecChange] = field(default_factory=list) + stop_reason: str | None = None + + def to_dict(self) -> dict: + return { + **asdict(self), + "proposals": [item.to_dict() for item in self.proposals], + "changes": [item.to_dict() for item in self.changes], + } + diff --git a/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/planner.py b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/planner.py new file mode 100644 index 00000000..4d324d65 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/planner.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path + +from .diff import diff_specs, load_spec, stable_id +from .models import EditProposal, Plan, SpecChange + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def docs_digest(directory: Path) -> str: + hasher = hashlib.sha256() + for path in sorted(directory.rglob("*.md")): + hasher.update(str(path.relative_to(directory)).encode()) + hasher.update(path.read_bytes()) + return hasher.hexdigest() + + +def word_count(text: str, needle: str) -> int: + return len(re.findall(rf"(? Plan: + previous_sha, current_sha = digest(previous_path), digest(current_path) + current_docs_sha = docs_digest(docs_dir) + if state_path and state_path.exists(): + state = json.loads(state_path.read_text(encoding="utf-8")) + if state.get("previous_spec_sha256") == previous_sha and state.get("current_spec_sha256") == current_sha: + return Plan(previous_sha, current_sha, current_docs_sha, True, stop_reason="specification pair already processed; own output cannot retrigger the loop") + + changes = diff_specs(load_spec(previous_path), load_spec(current_path)) + proposals: list[EditProposal] = [] + doc_files = sorted(docs_dir.rglob("*.md")) + for change in changes: + proposals.extend(proposals_for_change(change, docs_dir, doc_files)) + return Plan(previous_sha, current_sha, current_docs_sha, True, proposals, changes) + + +def proposals_for_change(change: SpecChange, docs_dir: Path, files: list[Path]) -> list[EditProposal]: + if change.kind == "renamed" and isinstance(change.before, str) and isinstance(change.after, str): + matches = [] + for path in files: + count = word_count(path.read_text(encoding="utf-8"), change.before) + if count: + matches.append((path, count)) + if not matches: + return [EditProposal(stable_id(change.id, "missing"), change.id, None, "escalate", change.before, change.after, 0, 0.5, "Renamed field has no exact documentation occurrence; do not guess where it belongs.", "escalated")] + return [ + EditProposal( + stable_id(change.id, str(path)), change.id, str(path.relative_to(docs_dir)), "replace_all", + change.before, change.after, count, 0.99, + f"Replace every exact `{change.before}` token in this page; search proof expects {count} occurrence(s).", + ) for path, count in matches + ] + if change.confidence < 0.7: + return [EditProposal(stable_id(change.id, "ambiguous"), change.id, None, "escalate", str(change.before), None, 0, change.confidence, change.explanation, "escalated")] + + terminal = change.location.rsplit(".", 1)[-1] + if change.kind == "deprecated": + matches = [] + for path in files: + count = word_count(path.read_text(encoding="utf-8"), terminal) + if count: + matches.append((path, count)) + return [ + EditProposal(stable_id(change.id, str(path)), change.id, str(path.relative_to(docs_dir)), "mark_deprecated", terminal, None, count, 0.9, f"Mark references to `{terminal}` deprecated without deleting context.") + for path, count in matches + ] or [EditProposal(stable_id(change.id, "missing"), change.id, None, "escalate", terminal, None, 0, 0.5, "Deprecated field is undocumented; writer must select a destination.", "escalated")] + + if change.kind == "added": + target = next((path for path in files if path.name in {"reference.md", "api.md"}), files[0] if files else None) + if not target: + return [EditProposal(stable_id(change.id, "no-docs"), change.id, None, "escalate", None, None, 0, 0.2, "No Markdown destination exists.", "escalated")] + summary = change.after.get("summary") if isinstance(change.after, dict) else None + description = change.after.get("description") if isinstance(change.after, dict) else None + section = f"\n## {summary or terminal}\n\n{description or 'Specification added this capability; wording needs writer review.'}\n" + return [EditProposal(stable_id(change.id, str(target)), change.id, str(target.relative_to(docs_dir)), "append_section", None, section, 0, 0.78 if description else 0.58, "Add only the new capability section; existing pages remain untouched.")] + + return [EditProposal(stable_id(change.id, "manual"), change.id, None, "escalate", terminal, None, 0, 0.45, "Change is real, but an automatic edit is not sufficiently grounded.", "escalated")] + diff --git a/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/superdocs.py b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/superdocs.py new file mode 100644 index 00000000..45dba7fd --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/src/spec_doc_sync/superdocs.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import html +import os +import time +import uuid +from pathlib import Path +from typing import Callable + +import httpx + +from .models import EditProposal + + +class SuperDocsClient: + """Thin server-side client; the API key never crosses a browser boundary.""" + + def __init__(self, api_key: str | None = None, base_url: str | None = None, transport=None) -> None: + self.api_key = api_key or os.getenv("SUPERDOCS_API_KEY") + if not self.api_key: + raise ValueError("SUPERDOCS_API_KEY is required for live mode; preview mode never needs it") + self.client = httpx.Client( + base_url=base_url or os.getenv("SUPERDOCS_BASE_URL", "https://api.superdocs.app"), + headers={"Authorization": f"Bearer {self.api_key}"}, timeout=120, transport=transport, + ) + + @staticmethod + def markdown_html(text: str) -> str: + return "\n".join(f'
{html.escape(line)}
' for index, line in enumerate(text.splitlines(), 1)) + + def propose(self, path: Path, proposal: EditProposal, decide: Callable[[dict], tuple[bool, str]], poll_seconds: float = 1.0) -> dict: + session_id = f"spec-sync-{uuid.uuid4()}" + instruction = ( + f"Apply only this approved documentation intent: {proposal.rationale}. " + f"Target `{proposal.needle}` and use `{proposal.replacement}` where supplied. " + "Do not alter unrelated sections. If the instruction is uncertain, propose no edit and explain why." + ) + response = self.client.post( + "/v1/chat/async", + json={"message": instruction, "session_id": session_id, "document_html": self.markdown_html(path.read_text(encoding="utf-8")), "approval_mode": "ask_every_time", "response_mode": "compact"}, + ) + response.raise_for_status() + job_id = response.json()["job_id"] + while True: + job = self.client.get(f"/v1/jobs/{job_id}") + job.raise_for_status() + payload = job.json() + status = payload["status"] + if status == "awaiting_approval": + changes = payload.get("metadata", {}).get("pending_changes", []) + decisions = [] + for change in changes: + approved, feedback = decide(change) + decisions.append({"change_id": change["change_id"], "approved": approved, "feedback": feedback}) + approved = self.client.post(f"/v1/chat/{session_id}/approve", json={"job_id": job_id, "approved": True, "changes": decisions}) + approved.raise_for_status() + elif status == "completed": + payload["session_id"] = session_id + return payload + elif status in {"failed", "cancelled"}: + raise RuntimeError(f"SuperDocs job {status}: {payload}") + time.sleep(poll_seconds) + + def export(self, session_id: str, filename: str, format: str = "docx") -> bytes: + response = self.client.post( + "/v1/documents/export", + json={"session_id": session_id, "format": format, "filename": filename}, + ) + response.raise_for_status() + return response.content diff --git a/use-cases/ashish921998/spec-doc-sync/tests/test_sync.py b/use-cases/ashish921998/spec-doc-sync/tests/test_sync.py new file mode 100644 index 00000000..bd8eadd7 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/tests/test_sync.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import httpx + +from spec_doc_sync.apply import apply_plan +from spec_doc_sync.diff import diff_specs, load_spec +from spec_doc_sync.planner import build_plan +from spec_doc_sync.superdocs import SuperDocsClient + + +ROOT = Path(__file__).resolve().parents[1] + + +def copied_docs(tmp_path: Path) -> Path: + docs = tmp_path / "docs" + shutil.copytree(ROOT / "fixtures" / "docs", docs) + return docs + + +def test_rename_updates_every_exact_occurrence_and_not_substrings(tmp_path: Path) -> None: + docs = copied_docs(tmp_path) + plan = build_plan(ROOT / "fixtures" / "openapi-v1.yaml", ROOT / "fixtures" / "openapi-v2.yaml", docs) + rename = [item for item in plan.proposals if item.operation == "replace_all"] + assert sum(item.occurrences for item in rename) == 2 + for item in plan.proposals: item.decision = "approved" if item.operation == "replace_all" else "rejected" + apply_plan(plan, docs, tmp_path / "state.json") + content = "\n".join(path.read_text(encoding="utf-8") for path in docs.rglob("*.md")) + assert "customer_id" not in content + assert content.count("account_id") == 2 + + +def test_untouched_page_is_byte_identical(tmp_path: Path) -> None: + docs = copied_docs(tmp_path) + untouched_before = (docs / "authentication.md").read_bytes() + plan = build_plan(ROOT / "fixtures" / "openapi-v1.yaml", ROOT / "fixtures" / "openapi-v2.yaml", docs) + for item in plan.proposals: item.decision = "approved" if item.operation == "replace_all" else "rejected" + manifest = apply_plan(plan, docs, tmp_path / "state.json") + assert (docs / "authentication.md").read_bytes() == untouched_before + assert manifest["proof"]["authentication.md"]["byte_identical"] is True + + +def test_ambiguous_rename_escalates() -> None: + previous = {"openapi":"3.1.0","components":{"schemas":{"Thing":{"properties":{"old":{"type":"string"}}}}},"paths":{}} + current = {"openapi":"3.1.0","components":{"schemas":{"Thing":{"properties":{"new_a":{"type":"string"},"new_b":{"type":"string"}}}}},"paths":{}} + changes = diff_specs(previous, current) + assert len(changes) == 1 + assert changes[0].confidence < 0.7 + assert isinstance(changes[0].after, list) + + +def test_state_stops_own_output_from_retriggering(tmp_path: Path) -> None: + docs = copied_docs(tmp_path); state = tmp_path / "state.json" + first = build_plan(ROOT / "fixtures" / "openapi-v1.yaml", ROOT / "fixtures" / "openapi-v2.yaml", docs, state) + for item in first.proposals: item.decision = "rejected" + apply_plan(first, docs, state) + second = build_plan(ROOT / "fixtures" / "openapi-v1.yaml", ROOT / "fixtures" / "openapi-v2.yaml", docs, state) + assert second.proposals == [] + assert "own output cannot retrigger" in second.stop_reason + + +def test_preview_makes_no_network_call(tmp_path: Path, monkeypatch) -> None: + docs = copied_docs(tmp_path) + def forbidden(*args, **kwargs): raise AssertionError("preview attempted network access") + monkeypatch.setattr(httpx, "Client", forbidden) + plan = build_plan(ROOT / "fixtures" / "openapi-v1.yaml", ROOT / "fixtures" / "openapi-v2.yaml", docs) + assert plan.preview is True and plan.proposals + + +def test_live_client_uses_human_gate_contract() -> None: + requests: list[httpx.Request] = [] + polls = iter([ + {"status":"awaiting_approval","metadata":{"pending_changes":[{"change_id":"c1","old_html":"old","new_html":"new"}]}}, + {"status":"completed","result":{"document_changes":{"updated_html":"new"}}}, + ]) + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/v1/chat/async": return httpx.Response(200, json={"job_id":"j1"}) + if request.url.path == "/v1/jobs/j1": return httpx.Response(200, json=next(polls)) + if request.url.path == "/v1/chat/spec-sync-fixed/approve": return httpx.Response(200, json={"ok":True}) + return httpx.Response(404) + client = SuperDocsClient(api_key="sk_test", base_url="https://example.test", transport=httpx.MockTransport(handler)) + from spec_doc_sync.models import EditProposal + proposal = EditProposal("p1","ch","reference.md","replace_all","customer_id","account_id",1,.99,"rename") + path = ROOT / "fixtures" / "docs" / "reference.md" + import uuid + original = uuid.uuid4; uuid.uuid4 = lambda: "fixed" + try: result = client.propose(path, proposal, lambda change: (True, ""), poll_seconds=0) + finally: uuid.uuid4 = original + assert result["status"] == "completed" + approval = next(request for request in requests if request.url.path.endswith("/approve")) + payload = json.loads(approval.content) + assert payload == {"job_id":"j1","approved":True,"changes":[{"change_id":"c1","approved":True,"feedback":""}]} + diff --git a/use-cases/ashish921998/spec-doc-sync/uv.lock b/use-cases/ashish921998/spec-doc-sync/uv.lock new file mode 100644 index 00000000..2d080412 --- /dev/null +++ b/use-cases/ashish921998/spec-doc-sync/uv.lock @@ -0,0 +1,218 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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 = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "spec-doc-sync" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28,<1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4,<9" }, + { name = "pyyaml", specifier = ">=6,<7" }, +] +provides-extras = ["dev"] + +[[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" }, +]