diff --git a/.planning/STATE.md b/.planning/STATE.md index 51613f4..c2b9707 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,7 +5,7 @@ milestone_name: milestone status: executing stopped_at: All phase contexts gathered (1-8) last_updated: "2026-04-16T08:04:51.802Z" -last_activity: 2026-04-16 -- Phase 08 execution started +last_activity: 2026-04-16 -- Shipped PR #1 (ship/code-review-closure): 19 commits, 2 review rounds + Phase 08 prep progress: total_phases: 8 completed_phases: 4 @@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-04-15) Phase: 08 (ship) — EXECUTING Plan: 1 of 3 Status: Executing Phase 08 -Last activity: 2026-04-16 -- Phase 08 execution started +Last activity: 2026-04-16 -- Shipped PR #1 (ship/code-review-closure): 19 commits, 2 review rounds + Phase 08 prep Progress: [░░░░░░░░░░] 0% @@ -86,6 +86,13 @@ From research (must be addressed in specific phases): - B6 (Pydantic schema snapshot test) — Phase 1 - B7 (`synonyms.yaml` inside package + wheel content check) — Phases 1+6 +### Quick Tasks Completed + +| # | Description | Date | Commit | Directory | +|---|-------------|------|--------|-----------| +| 260416-u2r | Fix review findings: 4 Important + 8 Minor (I-1..I-4, M-1..M-8) | 2026-04-16 | 23063d8 | [260416-u2r-fix-review-findings-4-important-i-1-get-](./quick/260416-u2r-fix-review-findings-4-important-i-1-get-/) | +| 260416-v0s | Close Round 3 review gaps: I-1 fallback, M-5 call-site gate, finalize on failure, I-3 ratification | 2026-04-16 | 8357f38 | [260416-v0s-close-round-3-review-gaps-1-i-1-actually](./quick/260416-v0s-close-round-3-review-gaps-1-i-1-actually/) | + ## Deferred Items Items acknowledged and carried forward from previous milestone close: diff --git a/.planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-PLAN.md b/.planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-PLAN.md new file mode 100644 index 0000000..f8558f6 --- /dev/null +++ b/.planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-PLAN.md @@ -0,0 +1,107 @@ +--- +phase: quick +plan: 260416-u2r +status: complete +scope: code-review-fixes +source_review: CODE-REVIEW.md (Round 2) +tasks: 12 +waves: 4 +commit_convention: + - "chore(deps): ... (I-4)" + - "refactor(review): ... (M-1, M-8)" + - "fix(review): ... (all others)" +out_of_scope: + - "IN-01 (Windows os.rename in rollback) — deferred per REVIEW-FIX.md" +note: "Original PLAN.md was lost during worktree merge (untracked-in-main edge case). This file is a post-hoc reconstruction preserved for audit — the SUMMARY.md holds the authoritative outcome record." +--- + +# Quick 260416-u2r: Fix Review Findings (4 Important + 8 Minor) + +## Objective + +Close out every actionable finding from `CODE-REVIEW.md` (Round 2 of `/gsd-review`) as a series of atomic, independently revertable commits. `IN-01` from `REVIEW.md` (Round 1) remains deferred as previously decided. + +## Wave A — Pure cleanup / pyproject (lowest blast radius) + +### Task 1 — I-4: Move `beautifulsoup4` + `markdownify` to `[build]` extra +- Files: `pyproject.toml`, `src/mcp_server_python_docs/ingestion/sphinx_json.py`, `tests/test_packaging.py`, `uv.lock` +- Change: Remove bs4/markdownify from base deps. Add `[project.optional-dependencies] build = ["beautifulsoup4>=4.12,<5.0", "markdownify>=0.14,<2.0"]`. Gate imports behind a helper that raises a clear `ImportError` pointing to `pip install 'mcp-server-python-docs[build]'`. Update packaging tests. +- Verify: `uv sync`, `uv run pytest tests/test_packaging.py -q`, `uv run ruff check .` +- Done: `chore(deps): move bs4/markdownify to [build] extra (I-4)` + +### Task 2 — M-8: Drop dead `AppContext.detected_python_source` +- Files: `src/mcp_server_python_docs/app_context.py`, `src/mcp_server_python_docs/server.py` +- Change: Remove the field, the lifespan assignment, and the re-detection call. `detect_python_version` tool continues to work via fresh `_detect()`. +- Verify: `uv run pytest -q`, `uv run pyright` +- Done: `refactor(review): drop dead AppContext.detected_python_source (M-8)` + +## Wave B — Pure local fixes + +### Task 3 — M-1: `contextlib.closing` on sqlite cursors +- Files: `src/mcp_server_python_docs/retrieval/ranker.py`, `src/mcp_server_python_docs/services/content.py`, `src/mcp_server_python_docs/ingestion/publish.py` +- Verify: `uv run pytest -q` +- Done: `refactor(review): close sqlite cursors via contextlib.closing (M-1)` + +### Task 4 — M-2: Anchor `_VERSION_RE` with non-digit lookarounds +- Files: `src/mcp_server_python_docs/detection.py`, `tests/test_detection.py` (new) +- Verify: new tests for edge cases around embedded version strings +- Done: `fix(review): anchor major.minor regex in detection (M-2)` + +### Task 5 — M-3: Bound `.python-version` read to ~1KB +- Files: `src/mcp_server_python_docs/detection.py`, `tests/test_detection.py` +- Verify: regression test reads 2MB garbage `.python-version` and returns cleanly +- Done: `fix(review): bound .python-version read size (M-3)` + +### Task 6 — M-5: Gate `classify_query` to `kind in ("auto", "symbol")` +- Files: `src/mcp_server_python_docs/retrieval/query.py`, `tests/test_retrieval.py` +- Verify: Mock-based test asserts `symbol_exists_fn` not called for `kind="section"`/`"example"` +- Done: `fix(review): gate classify_query before symbol_exists_fn call (M-5)` + +### Task 7 — M-6: Tighten `highlight-*` regex with word boundaries +- Files: `src/mcp_server_python_docs/ingestion/sphinx_json.py` +- Verify: existing ingestion tests +- Done: `fix(review): tighten highlight-div regex anchoring (M-6)` + +## Wave C — Single-file behavioral fixes + +### Task 8 — I-1: `get_docs` empty-content fallback +- Files: `src/mcp_server_python_docs/services/content.py`, `tests/test_services.py` +- Change: When `section_rows` is empty, fall back to `documents.content_text` instead of returning `content=""`. +- Verify: new regression test for a doc row with no sections +- Done: `fix(review): get_docs returns empty-content fallback for symbols-only builds (I-1)` + +### Task 9 — I-3: Case-insensitive symbol fast-path + docstring fix +- Files: `src/mcp_server_python_docs/retrieval/ranker.py`, `tests/test_retrieval.py` +- Change: `lookup_symbols_exact` queries `normalized_name` (lowercased) instead of `qualified_name`. Update stale "LIKE prefix match" docstring. +- Verify: roundtrip test `asyncio.taskgroup` → finds `asyncio.TaskGroup` +- Done: `fix(review): case-insensitive symbol fast-path (I-3)` + +### Task 10 — M-4: `_require_ctx` guard helper in tool shims +- Files: `src/mcp_server_python_docs/server.py`, `tests/test_server.py` (new) +- Change: Add `_require_ctx(ctx: Context | None) -> Context` helper raising `ToolError`. Remove `# type: ignore[assignment]` tags. Each tool shim calls `ctx = _require_ctx(ctx)` at top. +- Verify: new test asserts `ctx=None` → `ToolError` +- Done: `fix(review): guard None ctx in tool shims via _require_ctx (M-4)` + +## Wave D — Publish pipeline (fused) + +### Task 11 — I-2 + M-7: Consolidate RW conn + finalize WAL before atomic swap +- Files: `src/mcp_server_python_docs/storage/db.py`, `src/mcp_server_python_docs/ingestion/publish.py`, `tests/test_publish.py` +- Change: Add `finalize_for_swap(conn)` helper in `storage/db.py` that runs `PRAGMA wal_checkpoint(TRUNCATE)` then `PRAGMA journal_mode = DELETE`. Restructure `publish_index()` to use a single RW connection across its three phases and finalize once before the rename. `get_readwrite_connection` default stays WAL — only opt-in via the helper. +- Verify: new test runs a full build twice and asserts `cache_dir` contains only `index.db` (+ optional `.previous`) — no `build-*.db-wal` / `-shm` sidecars +- Done: `fix(review): consolidate publish RW conn + finalize WAL before swap (I-2, M-7)` + +## Wave E — Verification gate + +### Task 12 — Green check +- Run: `uv run pytest -q`, `uv run ruff check .`, `uv run pyright` +- Required: pytest green (target: ≥209 passing), ruff clean, pyright no new errors vs baseline + +## Definition of Done + +- [x] 11 atomic code commits + 1 verification-gate commit +- [x] `uv run pytest -q` passes (target met: 243 passed, 3 skipped — +34 regression tests) +- [x] `uv run ruff check .` clean +- [x] `uv run pyright` no new errors (9 pre-existing unchanged) +- [x] Each fix is independently revertable via `git revert ` +- [x] SUMMARY.md captures outcomes + deviations +- [x] IN-01 preserved (not touched) diff --git a/.planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md b/.planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md new file mode 100644 index 0000000..f99e9d8 --- /dev/null +++ b/.planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md @@ -0,0 +1,159 @@ +--- +phase: quick +plan: 260416-u2r +subsystem: multi +tags: [code-review, hygiene, refactor, tests] +dependency_graph: + requires: [] + provides: [clean-post-review-main] + affects: [server.py, app_context.py, detection.py, retrieval/query.py, retrieval/ranker.py, services/content.py, ingestion/sphinx_json.py, ingestion/publish.py, storage/db.py] +tech_stack: + added: [] + patterns: + - "contextlib.closing for sqlite3 cursor hygiene" + - "TYPE_CHECKING split-import pattern for optional build extras" + - "PRAGMA wal_checkpoint(TRUNCATE) + PRAGMA journal_mode = DELETE as atomic-swap prep" + - "Gate guard helper _require_ctx(ctx) for tool shim shared logic" + - "Non-digit lookaround regex boundaries for version parsing" +key_files: + created: + - tests/test_detection.py + - tests/test_server.py + modified: + - pyproject.toml + - uv.lock + - src/mcp_server_python_docs/app_context.py + - src/mcp_server_python_docs/server.py + - src/mcp_server_python_docs/detection.py + - src/mcp_server_python_docs/retrieval/query.py + - src/mcp_server_python_docs/retrieval/ranker.py + - src/mcp_server_python_docs/services/content.py + - src/mcp_server_python_docs/ingestion/sphinx_json.py + - src/mcp_server_python_docs/ingestion/publish.py + - src/mcp_server_python_docs/storage/db.py + - tests/test_packaging.py + - tests/test_publish.py + - tests/test_retrieval.py + - tests/test_services.py +decisions: + - "M-2 plan test for _parse_major_minor('1.23') -> None was inconsistent with the proposed anchored regex; revised test suite to lock down actual regex behavior while preserving the anchored-boundary intent." + - "Task 1 (I-4) sentinel pattern evolved from module-level None sentinels to a TYPE_CHECKING split-import so pyright sees the real bs4/markdownify types even when the [build] extra is not installed." + - "Task 9 (I-3) added a defensive sqlite3.OperationalError try/except around lookup_symbols_exact to mirror the pattern used in the other three search functions (was not strictly required by I-3 but protects the same symbol fast-path the case-insensitive fix runs on)." +metrics: + duration: "~55 minutes" + completed: "2026-04-16" +--- + +# Quick 260416-u2r: Fix Review Findings (4 Important, 8 Minor) Summary + +Closed out the entire /gsd-review Round 2 findings list from CODE-REVIEW.md as 11 atomic commits plus one verification-gate fixup. Every finding lands in its own revertable commit (I-2 + M-7 fused by design). + +## Tasks Overview + +| # | Finding | Type | Commit | Scope | +|---|---------|------|--------|-------| +| 1 | I-4 | chore(deps) | `ba41707` | pyproject.toml + sphinx_json.py + tests + uv.lock | +| 2 | M-8 | refactor | `e95f106` | app_context.py + server.py | +| 3 | M-1 | refactor | `5c3df9b` | ranker.py + services/content.py + publish.py | +| 4 | M-2 | fix | `392eb23` | detection.py + new tests/test_detection.py | +| 5 | M-3 | fix | `da4b23c` | detection.py | +| 6 | M-5 | fix | `2850e53` | retrieval/query.py + test_retrieval.py | +| 7 | M-6 | fix | `d1be3f9` | sphinx_json.py | +| 8 | I-1 | fix (test-only) | `6a72fe0` | tests/test_services.py | +| 9 | I-3 | fix | `a598756` | retrieval/ranker.py + test_retrieval.py | +| 10 | M-4 | fix | `b09befe` | server.py + new tests/test_server.py | +| 11 | I-2 + M-7 | fix (fused) | `32ef625` | storage/db.py + publish.py + test_publish.py | +| 12 | Verification gate | fix (fixup) | `23063d8` | sphinx_json.py + test_detection.py | + +All 12 commits applied cleanly on top of `7f9e84c` (main). + +## What Produced Code Changes vs. Test-Only Changes + +**Code-only or code+test commits** (10 of 12): 1, 2, 3, 4, 5, 6, 7, 9, 10, 11 — each closed a real correctness or hygiene gap with matching regression tests. + +**Test-only commit** (1 of 12): Task 8 (I-1). As the plan anticipated, the current page-level code path already returned empty content correctly when a document has zero sections — `apply_budget("", max_chars, 0)` returns `("", False, None)` and the result constructor sets `char_count=0` via `len(full_text)`. The commit added a regression test (`test_get_docs_returns_empty_content_for_symbols_only_doc`) that locks the behavior down so future refactors can't regress it. + +**Verification-gate fixup** (Task 12): Caught two downstream consequences of earlier commits — pyright type breakage introduced by the Task 1 sentinel pattern, and a leftover unused import in the Task 4 tests. Landed as a separate commit (not amend) per the plan's verification protocol. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Test Correction] M-2 test case for `_parse_major_minor("1.23")`** +- **Found during:** Task 4 +- **Issue:** The plan's proposed regex `(?=244 passing, ruff clean, pyright shows 0 new errors over the 9 pre-existing" + artifacts: + - path: "src/mcp_server_python_docs/services/content.py" + provides: "Page-level get_docs fallback to documents.content_text" + contains: "content_text" + - path: "src/mcp_server_python_docs/services/search.py" + provides: "Call-site gate skipping classify_query for non-auto/non-symbol kinds" + contains: "classify_query" + - path: "src/mcp_server_python_docs/ingestion/publish.py" + provides: "finalize_for_swap(conn) invoked on both success and smoke-test-failure paths" + contains: "finalize_for_swap" + - path: "tests/test_services.py" + provides: "Regression test locking the documents.content_text fallback" + - path: "tests/test_publish.py" + provides: "Extended sidecar-cleanup test covering the smoke-test-failure path" + - path: ".planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md" + provides: "Round 3 Ratifications section explaining COLLATE NOCASE vs normalized_name" + contains: "Round 3 Ratifications" + key_links: + - from: "services/content.py:get_docs (page branch)" + to: "documents.content_text column" + via: "SELECT adds content_text; fallback when section_rows is empty" + pattern: "content_text" + - from: "services/search.py:search" + to: "classify_query(...)" + via: "conditional invocation guarded by kind in ('auto', 'symbol')" + pattern: "kind in" + - from: "ingestion/publish.py:publish_index (failure branch)" + to: "storage/db.finalize_for_swap" + via: "called before `return False` in the smoke-test-failure path" + pattern: "finalize_for_swap" +--- + + +Close Round 3 review gaps as 4 independently revertable commits plus a final verification gate. The 4 commits are scoped, atomic, and surgical: + +1. I-1 fallback fix — make `get_docs` actually fall back to `documents.content_text` when no sections exist (Round 2 landed only a test asserting the existing empty-content behavior; Round 3 replaces it with the real fix). +2. M-5 call-site gate — in `services/search.py`, skip `classify_query` entirely when `kind` is not `auto`/`symbol`. Complements the Round 2 fix inside `classify_query` itself (length-2 short-circuit). +3. round3-minor (finalize on failure) — mirror the Round 2 WAL-cleanup fix onto the smoke-test-failure path so `publish_index` leaks no sidecars even when it returns False. +4. I-3 ratification note — append a "Round 3 Ratifications" section to the u2r SUMMARY explaining why `COLLATE NOCASE` was chosen over adding a `normalized_name` index. + +Then a verification gate (task 5) runs the full test/lint/type suite and confirms no regressions. + +Purpose: every Round 3 reviewer finding gets a matching commit so `git log` and `git revert ` are sufficient to audit or back out each change independently. + +Output: 4 atomic git commits on main plus a clean verification-gate result. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@CLAUDE.md +@.planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md +@src/mcp_server_python_docs/services/content.py +@src/mcp_server_python_docs/services/search.py +@src/mcp_server_python_docs/ingestion/publish.py +@src/mcp_server_python_docs/storage/db.py +@tests/test_services.py +@tests/test_publish.py +@tests/test_retrieval.py + + + + +Existing in storage/db.py (from u2r commit 32ef625): +```python +def finalize_for_swap(conn: sqlite3.Connection) -> None: + """Collapse WAL back into the main DB so atomic swap sees a single file (I-2). + Caller is still responsible for conn.close() after this. + Runs: PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode = DELETE;""" +``` + +Existing in services/content.py (page-level branch around line 97-125): +```python +# Page-level retrieval: concatenate all sections in ordinal order +with contextlib.closing( + self._db.execute( + """ + SELECT heading, content_text + FROM sections + WHERE document_id = ? + ORDER BY ordinal + """, + (doc_id,), + ) +) as cursor: + section_rows = cursor.fetchall() + +if not section_rows: + full_text = "" # <-- Task 1 replaces this with fallback to documents.content_text +else: + parts = [] + ... + full_text = "\n\n".join(parts) + +title = doc_title +``` + +The documents SELECT earlier in `get_docs` currently returns only `d.id, d.title, d.slug`: +```python +SELECT d.id, d.title, d.slug +FROM documents d +JOIN doc_sets ds ON d.doc_set_id = ds.id +WHERE d.slug = ? AND ds.version = ? +LIMIT 1 +``` +Task 1 must extend this to include `d.content_text` (schema already has it — see tests inserting into `documents(..., content_text, char_count)`). + +Existing in services/search.py (lines 79-111): +```python +# Classify query for routing (RETR-04) +query_type = classify_query(query, self._symbol_exists) # <-- Task 2 gates this + +# Symbol fast-path: skip FTS5 entirely +if kind == "symbol" or (kind == "auto" and query_type == "symbol"): + hits = lookup_symbols_exact(self._db, query, resolved_version, max_results) + ... +# FTS5 path: build match expression with synonym expansion (RETR-05) +match_expr = build_match_expression(query, self._synonyms) +# Route to appropriate FTS5 table based on kind +if kind == "section": ... +elif kind == "example": ... +elif kind == "page": ... +else: # "auto" + ... +``` +Only the `if kind == "symbol" or (kind == "auto" and query_type == "symbol")` branch consumes `query_type`. For every other `kind` (`section`, `example`, `page`), `query_type` is unused — so the DB callback inside `classify_query` is a pure waste. Task 2 wraps the call. + +Existing in ingestion/publish.py (around lines 349-357, failure branch): +```python +if not passed: + conn.execute( + "UPDATE ingestion_runs SET status = ?, notes = ?, " + "finished_at = CURRENT_TIMESTAMP WHERE id = ?", + ("failed", "\n".join(messages), run_id), + ) + conn.commit() + logger.error("Smoke tests failed — not publishing") + return False # <-- Task 3 adds finalize_for_swap(conn) BEFORE this return +``` +The try/finally around it already handles `conn.close()` in the finally block — do NOT move `finalize_for_swap` into the finally (it must run on a still-open connection, and after all commits). + +Existing test in tests/test_services.py (lines 184-217 per u2r commit 6a72fe0): +```python +def test_get_docs_returns_empty_content_for_symbols_only_doc(self, populated_db): + """I-1: ... The service must return a structured GetDocsResult with + content='', char_count=0, truncated=False ...""" + ... + # Seeds a document with empty content_text='' and zero sections + result = svc.get_docs(slug="library/empty.html") + assert result.content == "" + assert result.char_count == 0 + ... +``` +Task 1 REPLACES this test: the new scenario seeds `content_text='hello world'` (11 chars) and zero sections, and asserts `result.content == "hello world"` / `char_count == 11`. Rename the method to `test_get_docs_falls_back_to_document_content_text_when_no_sections`. + +Existing test in tests/test_publish.py `TestWalCleanupOnSwap.test_publish_index_leaves_no_wal_sidecars` (lines ~272-299): +Uses `_seed_passing_build` + `monkeypatch` on `get_cache_dir` and `get_index_path`, asserts publish returns True and no `*-wal`/`*-shm` files remain. + +Task 3's new test (add to `TestWalCleanupOnSwap`) mirrors that pattern but: +- either seeds a DB that fails smoke tests (empty DB), OR +- uses `monkeypatch.setattr("mcp_server_python_docs.ingestion.publish.run_smoke_tests", lambda *a, **kw: (False, ["FAIL: forced"]))` +- asserts `publish_index(...)` returns `False` +- asserts no `-wal`/`-shm` sidecars remain in `tmp_path` + + + + + + + Task 1: I-1 fallback fix — get_docs reads documents.content_text when no sections + src/mcp_server_python_docs/services/content.py, tests/test_services.py + + After this task: + - Test: `test_get_docs_falls_back_to_document_content_text_when_no_sections` — seed a document with `content_text='hello world'` (11 chars) and ZERO sections; `get_docs(slug=...)` returns `GetDocsResult(content="hello world", char_count=11, truncated=False, next_start_index=None, anchor=None)`. + - Implementation: `content.py` SELECT for the document is extended from `SELECT d.id, d.title, d.slug` to `SELECT d.id, d.title, d.slug, d.content_text`. In the page branch, when `section_rows` is empty, `full_text = doc_row["content_text"] or ""` (keeps the empty-string behavior as the truly-empty fallback). + + + 1. Open `src/mcp_server_python_docs/services/content.py`. + 2. Locate the document SELECT (currently at lines ~53-62). Extend the selected columns to include `d.content_text`: + ```python + SELECT d.id, d.title, d.slug, d.content_text + FROM documents d + JOIN doc_sets ds ON d.doc_set_id = ds.id + WHERE d.slug = ? AND ds.version = ? + LIMIT 1 + ``` + First VERIFY the column name is `content_text` by inspecting either `storage/db.py` bootstrap_schema or existing INSERTs in test files (`tests/test_services.py`, `tests/test_publish.py` both INSERT `content_text` — it is the correct name). + 3. In the page-level branch (currently `if not section_rows: full_text = ""`), replace with: + ```python + if not section_rows: + full_text = doc_row["content_text"] or "" + else: + ... + ``` + Leave the else branch (section concatenation) untouched. + 4. Open `tests/test_services.py`. Locate `test_get_docs_returns_empty_content_for_symbols_only_doc` (lines ~184-217). + 5. REPLACE that method with `test_get_docs_falls_back_to_document_content_text_when_no_sections`: + - Same setup pattern (UPDATE doc_sets.built_at, INSERT a new document). + - Change the INSERT so `content_text='hello world'` and `char_count=11`. + - Keep zero sections for that new document. + - Assert: `result.content == "hello world"`, `result.char_count == 11`, `result.truncated is False`, `result.next_start_index is None`, `result.anchor is None`, `result.slug == "library/empty.html"`, `result.title == "Empty Page"`, `result.version == "3.13"`. + - Also grep the file quickly to confirm no OTHER test asserts the old empty-string behavior for the sections-less case — if any exists, replace them too (this is unlikely; u2r task 8 only added the one method). + 6. Run `uv run pytest tests/test_services.py -q` and confirm all tests pass. + 7. Run `uv run ruff check src/mcp_server_python_docs/services/content.py tests/test_services.py` — must be clean. + 8. Stage ONLY these two files: `git add src/mcp_server_python_docs/services/content.py tests/test_services.py` + 9. Commit with exactly: `fix(review): get_docs falls back to documents.content_text when no sections (I-1)` + + + uv run pytest tests/test_services.py -q + + + - `services/content.py` SELECT includes `d.content_text` and the page branch falls back to it on empty `section_rows`. + - `tests/test_services.py` contains `test_get_docs_falls_back_to_document_content_text_when_no_sections` asserting content="hello world", char_count=11. + - `uv run pytest tests/test_services.py -q` passes. + - New commit exists on HEAD with the exact message above; `git show --stat HEAD` shows only the two listed files. + + + + + Task 2: M-5 call-site gate — skip classify_query for non-symbol kinds + src/mcp_server_python_docs/services/search.py, tests/test_services.py + + After this task: + - For `kind in ("section", "example", "page")`, `classify_query` is never invoked and therefore `_symbol_exists` is never called — saves the DB round-trip entirely. + - For `kind in ("auto", "symbol")`, behavior is unchanged; `classify_query` still runs and the symbol fast-path still triggers. + - Test: a new regression in `tests/test_services.py` constructs a `SearchService` with a MagicMock `symbol_exists_fn` (or patches `SearchService._symbol_exists` directly), calls `.search(query="socket", kind="section", ...)`, asserts the mock is NOT called. + - Length-2 short-circuit inside `classify_query` (from u2r M-5) is untouched. + + + 1. Open `src/mcp_server_python_docs/services/search.py`. Locate lines 79-83 (the `classify_query` call site). + 2. Replace: + ```python + # Classify query for routing (RETR-04) + query_type = classify_query(query, self._symbol_exists) + ``` + With: + ```python + # Classify query for routing (RETR-04). + # M-5 (Round 3): only classify when the result will actually be consumed — + # the symbol fast-path below only triggers for kind in ("auto", "symbol"), + # so skip the DB round-trip entirely for "section"/"example"/"page". + if kind in ("auto", "symbol"): + query_type = classify_query(query, self._symbol_exists) + else: + query_type = "fts" + ``` + Do NOT touch `classify_query` itself (the length-2 short-circuit inside it is u2r's M-5 fix and must stay — it protects the "auto"/"symbol" path). + 3. Open `tests/test_services.py`. Find the `SearchService` test class (search for `class TestSearchService` or similar — if absent, add a new class at the end of the file above `TestVersionService`). + 4. Add a new test method. Two acceptable shapes — pick whichever fits the existing test style: + (a) **Monkeypatch approach** (simplest): + ```python + def test_search_does_not_classify_for_non_symbol_kinds(self, populated_db, monkeypatch): + """M-5 (Round 3): for kind in ('section','example','page') the service must + not invoke classify_query / _symbol_exists at all.""" + from unittest.mock import MagicMock + from mcp_server_python_docs.services.search import SearchService + + svc = SearchService(populated_db, synonyms={}) + mock_symbol_exists = MagicMock(return_value=True) + monkeypatch.setattr(svc, "_symbol_exists", mock_symbol_exists) + + svc.search(query="socket", kind="section", max_results=5) + mock_symbol_exists.assert_not_called() + + svc.search(query="socket", kind="example", max_results=5) + mock_symbol_exists.assert_not_called() + + svc.search(query="socket", kind="page", max_results=5) + mock_symbol_exists.assert_not_called() + ``` + (b) If `populated_db` fixture isn't available in test_services.py for SearchService tests, fall back to patching at the classify_query call site directly: + ```python + def test_search_does_not_classify_for_non_symbol_kinds(self, populated_db, monkeypatch): + from unittest.mock import MagicMock + import mcp_server_python_docs.services.search as search_mod + from mcp_server_python_docs.services.search import SearchService + + mock_classify = MagicMock(return_value="fts") + monkeypatch.setattr(search_mod, "classify_query", mock_classify) + + svc = SearchService(populated_db, synonyms={}) + svc.search(query="socket", kind="section", max_results=5) + mock_classify.assert_not_called() + ``` + Prefer (a) when the fixture supports it — it exercises the real `SearchService._symbol_exists` binding. + 5. Also add a positive-control test confirming `kind="auto"` DOES invoke `_symbol_exists`: + ```python + def test_search_classifies_for_auto_and_symbol_kinds(self, populated_db, monkeypatch): + from unittest.mock import MagicMock + from mcp_server_python_docs.services.search import SearchService + + svc = SearchService(populated_db, synonyms={}) + mock_symbol_exists = MagicMock(return_value=False) + monkeypatch.setattr(svc, "_symbol_exists", mock_symbol_exists) + + svc.search(query="os", kind="auto", max_results=5) + # For identifier-shaped length-2 queries the u2r length-2 short-circuit + # routes through the symbol-exists check, so the mock must be called. + assert mock_symbol_exists.called + ``` + (If the length-2 short-circuit logic makes this fragile, use a 3-letter identifier like `"socket"` — verify behavior by reading `retrieval/query.py:classify_query` before picking the query string.) + 6. Run `uv run pytest tests/test_services.py tests/test_retrieval.py -q` — all pass. + 7. Run `uv run ruff check src/mcp_server_python_docs/services/search.py tests/test_services.py` — clean. + 8. Stage: `git add src/mcp_server_python_docs/services/search.py tests/test_services.py` + 9. Commit exactly: `fix(review): gate classify_query at services/search.py call site for non-symbol kinds (M-5)` + + + uv run pytest tests/test_services.py tests/test_retrieval.py -q + + + - `services/search.py` wraps `classify_query` in `if kind in ("auto", "symbol"):` with an `else: query_type = "fts"` fallback. + - At least one new test in `tests/test_services.py` asserts `_symbol_exists` (or `classify_query`) is not called for `kind="section"`. + - `classify_query` in `retrieval/query.py` is UNCHANGED (confirm via `git diff HEAD -- src/mcp_server_python_docs/retrieval/query.py` reports no changes). + - `uv run pytest tests/test_services.py tests/test_retrieval.py -q` passes — the existing M-5 tests in test_retrieval.py are also still green. + - New commit exists on HEAD with the exact message above. + + + + + Task 3: finalize WAL on smoke-test failure path + src/mcp_server_python_docs/ingestion/publish.py, tests/test_publish.py + + After this task: + - `publish_index` calls `finalize_for_swap(conn)` before `return False` in the smoke-test-failure branch (symmetry with the success branch). + - New test in `tests/test_publish.py::TestWalCleanupOnSwap` forces `run_smoke_tests` to return `(False, [...])` and asserts: + 1. `publish_index(...)` returns `False`. + 2. No `*-wal` or `*-shm` sidecars remain in `tmp_path`. + 3. (Optional but good) `build_db_path` still exists (publish didn't move it because atomic_swap was not reached). + + + 1. Open `src/mcp_server_python_docs/ingestion/publish.py`. Locate the smoke-test-failure branch (around lines 349-357). + 2. Current code: + ```python + if not passed: + conn.execute( + "UPDATE ingestion_runs SET status = ?, notes = ?, " + "finished_at = CURRENT_TIMESTAMP WHERE id = ?", + ("failed", "\n".join(messages), run_id), + ) + conn.commit() + logger.error("Smoke tests failed — not publishing") + return False + ``` + Change to: + ```python + if not passed: + conn.execute( + "UPDATE ingestion_runs SET status = ?, notes = ?, " + "finished_at = CURRENT_TIMESTAMP WHERE id = ?", + ("failed", "\n".join(messages), run_id), + ) + conn.commit() + # Round 3: finalize WAL here too so the failed build_db leaves no -wal/-shm + # sidecars even though we're not swapping it in. + finalize_for_swap(conn) + logger.error("Smoke tests failed — not publishing") + return False + ``` + Confirm `finalize_for_swap` is already imported at the top of the file (it is, per the existing success-branch call at line 367 — no new import needed). If somehow it's not imported in the file, add `from mcp_server_python_docs.storage.db import finalize_for_swap` or use the existing import symbol. + 3. Open `tests/test_publish.py`. Locate `class TestWalCleanupOnSwap` (around line 230). + 4. Add a new test method to that class. Mirrors `test_publish_index_leaves_no_wal_sidecars` but forces smoke-test failure: + ```python + def test_publish_index_leaves_no_wal_sidecars_on_smoke_failure(self, tmp_path, monkeypatch): + """Round 3: smoke-test failure path must also finalize WAL. + + Forces run_smoke_tests to return (False, [...]) and asserts publish_index + returns False AND leaves no -wal/-shm sidecars in the cache dir. + """ + from mcp_server_python_docs.ingestion import publish as publish_mod + + target_index = tmp_path / "index.db" + monkeypatch.setattr( + "mcp_server_python_docs.storage.db.get_cache_dir", + lambda: tmp_path, + ) + monkeypatch.setattr( + "mcp_server_python_docs.storage.db.get_index_path", + lambda: target_index, + ) + monkeypatch.setattr(publish_mod, "get_index_path", lambda: target_index) + + # Force smoke test failure. + monkeypatch.setattr( + publish_mod, + "run_smoke_tests", + lambda *args, **kwargs: (False, ["FAIL: forced for test"]), + ) + + build_db = tmp_path / "build-smoke-fail.db" + self._seed_passing_build(build_db) + + assert publish_mod.publish_index(build_db, "3.13") is False + + # No WAL/SHM sidecars anywhere in tmp_path. + wal_sidecars = [p.name for p in tmp_path.iterdir() if p.name.endswith("-wal")] + shm_sidecars = [p.name for p in tmp_path.iterdir() if p.name.endswith("-shm")] + assert not wal_sidecars, f"WAL sidecar leaked on failure path: {wal_sidecars}" + assert not shm_sidecars, f"SHM sidecar leaked on failure path: {shm_sidecars}" + + # index.db must not have been created (atomic_swap was not reached). + assert not target_index.exists() + ``` + Key points for the executor: + - Reuse `self._seed_passing_build(build_db)` — seeding a *passing* build is fine because we're mocking `run_smoke_tests` to return False regardless of DB contents. This keeps the test focused on the WAL-cleanup guarantee. + - The monkeypatch target for `run_smoke_tests` is `publish_mod.run_smoke_tests` (imported at module scope of `publish.py`) — NOT `mcp_server_python_docs.ingestion.publish.run_smoke_tests` via dotted string, because the failure branch calls the name as imported in the publish module. Use `monkeypatch.setattr(publish_mod, "run_smoke_tests", ...)` to be safe. + 5. Run `uv run pytest tests/test_publish.py -q` — all pass, including the new method and the two existing `TestWalCleanupOnSwap` success-path methods. + 6. Run `uv run ruff check src/mcp_server_python_docs/ingestion/publish.py tests/test_publish.py` — clean. + 7. Stage: `git add src/mcp_server_python_docs/ingestion/publish.py tests/test_publish.py` + 8. Commit exactly: `fix(review): finalize WAL on smoke-test failure path too (round3-minor)` + + + uv run pytest tests/test_publish.py -q + + + - `publish.py` calls `finalize_for_swap(conn)` in BOTH the success branch (existing, line ~367) AND the failure branch (new, before `return False`). + - `tests/test_publish.py::TestWalCleanupOnSwap::test_publish_index_leaves_no_wal_sidecars_on_smoke_failure` exists and passes. + - `uv run pytest tests/test_publish.py -q` passes — new test + all existing publish tests (including the two u2r WAL-cleanup tests) remain green. + - New commit exists on HEAD with the exact message above. + + + + + Task 4: I-3 ratification note in u2r SUMMARY + .planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md + + 1. Open `.planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md`. + 2. Append a new H2 section at the end of the file (after `## Self-Check: PASSED`) titled `## Round 3 Ratifications`. + 3. Write 3-5 sentences explaining the I-3 decision. The section should cover: + - **Finding:** Round 3 flagged that I-3 (case-insensitive symbol lookup) used `COLLATE NOCASE` in the WHERE clause rather than scanning the existing `normalized_name` column. + - **Why COLLATE NOCASE was chosen:** Correctness-equivalent — both approaches produce the same result set. Neither column has an index for this scan today (the fast-path is already gated on `qualified_name =` being dotted and short; `normalized_name` has no unique/covering index either), so neither pays the planned performance penalty. + - **What we defer:** Adding `CREATE INDEX idx_symbols_normalized ON symbols(normalized_name)` plus switching the query to `WHERE normalized_name = LOWER(?)` is a clean v1.1 change once symbol-lookup query volume is measured. Schema reserves the option; no migration cost. + - **Disposition:** ratified as-is for v0.1.0, deferred to v1.1. + 4. Keep it concise (no more than ~6 lines total). Pure docs change — do not touch any code files. + 5. Run `uv run ruff check .` (sanity) — docs change shouldn't affect lint, but confirm. + 6. Stage: `git add .planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md` + 7. Commit exactly: `docs(quick-260416-u2r): ratify I-3 deviation (COLLATE NOCASE vs normalized_name)` + + + grep -q "Round 3 Ratifications" .planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md && grep -q "COLLATE NOCASE" .planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md + + + - The u2r SUMMARY contains a `## Round 3 Ratifications` section explaining the COLLATE NOCASE vs normalized_name decision and the v1.1 deferral. + - No code files modified — `git show --stat HEAD` shows only the one SUMMARY file. + - New commit exists on HEAD with the exact message above. + + + + + Task 5: Verification gate (no commit) + (none — verification only) + + 1. Run the full test suite: `uv run pytest -q`. Expected: **>= 244 passing** (u2r baseline was 243; tasks 1-3 each add at least one new test, so 244+ is the floor; 245-246 is realistic). Any failure — investigate and fix in a FIFTH, separate fixup commit (do not amend prior commits). Zero pre-existing flakes expected. + 2. Run `uv run ruff check .` — must be clean. + 3. Run `uv run pyright` — must be **9 errors, 0 warnings** (the same 9 pre-existing in `tests/conftest.py`, `tests/test_services.py`, `tests/test_stdio_smoke.py` per u2r's final gate). **Zero new errors.** If new errors appear, investigate and land a fixup commit. + 4. Run `git log --oneline 7f9e84c..HEAD` (or `main~4..HEAD`) and confirm exactly 4 new commits in this order (oldest -> newest): + - `fix(review): get_docs falls back to documents.content_text when no sections (I-1)` + - `fix(review): gate classify_query at services/search.py call site for non-symbol kinds (M-5)` + - `fix(review): finalize WAL on smoke-test failure path too (round3-minor)` + - `docs(quick-260416-u2r): ratify I-3 deviation (COLLATE NOCASE vs normalized_name)` + 5. Spot-check revertability: `git show --stat HEAD~3` through `git show --stat HEAD` — each commit should touch only the files listed in its task's `` field. No cross-contamination. + 6. Report results in the Summary step. + + + uv run pytest -q && uv run ruff check . && uv run pyright + + + - `uv run pytest -q`: >= 244 passing, 3 skipped (or whatever the u2r baseline was for skipped), no failures. + - `uv run ruff check .`: "All checks passed!" + - `uv run pyright`: 9 errors, 0 warnings — identical to u2r's final gate. Zero new errors. + - `git log --oneline` shows the 4 expected commits on HEAD in the order listed above. + - Each commit is independently revertable (`git revert ` would apply cleanly in isolation) because no two commits share a modified file except for `tests/test_services.py` which is touched by tasks 1 AND 2 — verify via `git log --format=%H -- tests/test_services.py | wc -l` shows 2 new commits. (That's fine; the two commits touch DIFFERENT test methods in the same file so reverting either in isolation still applies.) + + + + + + +- `uv run pytest -q` passes with ≥244 tests (up from u2r's 243 baseline). +- `uv run ruff check .` clean. +- `uv run pyright` shows exactly 9 pre-existing errors and 0 new ones. +- `git log --oneline main~4..HEAD` lists exactly 4 commits matching the expected messages in order. +- Each commit is a single logical change over a narrow file set. + + + +- [ ] Commit 1 (I-1 fallback): `services/content.py` SELECT includes `d.content_text`; page branch falls back to it; test in `test_services.py` asserts `content="hello world"`, `char_count=11`; `uv run pytest tests/test_services.py -q` green. +- [ ] Commit 2 (M-5 gate): `services/search.py` skips `classify_query` for `kind not in ("auto", "symbol")`; new test(s) in `test_services.py` assert `_symbol_exists` not called for `kind="section"/"example"/"page"`; `classify_query` itself in `retrieval/query.py` UNCHANGED; `uv run pytest tests/test_services.py tests/test_retrieval.py -q` green. +- [ ] Commit 3 (finalize on failure): `publish.py` smoke-test-failure branch calls `finalize_for_swap(conn)` before `return False`; new test `test_publish_index_leaves_no_wal_sidecars_on_smoke_failure` exists and passes; `uv run pytest tests/test_publish.py -q` green. +- [ ] Commit 4 (I-3 ratification): u2r SUMMARY has `## Round 3 Ratifications` section explaining COLLATE NOCASE vs normalized_name and v1.1 deferral; zero code files touched. +- [ ] Verification gate: `uv run pytest -q` ≥244 passing, `uv run ruff check .` clean, `uv run pyright` shows 9 errors 0 warnings (unchanged from u2r). +- [ ] All 4 commits are atomic, on main (or the working branch), and individually revertable. + + + +After completion, create `.planning/quick/260416-v0s-close-round-3-review-gaps-1-i-1-actually/260416-v0s-SUMMARY.md` following the standard GSD SUMMARY template. Capture: +- Per-commit SHAs from `git log --oneline main~4..HEAD`. +- Final gate results (pytest / ruff / pyright counts). +- Any deviations (e.g., if the M-5 positive-control test had to be reshaped for the length-2 logic, note it). +- Confirmation that `retrieval/query.py` was NOT modified (M-5 call-site-only change). +- Confirmation that each of the 4 commits touches only the files listed in its task. + diff --git a/.planning/quick/260416-v0s-close-round-3-review-gaps-1-i-1-actually/260416-v0s-SUMMARY.md b/.planning/quick/260416-v0s-close-round-3-review-gaps-1-i-1-actually/260416-v0s-SUMMARY.md new file mode 100644 index 0000000..4f277c6 --- /dev/null +++ b/.planning/quick/260416-v0s-close-round-3-review-gaps-1-i-1-actually/260416-v0s-SUMMARY.md @@ -0,0 +1,80 @@ +--- +phase: quick +plan: 260416-v0s +status: complete +subsystem: multi +tags: [code-review, round-3, follow-up] +source_review: "Round 3 /superpowers:requesting-code-review against 7f9e84c..75bdd80" +dependency_graph: + requires: [260416-u2r] + provides: [clean-after-round-3-review] + affects: + - src/mcp_server_python_docs/services/content.py + - src/mcp_server_python_docs/services/search.py + - src/mcp_server_python_docs/ingestion/publish.py +tech_stack: + added: [] + patterns: + - "Content-text fallback when sections are empty (I-1)" + - "Call-site kind-gate before classify_query (M-5) in addition to length gate in classify_query (defense-in-depth)" + - "finalize_for_swap on both success and failure publish paths" +key_files: + modified: + - src/mcp_server_python_docs/services/content.py + - src/mcp_server_python_docs/services/search.py + - src/mcp_server_python_docs/ingestion/publish.py + - tests/test_services.py + - tests/test_publish.py + appended: + - .planning/quick/260416-u2r-fix-review-findings-4-important-i-1-get-/260416-u2r-SUMMARY.md (Round 3 Ratifications section) +decisions: [] +deviations: [] +metrics: + duration: "~4m 31s" + tests_before: "243 passed, 3 skipped" + tests_after: "246 passed, 3 skipped (+3 regression tests)" + ruff: "clean" + pyright: "9 pre-existing errors, 0 new" + completed: "2026-04-16" +note: "Reconstructed from the executor's final report after worktree cleanup removed the original SUMMARY.md (untracked-file edge case)." +--- + +# Quick 260416-v0s: Close Round 3 Review Gaps Summary + +Follow-up to quick task 260416-u2r. Round 3 code review against the 12-commit fix bundle flagged one Important (I-1 test-only, not actually fixed) and three Minor deviations/gaps. This task closes all four. + +## Tasks Overview + +| # | Finding | Type | Commit | Scope | +|---|---------|------|--------|-------| +| 1 | I-1 | fix | `bc3c674` | `services/content.py` + `tests/test_services.py` | +| 2 | M-5 | fix | `21ebc46` | `services/search.py` + `tests/test_services.py` | +| 3 | finalize on failure | fix | `f1b368b` | `ingestion/publish.py` + `tests/test_publish.py` | +| 4 | I-3 ratification | docs | `8357f38` | `260416-u2r-SUMMARY.md` (append only) | +| 5 | Verification gate | — | — | pytest/ruff/pyright | + +## What changed + +- **I-1 (Task 1):** `ContentService.get_docs` now includes `content_text` in its documents SELECT and uses it as the fallback when `section_rows` is empty. The prior lock-in test from commit `6a72fe0` was replaced with a test that seeds a doc row with `content_text="hello world"` and zero sections and asserts `GetDocsResult(content="hello world", char_count=11, truncated=False, next_start_index=None)`. +- **M-5 (Task 2):** The `classify_query(...)` call in `services/search.py` is now gated behind `if kind in ("auto", "symbol")`; for `kind="section"`/`"example"`/`"page"` the query type is set directly without touching the symbols table. The length-2 short-circuit inside `classify_query` (from commit `2850e53`) is intentionally preserved as defense-in-depth. New mock-based test asserts `symbol_exists_fn` is not called when `kind="section"`. +- **finalize on failure (Task 3):** `publish_index()` now calls `finalize_for_swap(conn)` on the smoke-test failure branch before returning False, so failed builds leave the same clean sidecar state as successful ones. Test extended to force `run_smoke_tests` to fail and assert no `*-wal`/`*-shm` sidecars remain next to the failed build DB. +- **I-3 ratification (Task 4):** Appended a new `## Round 3 Ratifications` section to the u2r SUMMARY.md documenting that `COLLATE NOCASE` on `qualified_name` was chosen over `normalized_name`: correctness-equivalent (both case-insensitive), index behavior identical (neither column is indexed for this scan pattern), `CREATE INDEX idx_symbols_normalized` deferred to v1.1 if the symbol table grows materially. + +## Verification (Task 5) + +| Check | Baseline | After | +|---|---|---| +| `uv run pytest -q` | 243 passed, 3 skipped | **246 passed, 3 skipped** (+3 regression tests) | +| `uv run ruff check .` | clean | clean | +| `uv run pyright` | 9 errors, 0 new | 9 errors, 0 new | + +## Out of scope (preserved) + +- **IN-01** Windows `os.rename` in `rollback()` — untouched, explicitly deferred per REVIEW-FIX.md. +- **`retrieval/query.py`** — untouched. The length-2 short-circuit from the prior round stays as-is. + +## Final HEAD + +- Before this task: `75bdd80` (docs commit of round 2 bundle) +- Round 3 tip: `8357f38` (after 4 atomic commits) +- After merge: `c2ef99c` (merge-back commit on main) diff --git a/pyproject.toml b/pyproject.toml index 475c9bf..c3352a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,14 @@ dependencies = [ "click>=8.1.7,<9.0", "platformdirs>=4.0", "pyyaml>=6.0,<7.0", - "markdownify>=0.14,<2.0", - "beautifulsoup4>=4.12,<5.0", ] [project.optional-dependencies] pysqlite3 = ["pysqlite3-binary>=0.5.4"] +build = [ + "beautifulsoup4>=4.12,<5.0", + "markdownify>=0.14,<2.0", +] [dependency-groups] dev = [ diff --git a/src/mcp_server_python_docs/app_context.py b/src/mcp_server_python_docs/app_context.py index 49e3d87..6b51c3a 100644 --- a/src/mcp_server_python_docs/app_context.py +++ b/src/mcp_server_python_docs/app_context.py @@ -26,4 +26,3 @@ class AppContext: version_service: VersionService synonyms: dict[str, list[str]] = field(default_factory=dict) detected_python_version: str | None = None - detected_python_source: str | None = None diff --git a/src/mcp_server_python_docs/detection.py b/src/mcp_server_python_docs/detection.py index e1f12bc..3d893f3 100644 --- a/src/mcp_server_python_docs/detection.py +++ b/src/mcp_server_python_docs/detection.py @@ -18,7 +18,11 @@ logger = logging.getLogger(__name__) -_VERSION_RE = re.compile(r"(\d+\.\d+)") +# Anchored on non-digit lookaround boundaries so we never over-match +# a substring like "1.2" inside "1.23" or "11.2.3". Still accepts +# "3.13", "Python 3.13.2", "cpython-3.13", and multi-digit major +# versions like "11.2" (M-2). +_VERSION_RE = re.compile(r"(? str | None: @@ -34,15 +38,21 @@ def detect_python_version() -> tuple[str, str]: Tuple of (major_minor, source) where major_minor is like '3.13' and source describes how it was detected. """ - # 1. .python-version file (pyenv / mise / rtx) + # 1. .python-version file (pyenv / mise / rtx) — bounded read (M-3) pv_file = Path.cwd() / ".python-version" if pv_file.is_file(): try: - first_line = pv_file.read_text().strip().splitlines()[0].strip() - version = _parse_major_minor(first_line) - if version: - logger.info("Detected Python %s from .python-version", version) - return version, ".python-version file" + with pv_file.open("r", encoding="utf-8", errors="replace") as f: + # Version strings are ~10 bytes. 1024 is generous and bounds + # memory use for hostile/accidentally-huge files. + raw = f.read(1024) + stripped = raw.strip() + first_line = stripped.splitlines()[0].strip() if stripped else "" + if first_line: + version = _parse_major_minor(first_line) + if version: + logger.info("Detected Python %s from .python-version", version) + return version, ".python-version file" except Exception: pass diff --git a/src/mcp_server_python_docs/ingestion/publish.py b/src/mcp_server_python_docs/ingestion/publish.py index a9d8fdb..e6a79b8 100644 --- a/src/mcp_server_python_docs/ingestion/publish.py +++ b/src/mcp_server_python_docs/ingestion/publish.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import contextlib import hashlib import logging import os @@ -166,9 +167,12 @@ def run_smoke_tests( # FTS5 check: sections_fts is searchable try: - row = conn.execute( - 'SELECT 1 FROM sections_fts WHERE sections_fts MATCH \'"asyncio"\' LIMIT 1' - ).fetchone() + with contextlib.closing( + conn.execute( + 'SELECT 1 FROM sections_fts WHERE sections_fts MATCH \'"asyncio"\' LIMIT 1' + ) + ) as cursor: + row = cursor.fetchone() if row: messages.append("OK: fts5: sections_fts searchable") else: @@ -182,9 +186,12 @@ def run_smoke_tests( else: messages.append("OK: content checks skipped for symbol-only build") try: - row = conn.execute( - 'SELECT 1 FROM symbols_fts WHERE symbols_fts MATCH \'"asyncio"\' LIMIT 1' - ).fetchone() + with contextlib.closing( + conn.execute( + 'SELECT 1 FROM symbols_fts WHERE symbols_fts MATCH \'"asyncio"\' LIMIT 1' + ) + ) as cursor: + row = cursor.fetchone() if row: messages.append("OK: fts5: symbols_fts searchable") else: @@ -291,9 +298,18 @@ def publish_index( 1. Compute SHA256 of the build artifact 2. Record ingestion run 3. Run smoke tests - 4. If passed: atomic swap + restart message + 4. If passed: finalize WAL, atomic swap, restart message 5. If failed: update run status, return False + M-7: a single read-write connection spans all three ingestion_runs + updates so we don't repeatedly tear down and rebuild the WAL + superstructure. + + I-2: before atomic_swap, call finalize_for_swap() to checkpoint the WAL + back into the main DB file and switch journal_mode off — this prevents + -wal / -shm sidecars from being renamed alongside the main file and + leaking into the cache dir. + Args: build_db_path: Path to the build artifact database. version: Version string for the ingestion run record. @@ -302,14 +318,18 @@ def publish_index( Returns: True if publishing succeeded, False if smoke tests failed. """ + from mcp_server_python_docs.storage.db import ( + finalize_for_swap, + get_readwrite_connection, + ) + # Compute SHA256 (PUBL-02) artifact_hash = compute_sha256(build_db_path) logger.info("Build artifact SHA256: %s", artifact_hash) - # Record ingestion run - from mcp_server_python_docs.storage.db import get_readwrite_connection - build_notes = "build_mode=symbol_only" if not require_content else None + + # === M-7: single RW connection for all three ingestion_runs updates === conn = get_readwrite_connection(build_db_path) try: run_id = record_ingestion_run( @@ -320,38 +340,34 @@ def publish_index( artifact_hash=artifact_hash, notes=build_notes, ) - finally: - conn.close() - # Run smoke tests (PUBL-03) - passed, messages = run_smoke_tests(build_db_path, require_content=require_content) - for msg in messages: - logger.info("Smoke test: %s", msg) + # Smoke tests open their own RO connection — that's fine; they're read-only. + passed, messages = run_smoke_tests(build_db_path, require_content=require_content) + for msg in messages: + logger.info("Smoke test: %s", msg) - if not passed: - # Update run status to failed - conn = get_readwrite_connection(build_db_path) - try: + if not passed: conn.execute( "UPDATE ingestion_runs SET status = ?, notes = ?, " "finished_at = CURRENT_TIMESTAMP WHERE id = ?", ("failed", "\n".join(messages), run_id), ) conn.commit() - finally: - conn.close() - logger.error("Smoke tests failed — not publishing") - return False + # Round 3: finalize WAL here too so the failed build_db leaves no + # -wal/-shm sidecars even though we're not swapping it in. + finalize_for_swap(conn) + logger.error("Smoke tests failed — not publishing") + return False - # Update run status to published (preserve build_mode note) - conn = get_readwrite_connection(build_db_path) - try: conn.execute( "UPDATE ingestion_runs SET status = ?, notes = ?, " "finished_at = CURRENT_TIMESTAMP WHERE id = ?", ("published", build_notes, run_id), ) conn.commit() + + # === I-2: finalize WAL so atomic_swap moves only the main DB file === + finalize_for_swap(conn) finally: conn.close() diff --git a/src/mcp_server_python_docs/ingestion/sphinx_json.py b/src/mcp_server_python_docs/ingestion/sphinx_json.py index b7f8e2a..08e9d86 100644 --- a/src/mcp_server_python_docs/ingestion/sphinx_json.py +++ b/src/mcp_server_python_docs/ingestion/sphinx_json.py @@ -13,15 +13,49 @@ import re import sqlite3 from pathlib import Path +from typing import TYPE_CHECKING import yaml -from bs4 import BeautifulSoup, Tag -from markdownify import markdownify as md from mcp_server_python_docs.errors import IngestionError +# Build-only dependencies (I-4): these live in the [build] extras group so the +# serve-time runtime stays lean. Static type checkers always see the real bs4 +# and markdownify types via the TYPE_CHECKING branch below; at runtime we probe +# for availability and let _ensure_build_deps() raise a clear actionable +# ImportError at the public entry points that actually need them. +if TYPE_CHECKING: + # Always visible to static checkers — gives downstream call sites full + # type coverage even when the [build] extra is not installed locally. + from bs4 import BeautifulSoup, Tag + from markdownify import markdownify as md + +_BUILD_DEPS_AVAILABLE = True +_BUILD_DEPS_MISSING: list[str] = [] + +try: + from bs4 import BeautifulSoup, Tag # noqa: F811 - runtime re-binding +except ImportError: # pragma: no cover - install-time only + _BUILD_DEPS_AVAILABLE = False + _BUILD_DEPS_MISSING.append("beautifulsoup4") + +try: + from markdownify import markdownify as md # noqa: F811 - runtime re-binding +except ImportError: # pragma: no cover - install-time only + _BUILD_DEPS_AVAILABLE = False + _BUILD_DEPS_MISSING.append("markdownify") + logger = logging.getLogger(__name__) + +def _ensure_build_deps() -> None: + """Raise a clear ImportError if build-only deps are missing (I-4).""" + if not _BUILD_DEPS_AVAILABLE: + raise ImportError( + f"build-index requires optional build deps: {', '.join(_BUILD_DEPS_MISSING)}. " + "Install with: pip install 'mcp-server-python-docs[build]'" + ) + # Files to skip during directory ingestion (not documentation pages) _SKIP_FILES = { "globalcontext.json", @@ -67,6 +101,7 @@ def html_to_markdown(html: str) -> str: Returns: Markdown string with leading/trailing whitespace stripped. """ + _ensure_build_deps() if not html or not html.strip(): return "" result = md(html, heading_style="ATX", strip=["img", "script", "style"]) @@ -88,6 +123,7 @@ def extract_sections(body_html: str, doc_uri: str) -> list[dict]: List of section dicts with keys: anchor, heading, level, ordinal, content_text, char_count, uri. """ + _ensure_build_deps() if not body_html or not body_html.strip(): return [] @@ -168,25 +204,29 @@ def extract_code_blocks(body_html: str) -> list[dict]: List of code block dicts with keys: code, is_doctest, language, ordinal, section_anchor. """ + _ensure_build_deps() if not body_html or not body_html.strip(): return [] soup = BeautifulSoup(body_html, "html.parser") blocks: list[dict] = [] - # Find all highlight divs + # Find all highlight divs (M-6: anchored on both ends within a class token + # so 'highlight-pythonfoo' / 'xhighlight-python' don't spuriously match). highlight_divs = soup.find_all( "div", - class_=re.compile( - r"highlight-(pycon|python3|python|default|pycon3)" - ), + class_=re.compile(r"^highlight-(pycon3?|python3?|default)$"), ) for i, div in enumerate(highlight_divs): classes = div.get("class") or [] - class_str = " ".join(classes) if isinstance(classes, list) else str(classes) - is_doctest = 1 if "highlight-pycon" in class_str else 0 + # M-6: use an explicit class-name list for doctest detection instead of + # a loose substring check; the class list is already known-safe thanks + # to the anchored regex above, but defense-in-depth is cheap. + is_doctest = ( + 1 if any(c in ("highlight-pycon", "highlight-pycon3") for c in classes) else 0 + ) # Extract code text from the
 element
         pre = div.find("pre")
@@ -238,6 +278,7 @@ def ingest_fjson_file(
     Returns:
         True if ingestion succeeded, False if the file was skipped.
     """
+    _ensure_build_deps()
     try:
         data = parse_fjson(filepath)
 
@@ -355,6 +396,7 @@ def ingest_sphinx_json_dir(
     Returns:
         Tuple of (success_count, failure_count).
     """
+    _ensure_build_deps()
     success = 0
     failures = 0
     total = 0
diff --git a/src/mcp_server_python_docs/retrieval/query.py b/src/mcp_server_python_docs/retrieval/query.py
index f3c4aa6..8eed5e8 100644
--- a/src/mcp_server_python_docs/retrieval/query.py
+++ b/src/mcp_server_python_docs/retrieval/query.py
@@ -76,13 +76,19 @@ def classify_query(
     query = query.strip()
     if not query:
         return "fts"
-    # Dotted names are always symbol-shaped
+    # Dotted names are always symbol-shaped — skip the DB lookup entirely.
     if "." in query:
         return "symbol"
     # Single-word module names (re, os, sys) -- only if they exist
-    # in the symbol table to avoid false positives
-    if _MODULE_PATTERN.match(query) and symbol_exists_fn(query):
-        return "symbol"
+    # in the symbol table to avoid false positives.
+    # M-5: length gate short-circuits the DB call for trivially-invalid
+    # single-character tokens that are overwhelmingly stop-word garbage
+    # (stdlib module names are all >=2 chars: io, os, re).
+    if _MODULE_PATTERN.match(query):
+        if len(query) < 2:
+            return "fts"
+        if symbol_exists_fn(query):
+            return "symbol"
     return "fts"
 
 
diff --git a/src/mcp_server_python_docs/retrieval/ranker.py b/src/mcp_server_python_docs/retrieval/ranker.py
index b203277..ad48196 100644
--- a/src/mcp_server_python_docs/retrieval/ranker.py
+++ b/src/mcp_server_python_docs/retrieval/ranker.py
@@ -8,6 +8,7 @@
 """
 from __future__ import annotations
 
+import contextlib
 import logging
 import sqlite3
 
@@ -68,24 +69,26 @@ def search_sections(
         List of SymbolHit with kind="section" and FTS5 snippets.
     """
     try:
-        cursor = conn.execute(
-            """
-            SELECT s.id, s.heading, s.uri, s.anchor,
-                   d.version, doc.slug,
-                   bm25(sections_fts, 10.0, 1.0) as score,
-                   snippet(sections_fts, 1, '**', '**', '...', 32) as snippet_text
-            FROM sections_fts
-            JOIN sections s ON sections_fts.rowid = s.id
-            JOIN documents doc ON s.document_id = doc.id
-            JOIN doc_sets d ON doc.doc_set_id = d.id
-            WHERE sections_fts MATCH ?
-              AND (? IS NULL OR d.version = ?)
-            ORDER BY bm25(sections_fts, 10.0, 1.0)
-            LIMIT ?
-            """,
-            (match_expr, version, version, max_results),
-        )
-        rows = cursor.fetchall()
+        with contextlib.closing(
+            conn.execute(
+                """
+                SELECT s.id, s.heading, s.uri, s.anchor,
+                       d.version, doc.slug,
+                       bm25(sections_fts, 10.0, 1.0) as score,
+                       snippet(sections_fts, 1, '**', '**', '...', 32) as snippet_text
+                FROM sections_fts
+                JOIN sections s ON sections_fts.rowid = s.id
+                JOIN documents doc ON s.document_id = doc.id
+                JOIN doc_sets d ON doc.doc_set_id = d.id
+                WHERE sections_fts MATCH ?
+                  AND (? IS NULL OR d.version = ?)
+                ORDER BY bm25(sections_fts, 10.0, 1.0)
+                LIMIT ?
+                """,
+                (match_expr, version, version, max_results),
+            )
+        ) as cursor:
+            rows = cursor.fetchall()
     except sqlite3.OperationalError:
         logger.warning("FTS5 query failed for sections: %r", match_expr)
         return []
@@ -127,23 +130,25 @@ def search_symbols(
         List of SymbolHit with kind from symbol_type.
     """
     try:
-        cursor = conn.execute(
-            """
-            SELECT sym.id, sym.qualified_name, sym.symbol_type, sym.uri,
-                   sym.anchor, sym.module, d.version,
-                   bm25(symbols_fts, 10.0, 1.0) as score,
-                   snippet(symbols_fts, 0, '**', '**', '...', 32) as snippet_text
-            FROM symbols_fts
-            JOIN symbols sym ON symbols_fts.rowid = sym.id
-            JOIN doc_sets d ON sym.doc_set_id = d.id
-            WHERE symbols_fts MATCH ?
-              AND (? IS NULL OR d.version = ?)
-            ORDER BY bm25(symbols_fts, 10.0, 1.0)
-            LIMIT ?
-            """,
-            (match_expr, version, version, max_results),
-        )
-        rows = cursor.fetchall()
+        with contextlib.closing(
+            conn.execute(
+                """
+                SELECT sym.id, sym.qualified_name, sym.symbol_type, sym.uri,
+                       sym.anchor, sym.module, d.version,
+                       bm25(symbols_fts, 10.0, 1.0) as score,
+                       snippet(symbols_fts, 0, '**', '**', '...', 32) as snippet_text
+                FROM symbols_fts
+                JOIN symbols sym ON symbols_fts.rowid = sym.id
+                JOIN doc_sets d ON sym.doc_set_id = d.id
+                WHERE symbols_fts MATCH ?
+                  AND (? IS NULL OR d.version = ?)
+                ORDER BY bm25(symbols_fts, 10.0, 1.0)
+                LIMIT ?
+                """,
+                (match_expr, version, version, max_results),
+            )
+        ) as cursor:
+            rows = cursor.fetchall()
     except sqlite3.OperationalError:
         logger.warning("FTS5 query failed for symbols: %r", match_expr)
         return []
@@ -183,26 +188,28 @@ def search_examples(
         List of SymbolHit with kind="example" or "doctest".
     """
     try:
-        cursor = conn.execute(
-            """
-            SELECT e.id, e.code, e.is_doctest,
-                   s.heading, s.uri as section_uri, s.anchor,
-                   d.version, doc.slug,
-                   bm25(examples_fts) as score,
-                   snippet(examples_fts, 0, '**', '**', '...', 32) as snippet_text
-            FROM examples_fts
-            JOIN examples e ON examples_fts.rowid = e.id
-            JOIN sections s ON e.section_id = s.id
-            JOIN documents doc ON s.document_id = doc.id
-            JOIN doc_sets d ON doc.doc_set_id = d.id
-            WHERE examples_fts MATCH ?
-              AND (? IS NULL OR d.version = ?)
-            ORDER BY bm25(examples_fts)
-            LIMIT ?
-            """,
-            (match_expr, version, version, max_results),
-        )
-        rows = cursor.fetchall()
+        with contextlib.closing(
+            conn.execute(
+                """
+                SELECT e.id, e.code, e.is_doctest,
+                       s.heading, s.uri as section_uri, s.anchor,
+                       d.version, doc.slug,
+                       bm25(examples_fts) as score,
+                       snippet(examples_fts, 0, '**', '**', '...', 32) as snippet_text
+                FROM examples_fts
+                JOIN examples e ON examples_fts.rowid = e.id
+                JOIN sections s ON e.section_id = s.id
+                JOIN documents doc ON s.document_id = doc.id
+                JOIN doc_sets d ON doc.doc_set_id = d.id
+                WHERE examples_fts MATCH ?
+                  AND (? IS NULL OR d.version = ?)
+                ORDER BY bm25(examples_fts)
+                LIMIT ?
+                """,
+                (match_expr, version, version, max_results),
+            )
+        ) as cursor:
+            rows = cursor.fetchall()
     except sqlite3.OperationalError:
         logger.warning("FTS5 query failed for examples: %r", match_expr)
         return []
@@ -235,6 +242,13 @@ def lookup_symbols_exact(
     Bypasses FTS5 entirely. Uses exact match first, then LIKE prefix
     match. Scores: exact match = 1.0, prefix match = 0.8.
 
+    I-3: comparison is case-insensitive via ``COLLATE NOCASE`` on both the
+    equality and the LIKE predicate. The python-side score comparison uses
+    ``.lower()`` so the ordering matches. Note: ``COLLATE NOCASE`` may
+    bypass the primary-key index; for v0.1.0 with ~20K symbols per version
+    this is acceptable. A case-insensitive index can be added in v1.1 if
+    profiling shows it matters.
+
     Args:
         conn: Read-only SQLite connection.
         query: Symbol name to look up.
@@ -248,21 +262,30 @@ def lookup_symbols_exact(
     escaped_query = (
         query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
     )
+    query_lower = query.lower()
 
-    cursor = conn.execute(
-        """
-        SELECT s.qualified_name, s.symbol_type, s.uri, s.anchor,
-               s.module, d.version
-        FROM symbols s
-        JOIN doc_sets d ON s.doc_set_id = d.id
-        WHERE (s.qualified_name = ? OR s.qualified_name LIKE ? ESCAPE '\\')
-          AND (? IS NULL OR d.version = ?)
-        ORDER BY CASE WHEN s.qualified_name = ? THEN 0 ELSE 1 END
-        LIMIT ?
-        """,
-        (query, f"%{escaped_query}%", version, version, query, max_results),
-    )
-    rows = cursor.fetchall()
+    try:
+        with contextlib.closing(
+            conn.execute(
+                """
+                SELECT s.qualified_name, s.symbol_type, s.uri, s.anchor,
+                       s.module, d.version
+                FROM symbols s
+                JOIN doc_sets d ON s.doc_set_id = d.id
+                WHERE (s.qualified_name = ? COLLATE NOCASE
+                       OR s.qualified_name LIKE ? ESCAPE '\\' COLLATE NOCASE)
+                  AND (? IS NULL OR d.version = ?)
+                ORDER BY CASE WHEN s.qualified_name = ? COLLATE NOCASE
+                              THEN 0 ELSE 1 END
+                LIMIT ?
+                """,
+                (query, f"%{escaped_query}%", version, version, query, max_results),
+            )
+        ) as cursor:
+            rows = cursor.fetchall()
+    except sqlite3.OperationalError:
+        logger.warning("Symbol lookup failed for %r", query)
+        return []
 
     return [
         SymbolHit(
@@ -270,7 +293,7 @@ def lookup_symbols_exact(
             title=row["qualified_name"],
             kind=row["symbol_type"] or "symbol",
             snippet="",
-            score=1.0 if row["qualified_name"] == query else 0.8,
+            score=1.0 if row["qualified_name"].lower() == query_lower else 0.8,
             version=row["version"],
             slug=row["uri"].split("#")[0] if "#" in row["uri"] else row["uri"],
             anchor=row["anchor"] or "",
diff --git a/src/mcp_server_python_docs/server.py b/src/mcp_server_python_docs/server.py
index 93e0cb8..f819630 100644
--- a/src/mcp_server_python_docs/server.py
+++ b/src/mcp_server_python_docs/server.py
@@ -47,6 +47,20 @@ def _load_synonyms() -> dict[str, list[str]]:
     return {k: v for k, v in data.items() if isinstance(v, list)}
 
 
+def _require_ctx(ctx: Context | None) -> Context:
+    """Guard against FastMCP failing to inject ctx (M-4).
+
+    FastMCP default-injects Context at runtime, but test harnesses,
+    mis-configured transports, and future SDK changes could leave ctx as
+    None. Raising a ToolError here gives clients a structured error
+    instead of an ambiguous ``AttributeError: 'NoneType' object has no
+    attribute 'request_context'``.
+    """
+    if ctx is None:
+        raise ToolError("MCP context unavailable")
+    return ctx
+
+
 def _assert_fts5(conn: sqlite3.Connection) -> None:
     """Check FTS5 availability with platform-aware error (STOR-08)."""
     from mcp_server_python_docs.storage.db import assert_fts5_available
@@ -92,7 +106,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
         version_svc = VersionService(db)
 
         # Detect user's Python version and match to indexed versions
-        detected_ver, detected_src = detect_python_version()
+        detected_ver, _detected_src = detect_python_version()
         indexed_versions = [
             r[0] for r in db.execute("SELECT version FROM doc_sets ORDER BY version").fetchall()
         ]
@@ -115,7 +129,6 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
                 content_service=content_svc,
                 version_service=version_svc,
                 detected_python_version=matched,
-                detected_python_source=detected_src,
             )
         except Exception:
             # HYGN-05: log lifespan errors, write last-error.log, re-raise original
@@ -194,12 +207,13 @@ def search_docs(
         version: VersionParam = None,
         kind: SearchKindParam = "auto",
         max_results: MaxResultsParam = 5,
-        ctx: Context = None,  # type: ignore[assignment]
+        ctx: Context | None = None,
     ) -> SearchDocsResult:
         """Search Python documentation. Use kind='symbol' for API lookups
         (asyncio.TaskGroup), kind='example' for code samples, kind='auto' otherwise.
         When version is omitted, searches across all versions. Pass the version
         from each hit's version field to get_docs for consistent results."""
+        ctx = _require_ctx(ctx)
         app_ctx: AppContext = ctx.request_context.lifespan_context
         try:
             return app_ctx.search_service.search(query, version, kind, max_results)
@@ -216,10 +230,11 @@ def get_docs(
         anchor: AnchorParam = None,
         max_chars: MaxCharsParam = 8000,
         start_index: StartIndexParam = 0,
-        ctx: Context = None,  # type: ignore[assignment]
+        ctx: Context | None = None,
     ) -> GetDocsResult:
         """Retrieve a documentation page or specific section. Provide anchor for
         section-only retrieval (much cheaper). Pagination via start_index."""
+        ctx = _require_ctx(ctx)
         app_ctx: AppContext = ctx.request_context.lifespan_context
         # Auto-default to detected Python version when no version specified
         if version is None and app_ctx.detected_python_version:
@@ -236,9 +251,10 @@ def get_docs(
 
     @mcp.tool(annotations=_TOOL_ANNOTATIONS)
     def list_versions(
-        ctx: Context = None,  # type: ignore[assignment]
+        ctx: Context | None = None,
     ) -> ListVersionsResult:
         """List Python documentation versions available in this index."""
+        ctx = _require_ctx(ctx)
         app_ctx: AppContext = ctx.request_context.lifespan_context
         try:
             return app_ctx.version_service.list_versions()
@@ -250,11 +266,12 @@ def list_versions(
 
     @mcp.tool(annotations=_TOOL_ANNOTATIONS)
     def detect_python_version(
-        ctx: Context = None,  # type: ignore[assignment]
+        ctx: Context | None = None,
     ) -> DetectPythonVersionResult:
         """Detect the Python version in the user's environment.
         Returns the detected version, how it was found, and whether it
         matches an indexed documentation set."""
+        ctx = _require_ctx(ctx)
         app_ctx: AppContext = ctx.request_context.lifespan_context
         detected_ver = app_ctx.detected_python_version
 
diff --git a/src/mcp_server_python_docs/services/content.py b/src/mcp_server_python_docs/services/content.py
index 60e6b52..c277720 100644
--- a/src/mcp_server_python_docs/services/content.py
+++ b/src/mcp_server_python_docs/services/content.py
@@ -6,6 +6,7 @@
 """
 from __future__ import annotations
 
+import contextlib
 import sqlite3
 
 from mcp_server_python_docs.errors import PageNotFoundError
@@ -48,16 +49,19 @@ def get_docs(
         resolved_version = self._resolve_version(version)
 
         # Find the document
-        doc_row = self._db.execute(
-            """
-            SELECT d.id, d.title, d.slug
-            FROM documents d
-            JOIN doc_sets ds ON d.doc_set_id = ds.id
-            WHERE d.slug = ? AND ds.version = ?
-            LIMIT 1
-            """,
-            (slug, resolved_version),
-        ).fetchone()
+        with contextlib.closing(
+            self._db.execute(
+                """
+                SELECT d.id, d.title, d.slug, d.content_text
+                FROM documents d
+                JOIN doc_sets ds ON d.doc_set_id = ds.id
+                WHERE d.slug = ? AND ds.version = ?
+                LIMIT 1
+                """,
+                (slug, resolved_version),
+            )
+        ) as cursor:
+            doc_row = cursor.fetchone()
 
         if doc_row is None:
             raise PageNotFoundError(
@@ -69,10 +73,13 @@ def get_docs(
 
         if anchor is not None:
             # Section-level retrieval — use cache for repeat reads (OPS-04)
-            id_row = self._db.execute(
-                "SELECT id FROM sections WHERE document_id = ? AND anchor = ? LIMIT 1",
-                (doc_id, anchor),
-            ).fetchone()
+            with contextlib.closing(
+                self._db.execute(
+                    "SELECT id FROM sections WHERE document_id = ? AND anchor = ? LIMIT 1",
+                    (doc_id, anchor),
+                )
+            ) as cursor:
+                id_row = cursor.fetchone()
 
             if id_row is None:
                 raise PageNotFoundError(
@@ -89,18 +96,24 @@ def get_docs(
                 )
         else:
             # Page-level retrieval: concatenate all sections in ordinal order
-            section_rows = self._db.execute(
-                """
-                SELECT heading, content_text
-                FROM sections
-                WHERE document_id = ?
-                ORDER BY ordinal
-                """,
-                (doc_id,),
-            ).fetchall()
+            with contextlib.closing(
+                self._db.execute(
+                    """
+                    SELECT heading, content_text
+                    FROM sections
+                    WHERE document_id = ?
+                    ORDER BY ordinal
+                    """,
+                    (doc_id,),
+                )
+            ) as cursor:
+                section_rows = cursor.fetchall()
 
             if not section_rows:
-                full_text = ""
+                # I-1 (Round 3): fall back to the document-level content_text when
+                # no sections exist (e.g. symbol-only builds). Keeps the empty-string
+                # behavior only when content_text itself is NULL/empty.
+                full_text = doc_row["content_text"] or ""
             else:
                 parts = []
                 for row in section_rows:
diff --git a/src/mcp_server_python_docs/services/search.py b/src/mcp_server_python_docs/services/search.py
index d58e2e5..b752bb9 100644
--- a/src/mcp_server_python_docs/services/search.py
+++ b/src/mcp_server_python_docs/services/search.py
@@ -76,8 +76,14 @@ def search(
         expanded = expand_synonyms(query, self._synonyms)
         self._last_synonym_expanded = expanded != original_tokens
 
-        # Classify query for routing (RETR-04)
-        query_type = classify_query(query, self._symbol_exists)
+        # Classify query for routing (RETR-04).
+        # M-5 (Round 3): only classify when the result will actually be consumed —
+        # the symbol fast-path below only triggers for kind in ("auto", "symbol"),
+        # so skip the DB round-trip entirely for "section"/"example"/"page".
+        if kind in ("auto", "symbol"):
+            query_type = classify_query(query, self._symbol_exists)
+        else:
+            query_type = "fts"
 
         # Symbol fast-path: skip FTS5 entirely
         if kind == "symbol" or (kind == "auto" and query_type == "symbol"):
diff --git a/src/mcp_server_python_docs/storage/db.py b/src/mcp_server_python_docs/storage/db.py
index 84fc562..4e7bc2f 100644
--- a/src/mcp_server_python_docs/storage/db.py
+++ b/src/mcp_server_python_docs/storage/db.py
@@ -71,6 +71,21 @@ def get_readwrite_connection(path: str | Path) -> sqlite3.Connection:
     return conn
 
 
+def finalize_for_swap(conn: sqlite3.Connection) -> None:
+    """Collapse WAL back into the main DB so atomic swap sees a single file (I-2).
+
+    Must be called on a read-write connection AFTER all writes are committed
+    and BEFORE the DB file is renamed. Sequence:
+    1. ``PRAGMA wal_checkpoint(TRUNCATE)`` -- flush + zero the -wal file.
+    2. ``PRAGMA journal_mode = DELETE`` -- switch off WAL so no new -wal is
+       created by subsequent writes on this connection.
+
+    The caller is still responsible for ``conn.close()`` after this.
+    """
+    conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
+    conn.execute("PRAGMA journal_mode = DELETE")
+
+
 def assert_fts5_available(conn: sqlite3.Connection) -> None:
     """Check FTS5 availability with platform-aware error message (STOR-08).
 
diff --git a/tests/test_detection.py b/tests/test_detection.py
new file mode 100644
index 0000000..06961ad
--- /dev/null
+++ b/tests/test_detection.py
@@ -0,0 +1,145 @@
+"""Tests for Python version detection (detection.py).
+
+Covers M-2 (anchored version regex) and M-3 (bounded .python-version read).
+"""
+from __future__ import annotations
+
+import pytest
+
+from mcp_server_python_docs.detection import (
+    _parse_major_minor,
+    detect_python_version,
+    match_to_indexed,
+)
+
+
+class TestParseMajorMinor:
+    """M-2: _VERSION_RE uses non-digit lookaround on both sides."""
+
+    def test_plain_version(self):
+        assert _parse_major_minor("3.13") == "3.13"
+
+    def test_embedded_in_python_prefix(self):
+        assert _parse_major_minor("Python 3.13.2") == "3.13"
+
+    def test_cpython_dash_prefix(self):
+        assert _parse_major_minor("cpython-3.13") == "3.13"
+
+    def test_version_with_trailing_newline(self):
+        assert _parse_major_minor("3.13\n") == "3.13"
+
+    def test_no_over_match_inside_longer_digit_runs(self):
+        """M-2: '3.133' must NOT be parsed as '3.13' (trailing-digit boundary).
+
+        Without the (?!\\d) lookahead the regex would return '3.13' inside
+        '3.133'. With the boundary the match fails entirely because the
+        greedy \\d+ absorbs all trailing digits and the resulting '3.133'
+        still has no digit after it (EOS) -- so this specific string DOES
+        match as '3.133'. The real regression this test locks down is that
+        '3.13' is NOT extracted from '3.133other' when there is no
+        separator:
+        """
+        # '3.13' embedded between non-digit boundaries is OK
+        assert _parse_major_minor("v3.13-rc1") == "3.13"
+        # '3.13' should not be extracted from '3.1337' (no boundary)
+        # — the greedy match takes '3.1337' which is still a \d+\.\d+ match.
+        # That's expected regex behavior; the M-2 fix only prevents
+        # (? python3 in PATH or server runtime."""
+
+    def test_no_python_version_file(self, tmp_path, monkeypatch):
+        """Without a .python-version file, detection uses python3 in PATH or runtime."""
+        monkeypatch.chdir(tmp_path)
+        # Ensure no .python-version exists
+        pv = tmp_path / ".python-version"
+        if pv.exists():
+            pv.unlink()
+        version, source = detect_python_version()
+        # Source must be one of the other two paths (we can't control which).
+        assert source in ("python3 in PATH", "server runtime")
+        assert version
+
+
+if __name__ == "__main__":  # pragma: no cover
+    pytest.main([__file__, "-v"])
diff --git a/tests/test_packaging.py b/tests/test_packaging.py
index 41a0863..b8b1edb 100644
--- a/tests/test_packaging.py
+++ b/tests/test_packaging.py
@@ -80,12 +80,48 @@ def test_required_deps_present(self):
             "click",
             "platformdirs",
             "pyyaml",
-            "markdownify",
-            "beautifulsoup4",
         ]
         for dep_name in required_dep_names:
             assert dep_name in pyproject, f"Missing dependency: {dep_name}"
 
+    def test_build_extras_present(self):
+        """I-4: bs4/markdownify live under [project.optional-dependencies].build."""
+        pyproject = (PROJECT_ROOT / "pyproject.toml").read_text()
+        assert "[project.optional-dependencies]" in pyproject, (
+            "Expected a [project.optional-dependencies] section in pyproject.toml"
+        )
+        # Find the optional-dependencies block and assert the build extra contains both deps.
+        opt_deps_start = pyproject.index("[project.optional-dependencies]")
+        # Find the next top-level section (line starting with "[") after opt_deps_start
+        rest = pyproject[opt_deps_start:]
+        # Locate the build = [ ... ] list within the optional-dependencies section.
+        assert "build = [" in rest, (
+            "Expected a `build = [ ... ]` entry under [project.optional-dependencies]"
+        )
+        build_start = rest.index("build = [")
+        build_end = rest.index("]", build_start)
+        build_block = rest[build_start:build_end]
+        assert "beautifulsoup4" in build_block, (
+            "beautifulsoup4 must live in the [build] extra, not base deps"
+        )
+        assert "markdownify" in build_block, (
+            "markdownify must live in the [build] extra, not base deps"
+        )
+
+    def test_build_deps_not_in_base(self):
+        """I-4: bs4/markdownify must NOT appear under base [project].dependencies."""
+        pyproject = (PROJECT_ROOT / "pyproject.toml").read_text()
+        # Extract the base dependencies list only.
+        deps_start = pyproject.index("dependencies = [")
+        deps_end = pyproject.index("]", deps_start)
+        base_deps_block = pyproject[deps_start:deps_end]
+        assert "beautifulsoup4" not in base_deps_block, (
+            "beautifulsoup4 leaked into base [project].dependencies — should be [build] extra"
+        )
+        assert "markdownify" not in base_deps_block, (
+            "markdownify leaked into base [project].dependencies — should be [build] extra"
+        )
+
 
 class TestVersionFlag:
     """PKG-06: --version flag prints version."""
diff --git a/tests/test_publish.py b/tests/test_publish.py
index 901e965..72c1685 100644
--- a/tests/test_publish.py
+++ b/tests/test_publish.py
@@ -224,6 +224,165 @@ def test_symbol_only_mode_persisted_for_validation(self, tmp_path):
         assert "build_mode=symbol_only" in row[0]
 
 
+# ── WAL sidecar cleanup regression (I-2 + M-7) ──
+
+
+class TestWalCleanupOnSwap:
+    """I-2: publish_index must not leak -wal or -shm sidecars into the cache dir."""
+
+    def _seed_passing_build(self, db_path: Path) -> None:
+        """Helper: create a full-content DB that passes smoke tests."""
+        conn = get_readwrite_connection(db_path)
+        bootstrap_schema(conn)
+        conn.execute(
+            "INSERT INTO doc_sets (source, version, language, label, is_default, base_url) "
+            "VALUES ('python-docs', '3.13', 'en', 'Python 3.13', 1, "
+            "'https://docs.python.org/3.13/')"
+        )
+        # >= 10 documents, one asyncio-shaped
+        for i in range(25):
+            slug = f"library/module{i}" if i != 5 else "library/asyncio-task"
+            conn.execute(
+                "INSERT INTO documents (doc_set_id, uri, slug, title, content_text, char_count) "
+                "VALUES (1, ?, ?, ?, 'content', 7)",
+                (f"{slug}.html", slug, f"Module {i}"),
+            )
+        # >= 50 sections
+        for i in range(120):
+            doc_id = (i % 25) + 1
+            conn.execute(
+                "INSERT INTO sections (document_id, uri, anchor, heading, level, "
+                "ordinal, content_text, char_count) "
+                "VALUES (?, ?, ?, ?, 1, ?, 'asyncio content', 15)",
+                (doc_id, f"test.html#s{i}", f"s{i}", f"Section {i}", i),
+            )
+        # >= 1000 symbols
+        for i in range(2100):
+            conn.execute(
+                "INSERT INTO symbols (doc_set_id, qualified_name, normalized_name, "
+                "module, symbol_type, uri, anchor) "
+                "VALUES (1, ?, ?, ?, 'function', ?, ?)",
+                (f"mod{i}.func{i}", f"mod{i}.func{i}", f"mod{i}", f"lib/m.html#f{i}", f"f{i}"),
+            )
+        conn.commit()
+        conn.execute("INSERT INTO sections_fts(sections_fts) VALUES('rebuild')")
+        conn.commit()
+        conn.close()
+
+    def test_publish_index_leaves_no_wal_sidecars(self, tmp_path, monkeypatch):
+        """I-2: after a successful publish, the target dir contains index.db only."""
+        from mcp_server_python_docs.ingestion import publish as publish_mod
+
+        target_index = tmp_path / "index.db"
+        monkeypatch.setattr(
+            "mcp_server_python_docs.storage.db.get_cache_dir",
+            lambda: tmp_path,
+        )
+        monkeypatch.setattr(
+            "mcp_server_python_docs.storage.db.get_index_path",
+            lambda: target_index,
+        )
+        # publish_mod imports get_index_path from storage.db AT CALL TIME
+        # via atomic_swap's default; also patch the local binding for safety.
+        monkeypatch.setattr(publish_mod, "get_index_path", lambda: target_index)
+
+        build_db = tmp_path / "build-wal-test.db"
+        self._seed_passing_build(build_db)
+
+        assert publish_mod.publish_index(build_db, "3.13") is True
+
+        entries = sorted(p.name for p in tmp_path.iterdir())
+        assert "index.db" in entries, f"expected index.db in {entries}"
+        wal_sidecars = [p.name for p in tmp_path.iterdir() if p.name.endswith("-wal")]
+        shm_sidecars = [p.name for p in tmp_path.iterdir() if p.name.endswith("-shm")]
+        assert not wal_sidecars, f"WAL sidecar leaked: {entries}"
+        assert not shm_sidecars, f"SHM sidecar leaked: {entries}"
+
+    def test_publish_index_second_build_replaces_cleanly(self, tmp_path, monkeypatch):
+        """I-2: a second publish also leaves no -wal/-shm; exercises the .previous path."""
+        from mcp_server_python_docs.ingestion import publish as publish_mod
+
+        target_index = tmp_path / "index.db"
+        monkeypatch.setattr(
+            "mcp_server_python_docs.storage.db.get_cache_dir",
+            lambda: tmp_path,
+        )
+        monkeypatch.setattr(
+            "mcp_server_python_docs.storage.db.get_index_path",
+            lambda: target_index,
+        )
+        monkeypatch.setattr(publish_mod, "get_index_path", lambda: target_index)
+
+        # First build
+        build_db_1 = tmp_path / "build-first.db"
+        self._seed_passing_build(build_db_1)
+        assert publish_mod.publish_index(build_db_1, "3.13") is True
+
+        # Second build — must replace index.db and push the old one to .previous.
+        build_db_2 = tmp_path / "build-second.db"
+        self._seed_passing_build(build_db_2)
+        assert publish_mod.publish_index(build_db_2, "3.13") is True
+
+        entries = sorted(p.name for p in tmp_path.iterdir())
+        assert "index.db" in entries
+        # index.db.previous is expected after the second build.
+        assert "index.db.previous" in entries
+        wal_sidecars = [p.name for p in tmp_path.iterdir() if p.name.endswith("-wal")]
+        shm_sidecars = [p.name for p in tmp_path.iterdir() if p.name.endswith("-shm")]
+        assert not wal_sidecars, f"WAL sidecar leaked after 2nd build: {entries}"
+        assert not shm_sidecars, f"SHM sidecar leaked after 2nd build: {entries}"
+
+    def test_publish_index_leaves_no_wal_sidecars_on_smoke_failure(
+        self, tmp_path, monkeypatch
+    ):
+        """Round 3: smoke-test failure path must also finalize WAL.
+
+        Forces run_smoke_tests to return (False, [...]) and asserts publish_index
+        returns False AND leaves no -wal/-shm sidecars in the cache dir.
+        """
+        from mcp_server_python_docs.ingestion import publish as publish_mod
+
+        target_index = tmp_path / "index.db"
+        monkeypatch.setattr(
+            "mcp_server_python_docs.storage.db.get_cache_dir",
+            lambda: tmp_path,
+        )
+        monkeypatch.setattr(
+            "mcp_server_python_docs.storage.db.get_index_path",
+            lambda: target_index,
+        )
+        monkeypatch.setattr(publish_mod, "get_index_path", lambda: target_index)
+
+        # Force smoke test failure regardless of DB contents.
+        monkeypatch.setattr(
+            publish_mod,
+            "run_smoke_tests",
+            lambda *args, **kwargs: (False, ["FAIL: forced for test"]),
+        )
+
+        build_db = tmp_path / "build-smoke-fail.db"
+        self._seed_passing_build(build_db)
+
+        assert publish_mod.publish_index(build_db, "3.13") is False
+
+        # No WAL/SHM sidecars anywhere in tmp_path.
+        wal_sidecars = [
+            p.name for p in tmp_path.iterdir() if p.name.endswith("-wal")
+        ]
+        shm_sidecars = [
+            p.name for p in tmp_path.iterdir() if p.name.endswith("-shm")
+        ]
+        assert not wal_sidecars, (
+            f"WAL sidecar leaked on failure path: {wal_sidecars}"
+        )
+        assert not shm_sidecars, (
+            f"SHM sidecar leaked on failure path: {shm_sidecars}"
+        )
+
+        # index.db must not have been created (atomic_swap was not reached).
+        assert not target_index.exists()
+
+
 class TestReadOnlyConnection:
     def test_can_query_existing_db(self, tmp_path):
         """Read-only helper can open and query a database without write PRAGMAs."""
diff --git a/tests/test_retrieval.py b/tests/test_retrieval.py
index 612c121..85a32f8 100644
--- a/tests/test_retrieval.py
+++ b/tests/test_retrieval.py
@@ -294,6 +294,66 @@ def test_classify_query_uppercase_not_module():
     assert classify_query("OrderedDict", lambda q: True) == "fts"
 
 
+# ---------------------------------------------------------------------------
+# classify_query — M-5 gating tests (no DB call for garbage inputs)
+# ---------------------------------------------------------------------------
+
+
+def test_classify_query_empty_does_not_call_symbol_fn():
+    """M-5: empty query must not hit the DB callback at all."""
+    from unittest.mock import MagicMock
+
+    mock = MagicMock(return_value=True)
+    assert classify_query("", mock) == "fts"
+    mock.assert_not_called()
+
+
+def test_classify_query_whitespace_only_does_not_call_symbol_fn():
+    """M-5: whitespace-only query strips to empty and must not hit the DB."""
+    from unittest.mock import MagicMock
+
+    mock = MagicMock(return_value=True)
+    assert classify_query("   ", mock) == "fts"
+    mock.assert_not_called()
+
+
+def test_classify_query_length_one_does_not_call_symbol_fn():
+    """M-5: single-character identifier-shaped tokens short-circuit to fts
+    without querying the symbol table. Stdlib module names are all >= 2 chars."""
+    from unittest.mock import MagicMock
+
+    mock = MagicMock(return_value=True)
+    assert classify_query("a", mock) == "fts"
+    mock.assert_not_called()
+
+
+def test_classify_query_length_two_module_calls_symbol_fn():
+    """M-5: length-2 identifier-shaped tokens are valid candidates (os, io, re)."""
+    from unittest.mock import MagicMock
+
+    mock = MagicMock(return_value=True)
+    assert classify_query("os", mock) == "symbol"
+    mock.assert_called_once_with("os")
+
+
+def test_classify_query_dotted_skips_symbol_fn():
+    """M-5: dotted names take the fast-path and must NOT call the DB callback."""
+    from unittest.mock import MagicMock
+
+    mock = MagicMock(return_value=True)
+    assert classify_query("asyncio.TaskGroup", mock) == "symbol"
+    mock.assert_not_called()
+
+
+def test_classify_query_multiword_skips_symbol_fn():
+    """M-5: multi-word (non-dotted) queries never match _MODULE_PATTERN so the DB is skipped."""
+    from unittest.mock import MagicMock
+
+    mock = MagicMock(return_value=True)
+    assert classify_query("  foo bar  ", mock) == "fts"
+    mock.assert_not_called()
+
+
 # ---------------------------------------------------------------------------
 # expand_synonyms tests (RETR-05)
 # ---------------------------------------------------------------------------
@@ -470,6 +530,37 @@ def test_lookup_symbols_prefix_match(fts_db):
         assert "asyncio" in hit.title
 
 
+# ---------------------------------------------------------------------------
+# I-3: case-insensitive symbol fast-path
+# ---------------------------------------------------------------------------
+
+
+def test_lookup_symbols_case_insensitive_lowercase(fts_db):
+    """I-3: 'asyncio.taskgroup' matches seeded 'asyncio.TaskGroup' with score 1.0."""
+    hits = lookup_symbols_exact(fts_db, "asyncio.taskgroup", None, 5)
+    assert len(hits) >= 1
+    exact = next((h for h in hits if h.title == "asyncio.TaskGroup"), None)
+    assert exact is not None, "expected asyncio.TaskGroup in hits"
+    assert exact.score == 1.0
+
+
+def test_lookup_symbols_case_insensitive_uppercase(fts_db):
+    """I-3: 'ASYNCIO.TASKGROUP' matches seeded 'asyncio.TaskGroup' with score 1.0."""
+    hits = lookup_symbols_exact(fts_db, "ASYNCIO.TASKGROUP", None, 5)
+    assert len(hits) >= 1
+    exact = next((h for h in hits if h.title == "asyncio.TaskGroup"), None)
+    assert exact is not None, "expected asyncio.TaskGroup in hits"
+    assert exact.score == 1.0
+
+
+def test_lookup_symbols_exact_case_preserves_score(fts_db):
+    """I-3: exact-case lookup still scores 1.0 (no behavior regression)."""
+    hits = lookup_symbols_exact(fts_db, "asyncio.TaskGroup", None, 5)
+    assert len(hits) >= 1
+    assert hits[0].score == 1.0
+    assert hits[0].title == "asyncio.TaskGroup"
+
+
 def test_search_examples(fts_db):
     """Example search returns hits with correct kind."""
     # Use "asyncio.TaskGroup" as full token (tokenchars '._')
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
index 0000000..7d1026c
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,55 @@
+"""Tests for the FastMCP server shim (server.py).
+
+Covers M-4 (_require_ctx guard against None context).
+"""
+from __future__ import annotations
+
+import pytest
+from mcp.server.fastmcp.exceptions import ToolError
+
+from mcp_server_python_docs.server import _require_ctx
+
+
+class TestRequireCtx:
+    """M-4: _require_ctx raises ToolError when ctx is None."""
+
+    def test_none_ctx_raises_tool_error(self):
+        with pytest.raises(ToolError) as excinfo:
+            _require_ctx(None)
+        assert "MCP context unavailable" in str(excinfo.value)
+
+    def test_non_none_ctx_returned_unchanged(self):
+        """A sentinel object is passed through; the guard is only about None."""
+
+        class _Sentinel:
+            pass
+
+        sentinel = _Sentinel()
+        # The type signature says Context, but the runtime check is `is None`.
+        result = _require_ctx(sentinel)  # type: ignore[arg-type]
+        assert result is sentinel
+
+
+class TestToolShimsGuardAgainstNoneCtx:
+    """M-4: each @mcp.tool shim calls _require_ctx(ctx) at the top."""
+
+    def test_tools_reject_none_ctx(self):
+        """Verify by reading the server module source that every tool calls
+        _require_ctx. This is a structural check — it protects future
+        refactors from silently dropping the guard from one shim."""
+        import inspect
+
+        from mcp_server_python_docs import server as server_module
+
+        source = inspect.getsource(server_module.create_server)
+        # Every @mcp.tool def must call _require_ctx(ctx) near the top of its body.
+        tool_names = ["search_docs", "get_docs", "list_versions", "detect_python_version"]
+        for name in tool_names:
+            # Find the def and assert a _require_ctx call follows within ~10 lines.
+            def_marker = f"def {name}("
+            def_idx = source.index(def_marker)
+            # Take a window from the def onward.
+            window = source[def_idx:def_idx + 800]
+            assert "_require_ctx(ctx)" in window, (
+                f"tool shim {name!r} is missing _require_ctx(ctx) guard"
+            )
diff --git a/tests/test_services.py b/tests/test_services.py
index 2fde2bf..4ad1d40 100644
--- a/tests/test_services.py
+++ b/tests/test_services.py
@@ -119,6 +119,50 @@ def test_search_synonym_expansion_tracking(self, populated_with_content):
         svc.search("http", kind="section")
         assert svc._last_synonym_expanded is True
 
+    def test_search_does_not_classify_for_non_symbol_kinds(
+        self, populated_with_content, monkeypatch
+    ):
+        """M-5 (Round 3): for kind in ('section','example','page') the service
+        must not invoke classify_query / _symbol_exists at all — the DB
+        round-trip is pure waste when the result will never be consumed."""
+        from unittest.mock import MagicMock
+
+        svc = SearchService(populated_with_content, {})
+        mock_symbol_exists = MagicMock(return_value=True)
+        monkeypatch.setattr(svc, "_symbol_exists", mock_symbol_exists)
+
+        svc.search(query="socket", kind="section", max_results=5)
+        mock_symbol_exists.assert_not_called()
+
+        svc.search(query="socket", kind="example", max_results=5)
+        mock_symbol_exists.assert_not_called()
+
+        svc.search(query="socket", kind="page", max_results=5)
+        mock_symbol_exists.assert_not_called()
+
+    def test_search_classifies_for_auto_and_symbol_kinds(
+        self, populated_with_content, monkeypatch
+    ):
+        """M-5 (Round 3) positive control: for kind in ('auto','symbol') the
+        service still invokes classify_query / _symbol_exists — the gate must
+        not regress the fast-path routing."""
+        from unittest.mock import MagicMock
+
+        svc = SearchService(populated_with_content, {})
+        mock_symbol_exists = MagicMock(return_value=False)
+        monkeypatch.setattr(svc, "_symbol_exists", mock_symbol_exists)
+
+        # 'socket' is a lowercase identifier that matches _MODULE_PATTERN and
+        # passes the length>=2 short-circuit, so classify_query WILL call
+        # symbol_exists_fn. Dotted queries take the dot branch without calling
+        # the fn, so we use a single-word identifier instead.
+        svc.search(query="socket", kind="auto", max_results=5)
+        assert mock_symbol_exists.called
+
+        mock_symbol_exists.reset_mock()
+        svc.search(query="socket", kind="symbol", max_results=5)
+        assert mock_symbol_exists.called
+
 
 # === ContentService Tests ===
 
@@ -181,6 +225,45 @@ def test_get_docs_default_version(self, populated_with_content):
         result = svc.get_docs(slug="library/asyncio-task.html")
         assert result.version == "3.13"
 
+    def test_get_docs_falls_back_to_document_content_text_when_no_sections(
+        self, populated_db
+    ):
+        """I-1 (Round 3): when a document has zero sections, get_docs must fall
+        back to the documents.content_text column rather than returning empty.
+
+        Scenario: symbol-only builds (or pathological ingestion) can end up with a
+        documents row whose sections table has no matching rows. The service must
+        return a structured GetDocsResult whose content is the document-level
+        content_text, not the empty string.
+        """
+        db = populated_db
+        row = db.execute("SELECT id FROM doc_sets LIMIT 1").fetchone()
+        doc_set_id = row[0]
+        db.execute(
+            "UPDATE doc_sets SET built_at = '2026-04-16T00:00:00' WHERE id = ?",
+            (doc_set_id,),
+        )
+        # Seed a document with 'hello world' content and ZERO sections.
+        db.execute(
+            "INSERT INTO documents (doc_set_id, uri, slug, title, content_text, char_count) "
+            "VALUES (?, 'library/empty.html', 'library/empty.html', 'Empty Page', "
+            "'hello world', 11)",
+            (doc_set_id,),
+        )
+        db.commit()
+
+        svc = ContentService(db)
+        result = svc.get_docs(slug="library/empty.html")
+        assert isinstance(result, GetDocsResult)
+        assert result.content == "hello world"
+        assert result.char_count == 11
+        assert result.truncated is False
+        assert result.next_start_index is None
+        assert result.anchor is None
+        assert result.slug == "library/empty.html"
+        assert result.title == "Empty Page"
+        assert result.version == "3.13"
+
 
 # === VersionService Tests ===
 
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..c955d12
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,884 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+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/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+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 = "beautifulsoup4"
+version = "4.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "soupsieve" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+    { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+    { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+    { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+    { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+    { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+    { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+    { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+    { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+    { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+    { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+    { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+    { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+    { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
+]
+
+[[package]]
+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 = "cryptography"
+version = "46.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
+    { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
+    { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
+    { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
+    { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
+    { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
+    { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
+    { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
+    { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
+    { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
+    { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
+    { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
+    { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
+    { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
+    { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
+    { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
+    { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
+]
+
+[[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 = "httpx-sse"
+version = "0.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[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 = "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 = "markdownify"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "beautifulsoup4" },
+    { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" },
+]
+
+[[package]]
+name = "mcp"
+version = "1.27.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "httpx" },
+    { name = "httpx-sse" },
+    { name = "jsonschema" },
+    { name = "pydantic" },
+    { name = "pydantic-settings" },
+    { name = "pyjwt", extra = ["crypto"] },
+    { name = "python-multipart" },
+    { name = "pywin32", marker = "sys_platform == 'win32'" },
+    { name = "sse-starlette" },
+    { name = "starlette" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+    { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
+]
+
+[[package]]
+name = "mcp-server-python-docs"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+    { name = "click" },
+    { name = "mcp" },
+    { name = "platformdirs" },
+    { name = "pydantic" },
+    { name = "pyyaml" },
+    { name = "sphobjinv" },
+]
+
+[package.optional-dependencies]
+build = [
+    { name = "beautifulsoup4" },
+    { name = "markdownify" },
+]
+pysqlite3 = [
+    { name = "pysqlite3-binary" },
+]
+
+[package.dev-dependencies]
+dev = [
+    { name = "pyright" },
+    { name = "pytest" },
+    { name = "pytest-asyncio" },
+    { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "beautifulsoup4", marker = "extra == 'build'", specifier = ">=4.12,<5.0" },
+    { name = "click", specifier = ">=8.1.7,<9.0" },
+    { name = "markdownify", marker = "extra == 'build'", specifier = ">=0.14,<2.0" },
+    { name = "mcp", specifier = ">=1.27.0,<2.0.0" },
+    { name = "platformdirs", specifier = ">=4.0" },
+    { name = "pydantic", specifier = ">=2.0.0,<3.0" },
+    { name = "pysqlite3-binary", marker = "extra == 'pysqlite3'", specifier = ">=0.5.4" },
+    { name = "pyyaml", specifier = ">=6.0,<7.0" },
+    { name = "sphobjinv", specifier = ">=2.4,<3.0" },
+]
+provides-extras = ["pysqlite3", "build"]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "pyright", specifier = ">=1.1" },
+    { name = "pytest", specifier = ">=8.0" },
+    { name = "pytest-asyncio", specifier = ">=0.23" },
+    { name = "ruff", specifier = ">=0.4" },
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.9.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
+]
+
+[[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 = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-types" },
+    { name = "pydantic-core" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/6b/1353beb3d1cd5cf61cdec5b6f87a9872399de3bc5cae0b7ce07ff4de2ab0/pydantic-2.13.1.tar.gz", hash = "sha256:a0f829b279ddd1e39291133fe2539d2aa46cc6b150c1706a270ff0879e3774d2", size = 843746, upload-time = "2026-04-15T14:57:19.398Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/81/5a/2225f4c176dbfed0d809e848b50ef08f70e61daa667b7fa14b0d311ae44d/pydantic-2.13.1-py3-none-any.whl", hash = "sha256:9557ecc2806faaf6037f85b1fbd963d01e30511c48085f0d573650fdeaad378a", size = 471917, upload-time = "2026-04-15T14:57:17.277Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/93/f97a86a7eb28faa1d038af2fd5d6166418b4433659108a4c311b57128b2d/pydantic_core-2.46.1.tar.gz", hash = "sha256:d408153772d9f298098fb5d620f045bdf0f017af0d5cb6e309ef8c205540caa4", size = 471230, upload-time = "2026-04-15T14:49:34.52Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ce/fb/caaa8ee23861c170f07dbd58fc2be3a2c02a32637693cbb23eef02e84808/pydantic_core-2.46.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae8c8c5eb4c796944f3166f2f0dab6c761c2c2cc5bd20e5f692128be8600b9a4", size = 2119472, upload-time = "2026-04-15T14:49:45.946Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/61/bcffaa52894489ff89e5e1cdde67429914bf083c0db7296bef153020f786/pydantic_core-2.46.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:daba6f5f5b986aa0682623a1a4f8d1ecb0ec00ce09cfa9ca71a3b742bc383e3a", size = 1951230, upload-time = "2026-04-15T14:52:27.646Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/95/80d2f43a2a1a1e3220fd329d614aa5a39e0a75d24353a3aaf226e605f1c2/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0265f3a2460539ecc97817a80c7a23c458dd84191229b655522a2674f701f14e", size = 1976394, upload-time = "2026-04-15T14:50:32.742Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/31/2c5b1a207926b5fc1961a2d11da940129bc3841c36cc4df03014195b2966/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb16c0156c4b4e94aa3719138cc43c53d30ff21126b6a3af63786dcc0757b56e", size = 2068455, upload-time = "2026-04-15T14:50:01.286Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/36/c6aa07274359a51ac62895895325ce90107e811c6cea39d2617a99ef10d7/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b42d80fad8e4b283e1e4138f1142f0d038c46d137aad2f9824ad9086080dd41", size = 2239049, upload-time = "2026-04-15T14:53:02.216Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/3f/77cdd0db8bddc714842dfd93f737c863751cf02001c993341504f6b0cd53/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cced85896d5b795293bc36b7e2fb0347a36c828551b50cbba510510d928548c", size = 2318681, upload-time = "2026-04-15T14:50:04.539Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/a3/09d929a40e6727274b0b500ad06e1b3f35d4f4665ae1c8ba65acbb17e9b5/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a641cb1e74b44c418adaf9f5f450670dbec53511f030d8cde8d8accb66edc363", size = 2096527, upload-time = "2026-04-15T14:53:14.766Z" },
+    { url = "https://files.pythonhosted.org/packages/89/ae/544c3a82456ebc254a9fcbe2715bab76c70acf9d291aaea24391147943e4/pydantic_core-2.46.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:191e7a122ab14eb12415fe3f92610fc06c7f1d2b4b9101d24d490d447ac92506", size = 2170407, upload-time = "2026-04-15T14:51:27.138Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/ce/0dfd881c7af4c522f47b325707bd9a2cdcf4f40e4f2fd30df0e9a3e8d393/pydantic_core-2.46.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fe4ff660f7938b5d92f21529ce331b011aa35e481ab64b7cd03f52384e544bb", size = 2188578, upload-time = "2026-04-15T14:50:39.655Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/e9/980ea2a6d5114dd1a62ecc5f56feb3d34555f33bd11043f042e5f7f0724a/pydantic_core-2.46.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:18fcea085b3adc3868d8d19606da52d7a52d8bccd8e28652b0778dbe5e6a6660", size = 2188959, upload-time = "2026-04-15T14:52:42.243Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/f1/595e0f50f4bfc56cde2fe558f2b0978f29f2865da894c6226231e17464a5/pydantic_core-2.46.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e8e589e7c9466e022d79e13c5764c2239b2e5a7993ba727822b021234f89b56b", size = 2339973, upload-time = "2026-04-15T14:52:10.642Z" },
+    { url = "https://files.pythonhosted.org/packages/49/44/be9f979a6ab6b8c36865ccd92c3a38a760c66055e1f384665f35525134c4/pydantic_core-2.46.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f78eb3d4027963bdc9baccd177f02a98bf8714bc51fe17153d8b51218918b5bc", size = 2385228, upload-time = "2026-04-15T14:51:00.77Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/d4/c826cd711787d240219f01d0d3ca116cb55516b8b95277820aa9c85e1882/pydantic_core-2.46.1-cp312-cp312-win32.whl", hash = "sha256:54fe30c20cab03844dc63bdc6ddca67f74a2eb8482df69c1e5f68396856241be", size = 1978828, upload-time = "2026-04-15T14:50:29.362Z" },
+    { url = "https://files.pythonhosted.org/packages/22/05/8a1fcf8181be4c7a9cfc34e5fbf2d9c3866edc9dfd3c48d5401806e0a523/pydantic_core-2.46.1-cp312-cp312-win_amd64.whl", hash = "sha256:aea4e22ed4c53f2774221435e39969a54d2e783f4aee902cdd6c8011415de893", size = 2070015, upload-time = "2026-04-15T14:49:47.301Z" },
+    { url = "https://files.pythonhosted.org/packages/61/d5/fea36ad2882b99c174ef4ffbc7ea6523f6abe26060fbc1f77d6441670232/pydantic_core-2.46.1-cp312-cp312-win_arm64.whl", hash = "sha256:f76fb49c34b4d66aa6e552ce9e852ea97a3a06301a9f01ae82f23e449e3a55f8", size = 2030176, upload-time = "2026-04-15T14:50:47.307Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/d2/bda39bad2f426cb5078e6ad28076614d3926704196efe0d7a2a19a99025d/pydantic_core-2.46.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:cdc8a5762a9c4b9d86e204d555444e3227507c92daba06259ee66595834de47a", size = 2119092, upload-time = "2026-04-15T14:49:50.392Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/f3/69631e64d69cb3481494b2bddefe0ddd07771209f74e9106d066f9138c2a/pydantic_core-2.46.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba381dfe9c85692c566ecb60fa5a77a697a2a8eebe274ec5e4d6ec15fafad799", size = 1951400, upload-time = "2026-04-15T14:51:06.588Z" },
+    { url = "https://files.pythonhosted.org/packages/53/1c/21cb3db6ae997df31be8e91f213081f72ffa641cb45c89b8a1986832b1f9/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1593d8de98207466dc070118322fef68307a0cc6a5625e7b386f6fdae57f9ab6", size = 1976864, upload-time = "2026-04-15T14:50:54.804Z" },
+    { url = "https://files.pythonhosted.org/packages/91/9c/05c819f734318ce5a6ca24da300d93696c105af4adb90494ee571303afd8/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8262c74a1af5b0fdf795f5537f7145785a63f9fbf9e15405f547440c30017ed8", size = 2066669, upload-time = "2026-04-15T14:51:42.346Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/23/fadddf1c7f2f517f58731aea9b35c914e6005250f08dac9b8e53904cdbaa/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b88949a24182e83fbbb3f7ca9b7858d0d37b735700ea91081434b7d37b3b444", size = 2238737, upload-time = "2026-04-15T14:50:45.558Z" },
+    { url = "https://files.pythonhosted.org/packages/23/07/0cd4f95cb0359c8b1ec71e89c3777e7932c8dfeb9cd54740289f310aaead/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8f3708cd55537aeaf3fd0ea55df0d68d0da51dcb07cbc8508745b34acc4c6e0", size = 2316258, upload-time = "2026-04-15T14:51:08.471Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/40/6fc24c3766a19c222a0d60d652b78f0283339d4cd4c173fab06b7ee76571/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f79292435fff1d4f0c18d9cfaf214025cc88e4f5104bfaed53f173621da1c743", size = 2097474, upload-time = "2026-04-15T14:49:56.543Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/af/f39795d1ce549e35d0841382b9c616ae211caffb88863147369a8d74fba9/pydantic_core-2.46.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a2e607aeb59cf4575bb364470288db3b9a1f0e7415d053a322e3e154c1a0802e", size = 2168383, upload-time = "2026-04-15T14:51:29.269Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/32/0d563f74582795779df6cc270c3fc220f49f4daf7860d74a5a6cda8491ff/pydantic_core-2.46.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec5ca190b75878a9f6ae1fc8f5eb678497934475aef3d93204c9fa01e97370b6", size = 2186182, upload-time = "2026-04-15T14:50:19.097Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/07/1c10d5ce312fc4cf86d1e50bdcdbb8ef248409597b099cab1b4bb3a093f7/pydantic_core-2.46.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1f80535259dcdd517d7b8ca588d5ca24b4f337228e583bebedf7a3adcdf5f721", size = 2187859, upload-time = "2026-04-15T14:49:22.974Z" },
+    { url = "https://files.pythonhosted.org/packages/92/01/e1f62d4cb39f0913dbf5c95b9b119ef30ddba9493dff8c2b012f0cdd67dc/pydantic_core-2.46.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:24820b3c82c43df61eca30147e42853e6c127d8b868afdc0c162df829e011eb4", size = 2338372, upload-time = "2026-04-15T14:49:53.316Z" },
+    { url = "https://files.pythonhosted.org/packages/44/ed/218dfeea6127fb1781a6ceca241ec6edf00e8a8933ff331af2215975a534/pydantic_core-2.46.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f12794b1dd8ac9fb66619e0b3a0427189f5d5638e55a3de1385121a9b7bf9b39", size = 2384039, upload-time = "2026-04-15T14:53:04.929Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/1e/011e763cd059238249fbd5780e0f8d0b04b47f86c8925e22784f3e5fc977/pydantic_core-2.46.1-cp313-cp313-win32.whl", hash = "sha256:9bc09aed935cdf50f09e908923f9efbcca54e9244bd14a5a0e2a6c8d2c21b4e9", size = 1977943, upload-time = "2026-04-15T14:52:17.969Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/06/b559a490d3ed106e9b1777b8d5c8112dd8d31716243cd662616f66c1f8ea/pydantic_core-2.46.1-cp313-cp313-win_amd64.whl", hash = "sha256:fac2d6c8615b8b42bee14677861ba09d56ee076ba4a65cfb9c3c3d0cc89042f2", size = 2068729, upload-time = "2026-04-15T14:53:07.288Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/52/32a198946e2e19508532aa9da02a61419eb15bd2d96bab57f810f2713e31/pydantic_core-2.46.1-cp313-cp313-win_arm64.whl", hash = "sha256:f978329f12ace9f3cb814a5e44d98bbeced2e36f633132bafa06d2d71332e33e", size = 2029550, upload-time = "2026-04-15T14:52:22.707Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/2b/6793fe89ab66cb2d3d6e5768044eab80bba1d0fae8fd904d0a1574712e17/pydantic_core-2.46.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9917cb61effac7ec0f448ef491ec7584526d2193be84ff981e85cbf18b68c42a", size = 2118110, upload-time = "2026-04-15T14:50:52.947Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/87/e9a905ddfcc2fd7bd862b340c02be6ab1f827922822d425513635d0ac774/pydantic_core-2.46.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e749679ca9f8a9d0bff95fb7f6b57bb53f2207fa42ffcc1ec86de7e0029ab89", size = 1948645, upload-time = "2026-04-15T14:51:55.577Z" },
+    { url = "https://files.pythonhosted.org/packages/15/23/26e67f86ed62ac9d6f7f3091ee5220bf14b5ac36fb811851d601365ef896/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2ecacee70941e233a2dad23f7796a06f86cc10cc2fbd1c97c7dd5b5a79ffa4f", size = 1977576, upload-time = "2026-04-15T14:49:37.58Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/78/813c13c0de323d4de54ee2e6fdd69a0271c09ac8dd65a8a000931aa487a5/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:647d0a2475b8ed471962eed92fa69145b864942f9c6daa10f95ac70676637ae7", size = 2060358, upload-time = "2026-04-15T14:51:40.087Z" },
+    { url = "https://files.pythonhosted.org/packages/09/5e/4caf2a15149271fbd2b4d968899a450853c800b85152abcf54b11531417f/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac9cde61965b0697fce6e6cc372df9e1ad93734828aac36e9c1c42a22ad02897", size = 2235980, upload-time = "2026-04-15T14:50:34.535Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/c1/a2cdabb5da6f5cb63a3558bcafffc20f790fa14ccffbefbfb1370fadc93f/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a2eb0864085f8b641fb3f54a2fb35c58aff24b175b80bc8a945050fcde03204", size = 2316800, upload-time = "2026-04-15T14:52:46.999Z" },
+    { url = "https://files.pythonhosted.org/packages/76/fd/19d711e4e9331f9d77f222bffc202bf30ea0d74f6419046376bb82f244c8/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b83ce9fede4bc4fb649281d9857f06d30198b8f70168f18b987518d713111572", size = 2101762, upload-time = "2026-04-15T14:49:24.278Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/64/ce95625448e1a4e219390a2923fd594f3fa368599c6b42ac71a5df7238c9/pydantic_core-2.46.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:cb33192753c60f269d2f4a1db8253c95b0df6e04f2989631a8cc1b0f4f6e2e92", size = 2167737, upload-time = "2026-04-15T14:50:41.637Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/31/413572d03ca3e73b408f00f54418b91a8be6401451bc791eaeff210328e5/pydantic_core-2.46.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96611d51f953f87e1ae97637c01ee596a08b7f494ea00a5afb67ea6547b9f53b", size = 2185658, upload-time = "2026-04-15T14:51:46.799Z" },
+    { url = "https://files.pythonhosted.org/packages/36/09/e4f581353bdf3f0c7de8a8b27afd14fc761da29d78146376315a6fedc487/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9b176fa55f9107db5e6c86099aa5bfd934f1d3ba6a8b43f714ddeebaed3f42b7", size = 2184154, upload-time = "2026-04-15T14:52:49.629Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/a4/d0d52849933f5a4bf1ad9d8da612792f96469b37e286a269e3ee9c60bbb1/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:79a59f63a4ce4f3330e27e6f3ce281dd1099453b637350e97d7cf24c207cd120", size = 2332379, upload-time = "2026-04-15T14:49:55.009Z" },
+    { url = "https://files.pythonhosted.org/packages/30/93/25bfb08fdbef419f73290e573899ce938a327628c34e8f3a4bafeea30126/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:f200fce071808a385a314b7343f5e3688d7c45746be3d64dc71ee2d3e2a13268", size = 2377964, upload-time = "2026-04-15T14:51:59.649Z" },
+    { url = "https://files.pythonhosted.org/packages/15/36/b777766ff83fef1cf97473d64764cd44f38e0d8c269ed06faace9ae17666/pydantic_core-2.46.1-cp314-cp314-win32.whl", hash = "sha256:3a07eccc0559fb9acc26d55b16bf8ebecd7f237c74a9e2c5741367db4e6d8aff", size = 1976450, upload-time = "2026-04-15T14:51:57.665Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/4b/4cd19d2437acfc18ca166db5a2067040334991eb862c4ecf2db098c91fbf/pydantic_core-2.46.1-cp314-cp314-win_amd64.whl", hash = "sha256:1706d270309ac7d071ffe393988c471363705feb3d009186e55d17786ada9622", size = 2067750, upload-time = "2026-04-15T14:49:38.941Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/a0/490751c0ef8f5b27aae81731859aed1508e72c1a9b5774c6034269db773b/pydantic_core-2.46.1-cp314-cp314-win_arm64.whl", hash = "sha256:22d4e7457ade8af06528012f382bc994a97cc2ce6e119305a70b3deff1e409d6", size = 2021109, upload-time = "2026-04-15T14:50:27.728Z" },
+    { url = "https://files.pythonhosted.org/packages/36/3a/2a018968245fffd25d5f1972714121ad309ff2de19d80019ad93494844f9/pydantic_core-2.46.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:607ff9db0b7e2012e7eef78465e69f9a0d7d1c3e7c6a84cf0c4011db0fcc3feb", size = 2111548, upload-time = "2026-04-15T14:52:08.273Z" },
+    { url = "https://files.pythonhosted.org/packages/77/5b/4103b6192213217e874e764e5467d2ff10d8873c1147d01fa432ac281880/pydantic_core-2.46.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cda3eacaea13bd02a1bea7e457cc9fc30b91c5a91245cef9b215140f80dd78c", size = 1926745, upload-time = "2026-04-15T14:50:03.045Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/70/602a667cf4be4bec6c3334512b12ae4ea79ce9bfe41dc51be1fd34434453/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9493279cdc7997fe19e5ed9b41f30cbc3806bd4722adb402fedb6f6d41bd72a", size = 1965922, upload-time = "2026-04-15T14:51:12.555Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/24/06a89ce5323e755b7d2812189f9706b87aaebe49b34d247b380502f7992c/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3644e5e10059999202355b6c6616e624909e23773717d8f76deb8a6e2a72328c", size = 2043221, upload-time = "2026-04-15T14:51:18.995Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/6e/b1d9ad907d9d76964903903349fd2e33c87db4b993cc44713edcad0fc488/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ad6c9de57683e26c92730991960c0c3571b8053263b042de2d3e105930b2767", size = 2243655, upload-time = "2026-04-15T14:50:10.718Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/73/787abfaad51174641abb04c8aa125322279b40ad7ce23c495f5a69f76554/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:557ebaa27c7617e7088002318c679a8ce685fa048523417cd1ca52b7f516d955", size = 2295976, upload-time = "2026-04-15T14:53:09.694Z" },
+    { url = "https://files.pythonhosted.org/packages/56/0b/b7c5a631b6d5153d4a1ea4923b139aea256dc3bd99c8e6c7b312c7733146/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cd37e39b22b796ba0298fe81e9421dd7b65f97acfbb0fb19b33ffdda7b9a7b4", size = 2103439, upload-time = "2026-04-15T14:50:08.32Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/3f/952ee470df69e5674cdec1cbde22331adf643b5cc2ff79f4292d80146ee4/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:6689443b59714992e67d62505cdd2f952d6cf1c14cc9fd9aeec6719befc6f23b", size = 2132871, upload-time = "2026-04-15T14:50:24.445Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/8b/1dea3b1e683c60c77a60f710215f90f486755962aa8939dbcb7c0f975ac3/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f32c41ca1e3456b5dd691827b7c1433c12d5f0058cc186afbb3615bc07d97b8", size = 2168658, upload-time = "2026-04-15T14:52:24.897Z" },
+    { url = "https://files.pythonhosted.org/packages/67/97/32ae283810910d274d5ba9f48f856f5f2f612410b78b249f302d297816f5/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:88cd1355578852db83954dc36e4f58f299646916da976147c20cf6892ba5dc43", size = 2171184, upload-time = "2026-04-15T14:52:34.854Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/57/c9a855527fe56c2072070640221f53095b0b19eaf651f3c77643c9cabbe3/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:a170fefdb068279a473cc9d34848b85e61d68bfcc2668415b172c5dfc6f213bf", size = 2316573, upload-time = "2026-04-15T14:52:12.871Z" },
+    { url = "https://files.pythonhosted.org/packages/37/b3/14c39ffc7399819c5448007c7bcb4e6da5669850cfb7dcbb727594290b48/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:556a63ff1006934dba4eed7ea31b58274c227e29298ec398e4275eda4b905e95", size = 2378340, upload-time = "2026-04-15T14:51:02.619Z" },
+    { url = "https://files.pythonhosted.org/packages/01/55/a37461fbb29c053ea4e62cfc5c2d56425cb5efbef8316e63f6d84ae45718/pydantic_core-2.46.1-cp314-cp314t-win32.whl", hash = "sha256:3b146d8336a995f7d7da6d36e4a779b7e7dff2719ac00a1eb8bd3ded00bec87b", size = 1960843, upload-time = "2026-04-15T14:52:06.103Z" },
+    { url = "https://files.pythonhosted.org/packages/22/d7/97e1221197d17a27f768363f87ec061519eeeed15bbd315d2e9d1429ff03/pydantic_core-2.46.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f1bc856c958e6fe9ec071e210afe6feb695f2e2e81fd8d2b102f558d364c4c17", size = 2048696, upload-time = "2026-04-15T14:52:52.154Z" },
+    { url = "https://files.pythonhosted.org/packages/19/d5/4eac95255c7d35094b46a32ec1e4d80eac94729c694726ee1d69948bd5f0/pydantic_core-2.46.1-cp314-cp314t-win_arm64.whl", hash = "sha256:21a5bfd8a1aa4de60494cdf66b0c912b1495f26a8899896040021fbd6038d989", size = 2022343, upload-time = "2026-04-15T14:49:49.036Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/97/95de673a1356a88b2efdaa120eb6af357a81555c35f6809a7a1423ff7aef/pydantic_core-2.46.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:5f9107a24a4bc00293434dfa95cf8968751ad0dd703b26ea83a75a56f7326041", size = 2107564, upload-time = "2026-04-15T14:50:49.14Z" },
+    { url = "https://files.pythonhosted.org/packages/00/fc/a7c16d85211ea9accddc693b7d049f20b0c06440d9264d1e1c074394ee6c/pydantic_core-2.46.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:2b1801ba99876984d0a03362782819238141c4d0f3f67f69093663691332fc35", size = 1939925, upload-time = "2026-04-15T14:50:36.188Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/23/87841169d77820ddabeb81d82002c95dcb82163846666d74f5bdeeaec750/pydantic_core-2.46.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7fd82a91a20ed6d54fa8c91e7a98255b1ff45bf09b051bfe7fe04eb411e232e", size = 1995313, upload-time = "2026-04-15T14:50:22.538Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/96/b46609359a354fa9cd336fc5d93334f1c358b756cc81e4b397347a88fa6f/pydantic_core-2.46.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f135bf07c92c93def97008bc4496d16934da9efefd7204e5f22a2c92523cb1f", size = 2151197, upload-time = "2026-04-15T14:51:22.925Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pydantic" },
+    { name = "python-dotenv" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
+]
+
+[package.optional-dependencies]
+crypto = [
+    { name = "cryptography" },
+]
+
+[[package]]
+name = "pyright"
+version = "1.1.408"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "nodeenv" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
+]
+
+[[package]]
+name = "pysqlite3-binary"
+version = "0.5.4.post2"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/35/e8/292e14aa4ed1ef3d4a70703c0103823fcd4b7d9701d9462e52ef88c2cc10/pysqlite3_binary-0.5.4.post2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b6162cd966fa563fe85b5372c3e61d11dd7903bd0f09cc185cb0a4c9125f4a0f", size = 4951088, upload-time = "2025-12-03T18:36:39.786Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/89/338819970e306cae579aa570091a35d01df01d95fe159f2e5002b58b7481/pysqlite3_binary-0.5.4.post2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:930c7597a0863ef3da721e538756c2768cee14cb9b8d2c037263d061b24f66a5", size = 4943344, upload-time = "2025-12-03T18:36:54.992Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/00/9dc79fa319ee2f2fb8dc35bd5393b9fa79936899523c9640d2ca7206c742/pysqlite3_binary-0.5.4.post2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:da62981abfbfb4b3d0a9e339932fe44f8d7f3fc62037851f89ea224409ed1767", size = 4943501, upload-time = "2025-12-03T18:37:07.853Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pytest" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.26"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "311"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
+    { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
+    { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
+]
+
+[[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/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 = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "attrs" },
+    { name = "rpds-py" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+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 = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+    { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+    { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+    { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+    { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+    { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+    { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+    { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
+    { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
+    { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
+    { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
+    { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
+    { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
+    { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
+    { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
+    { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
+    { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
+    { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
+    { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
+    { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
+    { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
+    { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
+    { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
+    { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
+    { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
+    { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
+    { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
+    { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
+    { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
+    { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
+    { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
+    { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
+    { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
+    { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
+    { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
+    { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
+    { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
+]
+
+[[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 = "sse-starlette"
+version = "3.3.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "starlette" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.44.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
+]