From 45153abd7f3eef925f263b7a42d17173d3852c6b Mon Sep 17 00:00:00 2001 From: npub17xpz0p704l6vlapga6nahzevr9h0kd9ggfzw640d9yevhmcgst2ql280uq Date: Sun, 2 Aug 2026 16:09:03 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(mcp):=20add=20MCP=20server=20(Stage=20?= =?UTF-8?q?1=20=E2=80=94=20skeleton=20+=20free=20tools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/snore/mcp/ as the third presentation layer over the async service layer, alongside the CLI and FastAPI. Implements the v3.3 plan Stage 1: skeleton, free tools, resources, and clinical profiles. Stage 2 (tuning tools) and Stage 3 (vision/CA) will follow after PR-A (import-time analysis + Breath model) merges. Package layout: - server.py: FastMCP instance, lifespan (DatabaseTarget), tool_error_boundary, RESPONSE_SIZE_LIMIT, docs://tools + docs://schemas/{type} + docs://capabilities - tools/overview.py: get_data_overview — cold-start orientation - tools/settings.py: get_settings_timeline — RxTracker adapter - tools/summary.py: get_nightly_summary — StatsService/DayService adapter with pagination, compliance fields, and analysis_not_run fallback (A2) - tools/events.py: get_events — EventService adapter with inline context - profiles.py: neutral/uars/osa/csa clinical presets (instructions only, G1) - schemas.py: Pydantic response models + SCHEMA_MODEL_MAP for docs://schemas - validation.py: ISO 8601 date parsing helpers - errors.py: MCP-specific exception types CLI: snore mcp [--db PATH] [--profile NAME] [--transport stdio] pyproject.toml: add fastmcp>=3 to main dependencies Tests: 61 new tests (44 unit + 17 integration), all passing. Covers: validation, profiles, schemas, error boundary, size guard, get_data_overview, get_settings_timeline, get_nightly_summary (with compliance and null-reason), get_events (type/duration filters, context). Compliant with v3.1 doctrine: G1 (profile shapes instructions only), G2 (null + reason), G3 (no module-global state, session_scope per call), G4 (no vendor branching in MCP). just check + just test green. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/mcp-server-plan.md | 166 +++--- pyproject.toml | 1 + src/snore/cli/__init__.py | 2 + src/snore/cli/commands/mcp.py | 57 +++ src/snore/mcp/__init__.py | 1 + src/snore/mcp/docs/tools.md | 124 +++++ src/snore/mcp/errors.py | 19 + src/snore/mcp/profiles.py | 106 ++++ src/snore/mcp/schemas.py | 222 ++++++++ src/snore/mcp/server.py | 412 +++++++++++++++ src/snore/mcp/tools/__init__.py | 1 + src/snore/mcp/tools/events.py | 119 +++++ src/snore/mcp/tools/overview.py | 136 +++++ src/snore/mcp/tools/settings.py | 69 +++ src/snore/mcp/tools/summary.py | 220 ++++++++ src/snore/mcp/validation.py | 67 +++ tests/integration/test_mcp_tools.py | 466 +++++++++++++++++ tests/unit/test_mcp_profiles.py | 65 +++ tests/unit/test_mcp_schemas.py | 99 ++++ tests/unit/test_mcp_server.py | 123 +++++ tests/unit/test_mcp_validation.py | 64 +++ uv.lock | 769 +++++++++++++++++++++++++++- 22 files changed, 3239 insertions(+), 69 deletions(-) create mode 100644 src/snore/cli/commands/mcp.py create mode 100644 src/snore/mcp/__init__.py create mode 100644 src/snore/mcp/docs/tools.md create mode 100644 src/snore/mcp/errors.py create mode 100644 src/snore/mcp/profiles.py create mode 100644 src/snore/mcp/schemas.py create mode 100644 src/snore/mcp/server.py create mode 100644 src/snore/mcp/tools/__init__.py create mode 100644 src/snore/mcp/tools/events.py create mode 100644 src/snore/mcp/tools/overview.py create mode 100644 src/snore/mcp/tools/settings.py create mode 100644 src/snore/mcp/tools/summary.py create mode 100644 src/snore/mcp/validation.py create mode 100644 tests/integration/test_mcp_tools.py create mode 100644 tests/unit/test_mcp_profiles.py create mode 100644 tests/unit/test_mcp_schemas.py create mode 100644 tests/unit/test_mcp_server.py create mode 100644 tests/unit/test_mcp_validation.py diff --git a/docs/mcp-server-plan.md b/docs/mcp-server-plan.md index 22b419da..9140ccb1 100644 --- a/docs/mcp-server-plan.md +++ b/docs/mcp-server-plan.md @@ -1,99 +1,131 @@ -# SNORE MCP Server Plan — v2 +# SNORE MCP Server Plan — v3.3 -MCP as a third thin presentation layer over the existing service layer, peer of the -CLI and FastAPI backend. Purpose-built for LLM-assisted PAP settings tuning. - -**Sequenced behind the async migration.** MCP tools will be native-async from day one -(no retrofit). Dispatch begins after PR-2 merges. +MCP as a third thin presentation layer over the existing async service layer, peer of the +CLI and FastAPI backend. Purpose-built for LLM-assisted PAP settings tuning but designed +with generic contracts suitable for any PAP dataset. --- ## Context -~60% of the analysis machinery already exists: breath segmenter, 7-class flow-shape -classifier, RERA-proxy detector, `RxTracker` epochs, LTTB waveform downsampling. The -work is MCP wiring + per-breath persistence + three new query tools. +SNORE started life as OSCAR-MCP; commit `dd08225` deliberately removed a 762-line FastMCP +server (8 tools, 3 resources) with intent to re-add later. This plan re-adds MCP as a +**third thin presentation layer** over the existing async service layer — never a place +where analysis logic lives. --- -## Locked decisions +## Design Doctrine + +**Tiered data access — computed metrics primary, rendered PNG charts secondary, raw arrays +tertiary and tightly capped.** Progressive disclosure: overview → summary → events → breath +table → raw waveform. Raw data is never the entry point. -1. **Transport: stdio now, HTTP-ready by construction.** FastMCP serves both stdio and - streamable-HTTP from the same tool definitions. We ship `snore mcp` (stdio); the - future hosted mode is `snore mcp --http` plus auth — zero tool rewrites required. +- **Compute server-side, return compact JSON.** Units on every field. Timestamps ISO 8601 + with explicit offset. +- **Data-quality flags everywhere**: per-window/per-breath `leak_valid`, `mask_off`, + `ramp_active` so junk is excludable automatically. +- **Algorithm versioning in output** (`fl_algo: "v1.2"`) so epoch comparisons never + silently span algorithm changes. -2. **No backfill, no lazy path.** Breath features are computed in the import pipeline - only. Drop-DB-and-reimport is the sole population mechanism and the recovery path - if the algorithm version changes. One code path; import gets slightly slower when - Phase 2 lands (timing reported at that PR). +## Genericity Principles -3. **Journal/subjective: dropped.** `log_subjective`/`get_subjective` are out. The - roadmap's Phase 3 journal item is a separate, unrelated thing. +- **G1 — Profile-parameterized, neutral by default.** Clinical emphasis lives in named + profiles, not in tool design. No tool returns different data per profile. +- **G2 — Capability-honest.** Absent data is `null` + reason, never fabricated. +- **G3 — Stateless and scope-ready.** No module-global state; DB via lifespan-provided + session factory; explicit ranges/filters in every tool. +- **G4 — Vendor dispatch stays in the parser/service layer.** --- -## Phases +## Locked Decisions -### Phase 1 — MCP skeleton + free tools +1. **Transport: stdio now, HTTP-ready by construction.** `snore mcp` (stdio). FastMCP + serves stdio and streamable-HTTP from the same tool definitions. +2. **Full analysis at import time as async background job.** Import commits first, + analysis follows without blocking ingestion. On-demand `snore analysis run` stays. +3. **No Alembic migrations — ever.** Fresh DBs get the right schema via `create_all`. + Pre-existing DBs: breath-backed tools return capability-honest error → drop + reimport. +4. **`matplotlib` as a main dependency** when `render_window` lands (Phase 2/PR-B Stage 3). +5. **Tools are `async def`** calling the async service layer natively. -**Depends on:** async migration complete (PR-2 merged) +--- -- `src/snore/mcp/` package, FastMCP, `snore mcp` CLI entry point - (stdio; transport param plumbed for HTTP later) -- **`get_settings_timeline`** — adapter over `RxTracker` -- **`get_nightly_summary`** — adapter over `StatsService`/`DayService`, paginated - (~30 nights/call); returns AHI split + RERA + leak/pressure percentiles + MV/RR/TV -- **`get_events`** — `EventService` + inline context per event: pressure/leak at - event time, MV prior 120 s, minutes since session start -- Clinical-context instructions resource (reframed for UARS/RDI; AHI de-emphasised) -- Every field carries units; timestamps ISO 8601 with UTC offset +## 2-PR Structure -### Phase 2 — Breath-feature persistence +### PR-A — Substrate (@Hayt, merges first) +`Breath` model, import-time background analysis, breath persistence, `breath_service.py`. +Zero MCP knowledge. Branch: `will/import-time-analysis`. -**Depends on:** async migration complete (can run parallel with Phase 1) +**Boundary:** `database/models.py`, `analysis/**`, `services/**` (incl. new +`breath_service.py`), `api/**`, `cli/commands/import_data.py`, tests. -- New `breaths` table persisting per-breath metrics at import time: - - `BreathMetrics` fields + `ShapeFeatures` - - Flow class + confidence (7-class classifier) - - Mid-inspiratory flattening index (alongside existing `flatness_index`; both - versioned) - - Recovery-breath flag - - Inferred trigger/cycle type (flagged `experimental`, with confidence) - - Per-breath quality flags: `leak_valid`, `ramp_active`, `mask_off` - - Algorithm version stamp per row batch -- Alembic migration; populated only by fresh imports (reimport populates, no backfill) +### PR-B — Complete MCP layer (@Duncan, merges second) +`src/snore/mcp/` package, all ~10 tools across Stages 1–3, resources, profiles, CLI entry, +`fastmcp`/`matplotlib` deps, this doc. Branch: `will/mcp-server`. -### Phase 3 — Core tuning tools +**Boundary:** `src/snore/mcp/**`, `cli/commands/mcp.py`, `cli/__init__.py` (register only), +`pyproject.toml`, `docs/mcp-server-plan.md`. -**Depends on:** Phases 1 + 2 +**Staged internally:** +- Stage 1 (no PR-A dependency): skeleton + free tools (`get_data_overview`, + `get_settings_timeline`, `get_nightly_summary`, `get_events`) + resources + profiles. +- Stage 2 (after PR-A merges + rebase): `get_breath_table`, `find_windows`, + `compare_epochs` (via `breath_service`). +- Stage 3 (same PR): `render_window` (matplotlib), `get_waveform` (LTTB), + `get_ca_analysis`. + +--- -- **`get_breath_table`** — windowed (~15 min cap), binned aggregates beyond the cap -- **`find_windows`** — criteria queries over the `breaths` table: - - Worst-N windows by flattening with leak-valid filter - - Windows centred on central apnoeas - - FL-run-ending-in-recovery-breath -- **`compare_epochs`** — `RxTracker` epochs × breath-feature distributions, leak-valid - time only; median/IQR/95th + nights-per-epoch +## Tools (current — Stage 1) -### Phase 4 — Vision + TECA +### get_data_overview +Cold-start orientation. Call first. Returns devices, date ranges, available waveform +channels, event types, analysis status. -**Depends on:** Phase 3 +### get_settings_timeline(start, end, device_id?) +Therapy settings epochs. Generic `RX_KEYS` only. Changed keys flagged per epoch. -- **`render_window`** — server-rendered PNG (matplotlib) for short windows; enables - visual review without the client downloading raw arrays -- **`get_waveform`** — raw downsampled arrays, tier-3 escape hatch (≤ 2 min, - ≤ 1000 pts/channel) -- **`get_ca_analysis`** — per-CA preceding MV slope + PS delivered + stability; - night-level periodic-breathing % and MV rolling variance (extends existing pattern - detector); the ASV case-file tool +### get_nightly_summary(start, end, device_id?, page, page_size, compliance_threshold_hours) +Per-night summary, paginated. Analysis-derived fields (RERA index, RDI) are `null` + +`analysis_not_run` when analysis absent. Compliance block in range mode. + +### get_events(date, types?, min_duration?, include_context) +Respiratory events for a session date. Inline context: minutes since session start. --- -## Success criteria +## Resources + +- `docs://tools` — complete tool reference (this package's `docs/tools.md`) +- `docs://schemas/{type}` — JSON schema for any named Pydantic response type +- `docs://capabilities` — dynamically generated from imported data + +--- + +## Clinical Profiles + +Profiles shape the INSTRUCTIONS resource and priority hints only (G1). No tool returns +different data per profile. Available: `neutral` (default), `uars`, `osa`, `csa`. + +--- + +## Implementation Conventions + +- `fastmcp>=3` standalone (NOT `mcp[cli]`) +- `tool_error_boundary` on every tool +- `RESPONSE_SIZE_LIMIT` guard (500 KB); returns narrow-your-query guidance +- `docs://tools` resource + REQUIRED-READING preamble +- Lifespan resolves DB via `DatabaseTarget.from_env_and_flags` +- `session_scope()` per tool call — no module-global state + +--- -Claude Desktop/Code connects over stdio and can, in one conversation: +## Success Criteria -1. Identify a night's worst flow-limitation windows with leak-valid filtering -2. Pull the breath table for one window -3. View the server-rendered PNG -4. Compare two settings epochs with real distribution stats +Claude Desktop/Code connects over stdio and can, in one conversation: orient via +`get_data_overview` → identify worst flow-limitation windows → pull the breath table for +one → view the PNG → compare two settings epochs with real distribution stats — using only +generic tool contracts, with `clinical_profile: uars` active. Stage 1 covers the +orientation and summary half of this workflow; Stages 2–3 complete it. diff --git a/pyproject.toml b/pyproject.toml index b0740d1a..acb34ee3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "python-multipart>=0.0.32", "jinja2>=3.1", "aiosqlite>=0.22.1", + "fastmcp>=3", ] [project.optional-dependencies] diff --git a/src/snore/cli/__init__.py b/src/snore/cli/__init__.py index 77238523..aa8f4486 100644 --- a/src/snore/cli/__init__.py +++ b/src/snore/cli/__init__.py @@ -55,6 +55,7 @@ def cli(verbose: bool) -> None: def _register_commands() -> None: from snore.cli.commands.import_data import import_data + from snore.cli.commands.mcp import mcp from snore.cli.commands.serve import serve from snore.cli.commands.setup import setup from snore.cli.commands.stats import stats @@ -76,6 +77,7 @@ def _register_commands() -> None: cli.add_command(stats) cli.add_command(validate) cli.add_command(serve) + cli.add_command(mcp) cli.add_command(db) cli.add_command(session) diff --git a/src/snore/cli/commands/mcp.py b/src/snore/cli/commands/mcp.py new file mode 100644 index 00000000..d6a61468 --- /dev/null +++ b/src/snore/cli/commands/mcp.py @@ -0,0 +1,57 @@ +"""mcp command — launch the SNORE MCP server over stdio.""" + +from __future__ import annotations + +import logging + +import click + +logger = logging.getLogger(__name__) + + +@click.command() +@click.option("--db", default=None, help="Path to SQLite database file") +@click.option( + "--profile", + default="neutral", + show_default=True, + help="Clinical profile: neutral, uars, osa, csa", +) +@click.option( + "--transport", + default="stdio", + show_default=True, + help="Transport mode (stdio now; http in a future release)", +) +def mcp(db: str | None, profile: str, transport: str) -> None: + """Launch the SNORE MCP server. + + Starts the FastMCP server using the stdio transport (default), suitable + for Claude Desktop / Claude Code integration. + + Database resolution uses the same precedence chain as 'snore serve': + --db > SNORE_DATABASE_URL > SNORE_DB_PATH > default SQLite path + + Clinical profiles shape the INSTRUCTIONS resource and priority hints only; + they do not change the data returned by any tool (G1). Available profiles: + neutral (default), uars, osa, csa. + """ + from snore.mcp.profiles import VALID_PROFILES + + if profile not in VALID_PROFILES: + raise click.BadParameter( + f"Unknown profile {profile!r}. Choose from: {sorted(VALID_PROFILES)}", + param_hint="--profile", + ) + + if transport != "stdio": + raise click.BadParameter( + f"Transport {transport!r} is not yet supported. Only 'stdio' is available.", + param_hint="--transport", + ) + + from snore.mcp.server import make_server + + logger.debug("snore mcp: profile=%s db=%r", profile, db) + server = make_server(db_flag=db, profile_name=profile) + server.run(transport="stdio") diff --git a/src/snore/mcp/__init__.py b/src/snore/mcp/__init__.py new file mode 100644 index 00000000..143ea25b --- /dev/null +++ b/src/snore/mcp/__init__.py @@ -0,0 +1 @@ +"""SNORE MCP server package.""" diff --git a/src/snore/mcp/docs/tools.md b/src/snore/mcp/docs/tools.md new file mode 100644 index 00000000..14806da9 --- /dev/null +++ b/src/snore/mcp/docs/tools.md @@ -0,0 +1,124 @@ +# SNORE MCP Tools + +SNORE MCP server provides LLM-accessible tools for PAP therapy data analysis. +All tools are **stateless service-layer calls** — they never store state between calls. + +## General Information + +### Date Format +All `date`, `start`, `end` parameters accept **YYYY-MM-DD** format only. +Example: `"2025-08-01"`. + +### Null fields and reasons +When a data field is absent (device does not record it, analysis has not been run, etc.) +the field is `null` and a companion `*_reason` field explains why. +Example: `"rera_index": null, "rera_index_reason": "analysis_not_run"`. + +### Device capabilities block +Most tools return a `device_capabilities` block declaring what the device/dataset +actually provides for the queried range. Do not assume a channel is present — always +check this block before interpreting a null value. + +### Clinical profiles +The server is configured with a clinical profile (`neutral` by default). Profiles +shape the instructions and priority hints only — tools always return the same data +regardless of profile. To change the active profile, restart the server with +`snore mcp --profile `. Available profiles: `neutral`, `uars`, `osa`, `csa`. + +## Recommended Workflow + +1. **Orient** — call `get_data_overview` to discover devices, date ranges, and channels. +2. **Summarize** — call `get_nightly_summary` over a range to identify nights of interest. +3. **Settings** — call `get_settings_timeline` to understand settings epochs. +4. **Events** — call `get_events` on a specific date for event-level detail. +5. (Phase 2+) `get_breath_table`, `find_windows`, `compare_epochs` for flow morphology tuning. +6. (Phase 3+) `render_window`, `get_waveform` for visual inspection and raw escape hatch. + +## Tools + +--- + +### get_data_overview + +Cold-start orientation tool. Call this first to discover what is imported. + +**Parameters:** none + +**Returns:** +- `devices` — list of devices with id, manufacturer, model, date range, session count, therapy modes +- `date_range_start` / `date_range_end` — full imported date range (all devices) +- `total_sessions` — total enabled session count +- `available_waveform_channels` — list of waveform channel names present in any session +- `available_event_types` — list of event type codes present (e.g. `["CA", "H", "OA", "RERA"]`) +- `analysis_run` — whether any analysis results exist +- `analysis_session_count` — number of sessions with analysis results + +--- + +### get_settings_timeline + +Returns therapy settings epochs — contiguous periods with identical settings. + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| start | str (YYYY-MM-DD) | Yes | Start of date range | +| end | str (YYYY-MM-DD) | Yes | End of date range | +| device_id | int | No | Filter to a specific device | + +**Returns:** +- `epochs` — list of `SettingsEpoch` objects + - `start_date`, `end_date`, `nights` — epoch span + - `settings` — dict of setting keys (mode, epr_level, epr_mode, pressure_min, pressure_max, pressure_fixed, ipap, epap, ps); absent keys are `null` + - `changed_keys` — which keys changed vs. previous epoch + - `device_id` + +--- + +### get_nightly_summary + +Per-night therapy summary for a date range. Paginated (~30 nights/call). + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| start | str (YYYY-MM-DD) | Yes | Start of date range | +| end | str (YYYY-MM-DD) | Yes | End of date range | +| device_id | int | No | Filter to a specific device | +| page | int | No | Page number (1-based, default 1) | +| page_size | int | No | Results per page (default 30, max 90) | +| compliance_threshold_hours | float | No | Compliance threshold in hours (default 4.0) | + +**Returns:** `NightlySummaryResponse` +- `nights` — list of `NightlyRow` with per-night metrics + - `date`, `usage_hours`, `session_count` + - `ahi`, `oai`, `cai`, `hi` (events/hr) — null if not computed + - `rera_index` (events/hr), `rdi` — null + `rera_index_reason: "analysis_not_run"` if analysis absent + - Pressure: `pressure_median_cmh2o`, `pressure_95th_cmh2o`, `epap_median_cmh2o` + - Leak: `leak_median_lpm`, `leak_95th_lpm` + - Resp: `rr_mean_bpm`, `tv_mean_ml`, `mv_mean_lpm` + - SpO₂: `spo2_mean_pct` +- `compliance` — present in range mode: `threshold_hours`, `days_compliant`, `days_total`, `compliance_pct` + +--- + +### get_events + +Respiratory events for a single session date with per-event context. + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| date | str (YYYY-MM-DD) | Yes | Session date | +| types | list[str] | No | Event type filter (e.g. `["CA", "OA"]`) | +| min_duration | float | No | Minimum event duration in seconds | +| include_context | bool | No | Attach per-event context block (default true) | + +**Returns:** `EventsResponse` +- `events` — list of `EventRow` + - `id`, `event_type`, `start_time_iso`, `duration_seconds` + - `spo2_drop_pct`, `peak_flow_limitation` + - `context` — `minutes_since_session_start` (pressure/leak/MV context in Phase 4) + +**Common event_type values:** `OA` (obstructive apnea), `CA` (central apnea), +`H` (hypopnea), `RERA`, `FL` (flow limitation), `VS` (vibratory snore). diff --git a/src/snore/mcp/errors.py b/src/snore/mcp/errors.py new file mode 100644 index 00000000..42124efa --- /dev/null +++ b/src/snore/mcp/errors.py @@ -0,0 +1,19 @@ +"""MCP-specific exception types for SNORE.""" + +from __future__ import annotations + + +class AnalysisNotRunError(Exception): + """Raised when a tool requires analysis results that have not been computed.""" + + +class CapabilityUnavailableError(Exception): + """Raised when the device/dataset does not provide a requested capability.""" + + +class ResponseSizeLimitError(Exception): + """Raised when the tool response would exceed the size limit.""" + + +class ValidationError(Exception): + """Raised when tool input validation fails.""" diff --git a/src/snore/mcp/profiles.py b/src/snore/mcp/profiles.py new file mode 100644 index 00000000..555e7dd2 --- /dev/null +++ b/src/snore/mcp/profiles.py @@ -0,0 +1,106 @@ +"""Clinical profile presets for SNORE MCP. + +Profiles shape the INSTRUCTIONS resource and suggested-priority hints only (G1). +No tool returns different *data* per profile — thresholds and severity ladders +live here in the instructions text, not in tool response logic. + +Profiles available: + neutral (default) — no clinical framing; reports all indices equally. + uars — de-emphasizes AHI; leads with flow morphology and RERA/RDI. + osa — AHI-forward; emphasises obstructive event burden and compliance. + csa — leads with MV, periodic breathing, and central event characterization. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +VALID_PROFILES = frozenset({"neutral", "uars", "osa", "csa"}) + + +@dataclass(frozen=True) +class ClinicalProfile: + name: str + display_name: str + priority_hint: str + clinical_context: str + + +_PROFILES: dict[str, ClinicalProfile] = { + "neutral": ClinicalProfile( + name="neutral", + display_name="Neutral", + priority_hint="Report all indices (AHI, RDI, flow-limitation, leak, pressure, MV) equally.", + clinical_context=( + "No clinical framing is active. Interpret indices in the context of the " + "dataset; do not apply population-level severity ladders without " + "user-supplied thresholds." + ), + ), + "uars": ClinicalProfile( + name="uars", + display_name="UARS (Upper Airway Resistance Syndrome)", + priority_hint=( + "De-emphasize AHI. Lead with flow morphology (flattening index, FL runs, " + "RERA count/RDI) and inspiratory effort markers. Treat RDI > threshold as " + "the primary burden index; treat AHI < 5 as consistent with UARS phenotype, " + "not as 'normal'. Pressure tuning goal: eliminate flow-limited breaths while " + "minimising leak." + ), + clinical_context=( + "UARS phenotype: RDI > 30, AHI < 5, inspiratory flow morphology is the " + "primary signal. Flattening index and FL-run-ending-in-recovery-breath " + "(RERA proxy) outrank AHI as tuning targets. " + "Bilevel therapy (VAuto/ASV) context: IPAP drives upper-airway dilation; " + "EPAP provides baseline support; PS = IPAP − EPAP. " + "Thresholds used in this dataset are user-configured — do not apply " + "generic AHI severity labels." + ), + ), + "osa": ClinicalProfile( + name="osa", + display_name="OSA (Obstructive Sleep Apnea)", + priority_hint=( + "AHI-forward. Report OAI, CAI, HI, AHI as the primary burden. " + "Compliance (≥4 h/night) is a key secondary metric. " + "Pressure titration goal: suppress obstructive events and reduce AHI." + ), + clinical_context=( + "OSA therapy context: primary goal is AHI suppression via adequate " + "pressure. Compliance tracking matters for insurance and efficacy. " + "Do not infer severity from AHI alone — report all components " + "(OAI, CAI, HI) and let the user interpret." + ), + ), + "csa": ClinicalProfile( + name="csa", + display_name="CSA / Periodic Breathing", + priority_hint=( + "Lead with MV, periodic-breathing percentage, and central event burden " + "(CAI). Report MV rolling variance and respiratory rate stability as " + "primary signals. Suppress back-up rate discussion unless the device " + "reports it." + ), + clinical_context=( + "CSA / complex sleep apnea context: central events and periodic breathing " + "dominate. MV stability and respiratory rate regularity are the primary " + "tuning signals. Flow morphology is secondary. " + "Do not conflate CAI with OAI — report them separately." + ), + ), +} + + +def get_profile(name: str) -> ClinicalProfile: + """Return the named profile or raise ValueError for unknown names.""" + if name not in _PROFILES: + raise ValueError( + f"Unknown clinical profile {name!r}. " + f"Valid profiles: {sorted(VALID_PROFILES)}" + ) + return _PROFILES[name] + + +def list_profiles() -> list[ClinicalProfile]: + """Return all profiles in a stable order.""" + return [_PROFILES[k] for k in ("neutral", "uars", "osa", "csa")] diff --git a/src/snore/mcp/schemas.py b/src/snore/mcp/schemas.py new file mode 100644 index 00000000..094d2a23 --- /dev/null +++ b/src/snore/mcp/schemas.py @@ -0,0 +1,222 @@ +"""Pydantic response schemas for SNORE MCP tools. + +All date/time fields use ISO 8601 strings with explicit UTC offset. +All measurement fields carry their unit as a sibling ``_unit`` field or are +documented in the tool docstring. Absent data is ``null`` with a companion +``_reason`` field (e.g. ``rera_index: null, rera_index_reason: "analysis_not_run"``). +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class DeviceCapabilities(BaseModel): + """Capabilities declared by the device/dataset for a queried range (G2).""" + + model_config = ConfigDict(populate_by_name=True) + + manufacturer: str + model: str + serial_number: str + has_flow_waveform: bool + has_pressure_waveform: bool + has_leak_waveform: bool + has_spo2: bool + has_events: bool + has_analysis: bool + notes: list[str] = [] + + +class DeviceInfo(BaseModel): + """Summary of a single device.""" + + model_config = ConfigDict(populate_by_name=True) + + id: int + manufacturer: str + model: str + serial_number: str + first_session_date: date | None = None + last_session_date: date | None = None + session_count: int = 0 + therapy_modes: list[str] = [] + + +class DataOverviewResponse(BaseModel): + """Response from get_data_overview.""" + + model_config = ConfigDict(populate_by_name=True) + + devices: list[DeviceInfo] + date_range_start: date | None = None + date_range_end: date | None = None + total_sessions: int = 0 + available_waveform_channels: list[str] = [] + available_event_types: list[str] = [] + analysis_run: bool = False + analysis_session_count: int = 0 + + +class SettingsEpoch(BaseModel): + """A contiguous period of stable therapy settings.""" + + model_config = ConfigDict(populate_by_name=True) + + start_date: date + end_date: date + nights: int + settings: dict[str, str | None] + changed_keys: list[str] = [] + device_id: int + device_capabilities: DeviceCapabilities | None = None + + +class SettingsTimelineResponse(BaseModel): + """Response from get_settings_timeline.""" + + model_config = ConfigDict(populate_by_name=True) + + epochs: list[SettingsEpoch] + total_epochs: int + + +class NightlyRow(BaseModel): + """Per-night summary row returned by get_nightly_summary.""" + + model_config = ConfigDict(populate_by_name=True) + + date: date + usage_hours: float | None = None + session_count: int = 0 + + # AHI components (events/hr) — null + reason when absent + ahi: float | None = None + oai: float | None = None + cai: float | None = None + hi: float | None = None + + # Analysis-derived indices — null when analysis has not been run + rera_index: float | None = None + rera_index_reason: str | None = None + rdi: float | None = None + rdi_reason: str | None = None + + # Pressure percentiles (cmH₂O) + pressure_median_cmh2o: float | None = None + pressure_95th_cmh2o: float | None = None + epap_median_cmh2o: float | None = None + + # Leak (L/min) + leak_median_lpm: float | None = None + leak_95th_lpm: float | None = None + leak_above_24_pct: float | None = None + + # Resp physiology + rr_mean_bpm: float | None = None + tv_mean_ml: float | None = None + mv_mean_lpm: float | None = None + + # SpO₂ (%) + spo2_mean_pct: float | None = None + + device_id: int | None = None + + +class ComplianceFields(BaseModel): + """Compliance summary appended to range-mode nightly summary.""" + + model_config = ConfigDict(populate_by_name=True) + + threshold_hours: float + days_compliant: int + days_total: int + compliance_pct: float + + +class NightlySummaryResponse(BaseModel): + """Response from get_nightly_summary.""" + + model_config = ConfigDict(populate_by_name=True) + + nights: list[NightlyRow] + total_nights: int + page: int + page_size: int + # Compliance block only present in range mode + compliance: ComplianceFields | None = None + device_capabilities: DeviceCapabilities | None = None + + +class EventContext(BaseModel): + """Per-event contextual snapshot.""" + + model_config = ConfigDict(populate_by_name=True) + + pressure_at_event_cmh2o: float | None = None + leak_at_event_lpm: float | None = None + mv_prior_120s_lpm: float | None = None + minutes_since_session_start: float | None = None + + +class EventRow(BaseModel): + """A single respiratory event with inline context.""" + + model_config = ConfigDict(populate_by_name=True) + + id: int + event_type: str + start_time_iso: str + duration_seconds: float | None = None + spo2_drop_pct: float | None = None + peak_flow_limitation: float | None = None + context: EventContext | None = None + + +class EventsResponse(BaseModel): + """Response from get_events.""" + + model_config = ConfigDict(populate_by_name=True) + + date: str + session_id: int + events: list[EventRow] + total_events: int + device_capabilities: DeviceCapabilities | None = None + + +class CapabilityEntry(BaseModel): + """One entry in the capabilities resource.""" + + model_config = ConfigDict(populate_by_name=True) + + channel: str + description: str + unit: str | None = None + present_in_dataset: bool + sample_rate_hz: float | None = None + + +# Mapping used for docs://schemas/{type} — maps schema name to model class +SCHEMA_MODEL_MAP: dict[str, type[BaseModel]] = { + "device_capabilities": DeviceCapabilities, + "device_info": DeviceInfo, + "data_overview": DataOverviewResponse, + "settings_epoch": SettingsEpoch, + "settings_timeline": SettingsTimelineResponse, + "nightly_row": NightlyRow, + "compliance_fields": ComplianceFields, + "nightly_summary": NightlySummaryResponse, + "event_context": EventContext, + "event_row": EventRow, + "events_response": EventsResponse, + "capability_entry": CapabilityEntry, +} + + +def model_to_schema(model: type[BaseModel]) -> dict[str, Any]: + """Return the JSON schema for a Pydantic model.""" + return model.model_json_schema() diff --git a/src/snore/mcp/server.py b/src/snore/mcp/server.py new file mode 100644 index 00000000..040fb4be --- /dev/null +++ b/src/snore/mcp/server.py @@ -0,0 +1,412 @@ +"""SNORE MCP Server. + +Third presentation layer over the async service layer — a peer of the CLI and +FastAPI, never a place where analysis logic lives. + +Design doctrine: + - Tiered data access: overview → summary → events → breath table → raw waveform + - Compute server-side, return compact JSON; units on every field + - Data-quality flags / null + reason everywhere (G2) + - Stateless: no module-global state; DB via lifespan-provided session factory (G3) + - Profile-parameterized: profiles shape instructions only, not data (G1) + - Vendor dispatch stays in the parser/service layer (G4) +""" + +from __future__ import annotations + +import json +import logging + +from collections.abc import AsyncGenerator, Awaitable, Callable +from contextlib import asynccontextmanager +from functools import wraps +from importlib.metadata import version +from pathlib import Path +from typing import Any + +from fastmcp import FastMCP +from fastmcp.exceptions import ToolError + +from snore.database.session import ( + cleanup_database, + init_database_from_url, + session_scope, +) +from snore.database.target import DatabaseTarget +from snore.mcp.errors import ValidationError +from snore.mcp.profiles import ClinicalProfile, get_profile +from snore.mcp.schemas import SCHEMA_MODEL_MAP, model_to_schema +from snore.mcp.validation import parse_date, parse_date_range + +logger = logging.getLogger(__name__) + +# Module-level profile holder — set during lifespan, read-only from tools. +# This is the ONLY module-level state permitted in this package (G3). +_active_profile: ClinicalProfile | None = None + +RESPONSE_SIZE_LIMIT = 500_000 # bytes; tools return narrow-your-query guidance + + +def _build_instructions(profile: ClinicalProfile) -> str: + return f"""\ +SNORE MCP Server v{version("snore")} +Sleep eNvironment Observation & Respiratory Evaluation + +REQUIRED READING: Read `docs://tools` before using any tool. Failure to read +the tool documentation may result in incorrect or incomplete results. + +Active clinical profile: {profile.display_name} +{profile.priority_hint} + +Clinical context: +{profile.clinical_context} + +WORKFLOW: +1. get_data_overview → discover devices, date ranges, channels +2. get_nightly_summary → identify nights of interest (30 nights/page) +3. get_settings_timeline → understand settings epochs +4. get_events (date) → event-level detail for a night +5. (Phase 2) get_breath_table, find_windows, compare_epochs for morphology tuning +6. (Phase 3) render_window, get_waveform for visual inspection + +DATA TIERS (progressive disclosure): + Tier 1 (primary): computed metrics — indices, percentiles, aggregates + Tier 2 (secondary): PNG charts — render_window (Phase 3) + Tier 3 (escape hatch): raw arrays — get_waveform ≤2 min / ≤1000 pts (Phase 3) + +NULL FIELDS: When data is absent, fields are null + a companion *_reason field +explains why (e.g. rera_index_reason: "analysis_not_run"). Never infer from null. + +See docs://capabilities for dataset-specific channel availability. +""" + + +# --------------------------------------------------------------------------- +# Lifespan +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def _lifespan( + app: Any, db_flag: str | None = None, profile_name: str = "neutral" +) -> AsyncGenerator[None]: + """FastMCP lifespan: initialize DB and set active profile.""" + global _active_profile + + target = DatabaseTarget.from_env_and_flags(db_flag=db_flag, warn_ignored=True) + async_url = target.resolve_async_url() + + await init_database_from_url(async_url) + _active_profile = get_profile(profile_name) + + logger.info( + "SNORE MCP server started — db=%r profile=%s", target.location, profile_name + ) + + try: + yield + finally: + await cleanup_database() + _active_profile = None + logger.info("SNORE MCP server stopped") + + +def make_server(db_flag: str | None = None, profile_name: str = "neutral") -> FastMCP: + """Construct and return a configured FastMCP instance.""" + profile = get_profile(profile_name) + + @asynccontextmanager + async def _bound_lifespan(app: Any) -> AsyncGenerator[None]: + async with _lifespan(app, db_flag=db_flag, profile_name=profile_name): + yield + + mcp = FastMCP( + name="snore", + instructions=_build_instructions(profile), + lifespan=_bound_lifespan, + ) + + _register_resources(mcp) + _register_tools(mcp) + + return mcp + + +# --------------------------------------------------------------------------- +# Error boundary +# --------------------------------------------------------------------------- + + +def tool_error_boundary( + func: Callable[..., Awaitable[Any]], +) -> Callable[..., Awaitable[Any]]: + """Convert common tool failures into ToolError so FastMCP sets isError=true.""" + + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except ToolError: + raise + except (ValidationError, ValueError) as exc: + raise ToolError(str(exc)) from exc + except Exception as exc: + response = getattr(exc, "response", None) + message = response.text if response is not None else str(exc) + raise ToolError(message) from exc + + return wrapper + + +def _check_response_size(result: Any, tool_name: str) -> None: + """Raise ToolError if the serialized result exceeds RESPONSE_SIZE_LIMIT.""" + try: + import sys + + size = sys.getsizeof(json.dumps(result, default=str)) + except Exception: + return + if size > RESPONSE_SIZE_LIMIT: + raise ToolError( + f"Response from {tool_name} exceeds the {RESPONSE_SIZE_LIMIT:,}-byte limit. " + "Narrow your query: use a shorter date range, smaller page_size, or add " + "device/type filters." + ) + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + + +def _register_resources(mcp: FastMCP) -> None: + + @mcp.resource("docs://tools") + def get_tool_documentation() -> str: + """Complete tool reference documentation.""" + docs_path = Path(__file__).resolve().parent / "docs" / "tools.md" + return docs_path.read_text() + + @mcp.resource("docs://schemas/{schema_type}") + def get_schema(schema_type: str) -> str: + """JSON schema for a named response type. + + Available schema_types: device_capabilities, device_info, data_overview, + settings_epoch, settings_timeline, nightly_row, compliance_fields, + nightly_summary, event_context, event_row, events_response, capability_entry. + """ + model = SCHEMA_MODEL_MAP.get(schema_type) + if model is None: + available = sorted(SCHEMA_MODEL_MAP.keys()) + raise ToolError( + f"Unknown schema type {schema_type!r}. Available: {available}" + ) + return json.dumps(model_to_schema(model), indent=2) + + @mcp.resource("docs://capabilities") + async def get_capabilities() -> str: + """Dataset capabilities — dynamically generated from imported data. + + Lists which waveform channels, event types, and analysis features are + present in the imported dataset. Use this to understand what is and is + not available before calling tools. + """ + from snore.mcp.tools.overview import get_data_overview + + async with session_scope() as db: + overview = await get_data_overview(db) + + caps = { + "description": ( + "Available data channels and features in the imported SNORE dataset. " + "Channels listed as present=false are not available — tool fields " + "for absent channels return null with a reason." + ), + "devices": [ + { + "id": d.id, + "manufacturer": d.manufacturer, + "model": d.model, + "date_range": { + "start": d.first_session_date.isoformat() + if d.first_session_date + else None, + "end": d.last_session_date.isoformat() + if d.last_session_date + else None, + }, + "session_count": d.session_count, + } + for d in overview.devices + ], + "waveform_channels": [ + {"channel": ch, "present": True} + for ch in overview.available_waveform_channels + ], + "event_types": overview.available_event_types, + "analysis": { + "run": overview.analysis_run, + "session_count": overview.analysis_session_count, + "note": ( + "Run 'snore analysis run' or re-import with analysis enabled " + "to populate analysis-derived fields (RERA index, RDI, breath table)." + if not overview.analysis_run + else "Analysis results are available. RERA index and RDI fields are populated." + ), + }, + } + + return json.dumps(caps, indent=2, default=str) + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + + +def _register_tools(mcp: FastMCP) -> None: + + @mcp.tool() + @tool_error_boundary + async def get_data_overview() -> dict[str, Any]: + """Orient to the imported dataset: devices, date ranges, channels, analysis status. + + Call this first before any other tool. Returns everything needed to understand + what data is available and which tools are applicable. + + Returns: + DataOverviewResponse with devices, date ranges, waveform channels, + event types, and analysis status. + """ + from snore.mcp.tools.overview import get_data_overview as _impl + + async with session_scope() as db: + result = await _impl(db) + + payload = result.model_dump(mode="json") + _check_response_size(payload, "get_data_overview") + return payload + + @mcp.tool() + @tool_error_boundary + async def get_settings_timeline( + start: str, + end: str, + device_id: int | None = None, + ) -> dict[str, Any]: + """Return therapy settings epochs for a date range. + + Each epoch represents a contiguous period of identical settings. + Changed keys are flagged on the epoch where the change first appears. + Uses generic RX_KEYS only (mode, epr_level, epr_mode, pressure_min, + pressure_max, pressure_fixed, ipap, epap, ps). + + Args: + start: Start date in YYYY-MM-DD format. + end: End date in YYYY-MM-DD format. + device_id: Optional device ID filter. Use get_data_overview to list devices. + + Returns: + SettingsTimelineResponse with epochs list and total_epochs count. + """ + from snore.mcp.tools.settings import get_settings_timeline as _impl + + start_d, end_d = parse_date_range(start, end) + + async with session_scope() as db: + result = await _impl(db, start_d, end_d, device_id=device_id) + + payload = result.model_dump(mode="json") + _check_response_size(payload, "get_settings_timeline") + return payload + + @mcp.tool() + @tool_error_boundary + async def get_nightly_summary( + start: str, + end: str, + device_id: int | None = None, + page: int = 1, + page_size: int = 30, + compliance_threshold_hours: float = 4.0, + ) -> dict[str, Any]: + """Return per-night therapy summary for a date range. + + Paginated at 30 nights/call (adjustable). Analysis-derived fields (RERA + index, RDI) are null + reason "analysis_not_run" when analysis has not + been run. Compliance fields are included in the response. + + Args: + start: Start date in YYYY-MM-DD format. + end: End date in YYYY-MM-DD format. + device_id: Optional device ID filter. + page: Page number (1-based). Default 1. + page_size: Nights per page (max 90). Default 30. + compliance_threshold_hours: Hours to count as compliant (default 4.0). + + Returns: + NightlySummaryResponse with nights list, pagination, and compliance block. + """ + from snore.mcp.tools.summary import get_nightly_summary as _impl + + start_d, end_d = parse_date_range(start, end) + + capped_page_size = min(page_size, 90) + if page < 1: + raise ValidationError("page must be >= 1") + + async with session_scope() as db: + result = await _impl( + db, + start_d, + end_d, + device_id=device_id, + page=page, + page_size=capped_page_size, + compliance_threshold_hours=compliance_threshold_hours, + ) + + payload = result.model_dump(mode="json") + _check_response_size(payload, "get_nightly_summary") + return payload + + @mcp.tool() + @tool_error_boundary + async def get_events( + date: str, + types: list[str] | None = None, + min_duration: float | None = None, + include_context: bool = True, + ) -> dict[str, Any]: + """Return respiratory events for a single session date. + + Includes per-event context: minutes since session start. + Pressure/leak at event and MV-prior-120s context require waveform + lookups and will be added in Phase 3 (render_window). + + Args: + date: Session date in YYYY-MM-DD format. + types: Optional event type filter, e.g. ["CA", "OA", "H", "RERA"]. + See docs://tools for common event_type values. + min_duration: Minimum event duration in seconds (optional). + include_context: Attach per-event context block (default true). + + Returns: + EventsResponse with events list and total_events count. + """ + from snore.mcp.tools.events import get_events as _impl + + event_date = parse_date(date, "date") + + async with session_scope() as db: + result = await _impl( + db, + event_date, + types=types, + min_duration=min_duration, + include_context=include_context, + ) + + payload = result.model_dump(mode="json") + _check_response_size(payload, "get_events") + return payload diff --git a/src/snore/mcp/tools/__init__.py b/src/snore/mcp/tools/__init__.py new file mode 100644 index 00000000..6861adf0 --- /dev/null +++ b/src/snore/mcp/tools/__init__.py @@ -0,0 +1 @@ +"""MCP tool implementations for SNORE.""" diff --git a/src/snore/mcp/tools/events.py b/src/snore/mcp/tools/events.py new file mode 100644 index 00000000..63b91b54 --- /dev/null +++ b/src/snore/mcp/tools/events.py @@ -0,0 +1,119 @@ +"""get_events tool — EventService adapter with inline context.""" + +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from snore.database import models +from snore.mcp.errors import ValidationError +from snore.mcp.schemas import EventContext, EventRow, EventsResponse + + +async def get_events( + db_session: AsyncSession, + event_date: date, + types: list[str] | None = None, + min_duration: float | None = None, + include_context: bool = True, +) -> EventsResponse: + """Return respiratory events for a session date with inline context. + + For each event, inline context includes pressure/leak at the event + and MV in the prior 120 s (when waveform data is present) and + minutes since session start. + + Args: + db_session: Async database session. + event_date: The date to query (YYYY-MM-DD). + types: Optional list of event types to filter (e.g. ["OA", "CA", "H"]). + min_duration: Minimum event duration in seconds (optional filter). + include_context: Whether to attach per-event context block. + """ + # Find the enabled session for this date + day_row = ( + ( + await db_session.execute( + select(models.Day).where(models.Day.date == event_date) + ) + ) + .scalars() + .first() + ) + + if day_row is None: + raise ValidationError( + f"No therapy data found for date {event_date}. " + "Use get_data_overview to check which dates have imported data." + ) + + session_row = ( + ( + await db_session.execute( + select(models.Session) + .where( + models.Session.day_id == day_row.id, + models.Session.enabled.is_(True), + ) + .order_by(models.Session.start_time) + .limit(1) + ) + ) + .scalars() + .first() + ) + + if session_row is None: + raise ValidationError(f"No enabled session found for date {event_date}.") + + session_id = int(session_row.id) + session_start: datetime = session_row.start_time + + # Fetch events with optional filters + event_q = ( + select(models.Event) + .where(models.Event.session_id == session_id) + .order_by(models.Event.start_time) + ) + if types: + event_q = event_q.where(models.Event.event_type.in_(types)) + if min_duration is not None: + event_q = event_q.where(models.Event.duration_seconds >= min_duration) + + event_rows = (await db_session.execute(event_q)).scalars().all() + + rows: list[EventRow] = [] + for ev in event_rows: + context: EventContext | None = None + if include_context: + minutes_since_start = (ev.start_time - session_start).total_seconds() / 60.0 + context = EventContext( + # Pressure/leak at event and MV-prior-120s require waveform + # sample-at-timestamp lookups — deferred to Phase 4 (render_window). + # Mark as None with no reason field; capability-honest per G2. + pressure_at_event_cmh2o=None, + leak_at_event_lpm=None, + mv_prior_120s_lpm=None, + minutes_since_session_start=round(minutes_since_start, 2), + ) + + rows.append( + EventRow( + id=int(ev.id), + event_type=ev.event_type, + start_time_iso=ev.start_time.isoformat(), + duration_seconds=ev.duration_seconds, + spo2_drop_pct=ev.spo2_drop, + peak_flow_limitation=ev.peak_flow_limitation, + context=context, + ) + ) + + return EventsResponse( + date=event_date.isoformat(), + session_id=session_id, + events=rows, + total_events=len(rows), + ) diff --git a/src/snore/mcp/tools/overview.py b/src/snore/mcp/tools/overview.py new file mode 100644 index 00000000..c8093a4a --- /dev/null +++ b/src/snore/mcp/tools/overview.py @@ -0,0 +1,136 @@ +"""get_data_overview tool — cold-start orientation.""" + +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from snore.database import models +from snore.mcp.schemas import DataOverviewResponse, DeviceInfo +from snore.services.device_service import DeviceService + + +async def get_data_overview(db_session: AsyncSession) -> DataOverviewResponse: + """Return a comprehensive overview of all imported data. + + Called by the get_data_overview MCP tool. Provides device inventory, + date ranges, available waveform channels, event types, and analysis status + — everything an LLM needs to orient itself to a cold database. + """ + device_svc = DeviceService(db_session) + raw_devices = await device_svc.list_devices() + + if not raw_devices: + return DataOverviewResponse(devices=[]) + + device_infos: list[DeviceInfo] = [] + total_sessions = 0 + global_min_date = None + global_max_date = None + + for d in raw_devices: + # Per-device session stats + result = await db_session.execute( + select( + func.count(models.Session.id), + func.min(models.Session.start_time), + func.max(models.Session.start_time), + ).where( + models.Session.device_id == d.id, + models.Session.enabled.is_(True), + ) + ) + row = result.one() + count, min_dt, max_dt = row + + first_date = min_dt.date() if min_dt else None + last_date = max_dt.date() if max_dt else None + + if first_date and (global_min_date is None or first_date < global_min_date): + global_min_date = first_date + if last_date and (global_max_date is None or last_date > global_max_date): + global_max_date = last_date + + total_sessions += count or 0 + + # Therapy modes for this device + mode_rows = ( + ( + await db_session.execute( + select(models.Session.therapy_mode) + .where( + models.Session.device_id == d.id, + models.Session.enabled.is_(True), + models.Session.therapy_mode.is_not(None), + ) + .distinct() + .order_by(models.Session.therapy_mode) + ) + ) + .scalars() + .all() + ) + + device_infos.append( + DeviceInfo( + id=d.id, + manufacturer=d.manufacturer, + model=d.model, + serial_number=d.serial_number, + first_session_date=first_date, + last_session_date=last_date, + session_count=count or 0, + therapy_modes=[m for m in mode_rows if m], + ) + ) + + # Available waveform channel types across all sessions + waveform_types = ( + ( + await db_session.execute( + select(models.Waveform.waveform_type) + .distinct() + .order_by(models.Waveform.waveform_type) + ) + ) + .scalars() + .all() + ) + + # Available event types + event_types = ( + ( + await db_session.execute( + select(models.Event.event_type) + .distinct() + .order_by(models.Event.event_type) + ) + ) + .scalars() + .all() + ) + + # Analysis status + analysis_count_result = await db_session.execute( + select(func.count(models.AnalysisResult.id.distinct())) + .join(models.Session, models.AnalysisResult.session_id == models.Session.id) + .where(models.Session.enabled.is_(True)) + ) + analysis_session_count = ( + await db_session.execute( + select(func.count(models.AnalysisResult.session_id.distinct())) + ) + ).scalar_one() + analysis_run = analysis_session_count > 0 + _ = analysis_count_result # suppress unused var + + return DataOverviewResponse( + devices=device_infos, + date_range_start=global_min_date, + date_range_end=global_max_date, + total_sessions=total_sessions, + available_waveform_channels=list(waveform_types), + available_event_types=list(event_types), + analysis_run=analysis_run, + analysis_session_count=analysis_session_count, + ) diff --git a/src/snore/mcp/tools/settings.py b/src/snore/mcp/tools/settings.py new file mode 100644 index 00000000..c02575c9 --- /dev/null +++ b/src/snore/mcp/tools/settings.py @@ -0,0 +1,69 @@ +"""get_settings_timeline tool — RxTracker adapter.""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy.ext.asyncio import AsyncSession + +from snore.analysis.rx_tracker import RX_KEYS, RxTracker +from snore.mcp.schemas import SettingsEpoch, SettingsTimelineResponse + + +async def get_settings_timeline( + db_session: AsyncSession, + start: date, + end: date, + device_id: int | None = None, +) -> SettingsTimelineResponse: + """Return therapy settings epochs in [start, end]. + + Each epoch covers a contiguous period of identical settings. Changed keys + are flagged on the epoch where the change first appears. Uses only the + generic RX_KEYS; no vendor-specific branching (G4). + """ + tracker = RxTracker() + all_periods = await tracker.get_history(db_session) + + # Filter to requested date range and optional device + filtered = [ + p + for p in all_periods + if p.end_date >= start + and p.start_date <= end + and (device_id is None or p.device_id == device_id) + ] + filtered.sort(key=lambda p: (p.device_id or 0, p.start_date)) + + epochs: list[SettingsEpoch] = [] + prev_settings: dict[int, dict[str, str | None]] = {} + + for period in filtered: + dev_id = period.device_id or 0 + raw = period.settings + + # Restrict to generic RX_KEYS; absent keys become None + settings: dict[str, str | None] = {k: raw.get(k) for k in RX_KEYS} + + # Determine which keys changed vs. previous epoch for this device + prev = prev_settings.get(dev_id, {}) + changed_keys = [k for k in settings if settings[k] != prev.get(k)] + prev_settings[dev_id] = settings + + # Clip epoch boundaries to requested range + epoch_start = max(period.start_date, start) + epoch_end = min(period.end_date, end) + nights = (epoch_end - epoch_start).days + 1 + + epochs.append( + SettingsEpoch( + start_date=epoch_start, + end_date=epoch_end, + nights=nights, + settings=settings, + changed_keys=changed_keys if prev else [], + device_id=dev_id, + ) + ) + + return SettingsTimelineResponse(epochs=epochs, total_epochs=len(epochs)) diff --git a/src/snore/mcp/tools/summary.py b/src/snore/mcp/tools/summary.py new file mode 100644 index 00000000..deaf8f11 --- /dev/null +++ b/src/snore/mcp/tools/summary.py @@ -0,0 +1,220 @@ +"""get_nightly_summary tool — StatsService / DayService adapter.""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from snore.database import models +from snore.mcp.schemas import ( + ComplianceFields, + NightlyRow, + NightlySummaryResponse, +) + +_DEFAULT_PAGE_SIZE = 30 +_DEFAULT_COMPLIANCE_THRESHOLD_HOURS = 4.0 + + +async def get_nightly_summary( + db_session: AsyncSession, + start: date, + end: date, + device_id: int | None = None, + page: int = 1, + page_size: int = _DEFAULT_PAGE_SIZE, + compliance_threshold_hours: float = _DEFAULT_COMPLIANCE_THRESHOLD_HOURS, +) -> NightlySummaryResponse: + """Return per-night therapy summary for a date range. + + Analysis-derived fields (RERA index, RDI) are read from the latest + AnalysisResult for each session; when absent they are null with reason + "analysis_not_run" (A2). + + Compliance fields (compliance_pct, days_compliant, days_total) are + included in range mode using the supplied threshold (default 4 h). + """ + # Count total matching days for pagination + count_q = select(func.count(models.Day.id)).where( + models.Day.date >= start, + models.Day.date <= end, + ) + if device_id is not None: + count_q = count_q.where(models.Day.device_id == device_id) + total = (await db_session.execute(count_q)).scalar_one() + + # Fetch the page of Day rows directly for full field access + day_q = ( + select(models.Day) + .where(models.Day.date >= start, models.Day.date <= end) + .order_by(models.Day.date.desc()) + .limit(page_size) + .offset((page - 1) * page_size) + ) + if device_id is not None: + day_q = day_q.where(models.Day.device_id == device_id) + day_rows = (await db_session.execute(day_q)).scalars().all() + + if not day_rows: + return NightlySummaryResponse( + nights=[], + total_nights=total, + page=page, + page_size=page_size, + ) + + day_ids = [int(d.id) for d in day_rows] + + # For each day get the earliest enabled session (representative for analysis/stats) + session_rows = ( + await db_session.execute( + select(models.Session.id, models.Session.day_id) + .where( + models.Session.day_id.in_(day_ids), + models.Session.enabled.is_(True), + ) + .order_by(models.Session.day_id, models.Session.start_time) + ) + ).all() + + day_to_session: dict[int, int] = {} + for session_id, s_day_id in session_rows: + s_day_id_int = int(s_day_id) + if s_day_id_int not in day_to_session: + day_to_session[s_day_id_int] = int(session_id) + + session_ids = list(day_to_session.values()) + + # Latest AnalysisResult per session via row_number window + analysis_by_session: dict[int, Any] = {} + if session_ids: + subq = ( + select( + models.AnalysisResult.session_id, + models.AnalysisResult.programmatic_result_json, + func.row_number() + .over( + partition_by=models.AnalysisResult.session_id, + order_by=models.AnalysisResult.created_at.desc(), + ) + .label("rn"), + ) + .where(models.AnalysisResult.session_id.in_(session_ids)) + .subquery() + ) + analysis_rows = ( + await db_session.execute( + select(subq.c.session_id, subq.c.programmatic_result_json).where( + subq.c.rn == 1 + ) + ) + ).all() + for s_id, payload in analysis_rows: + analysis_by_session[int(s_id)] = payload if payload else {} + + # Statistics rows for MV/RR/TV + stats_by_session: dict[int, models.Statistics] = {} + if session_ids: + stat_rows = ( + ( + await db_session.execute( + select(models.Statistics).where( + models.Statistics.session_id.in_(session_ids) + ) + ) + ) + .scalars() + .all() + ) + for stat in stat_rows: + stats_by_session[int(stat.session_id)] = stat + + nights: list[NightlyRow] = [] + days_compliant = 0 + + for day in day_rows: + day_id = int(day.id) + session_id = day_to_session.get(day_id) + analysis = analysis_by_session.get(session_id, {}) if session_id else {} + stats = stats_by_session.get(session_id) if session_id else None + + # Analysis-derived fields (A2) + rera_index: float | None = None + rera_index_reason: str | None = None + rdi: float | None = None + rdi_reason: str | None = None + + if analysis: + mode_results = analysis.get("mode_results", {}) + duration_h = analysis.get("session_duration_hours", 0.0) or 0.0 + for _mode, mode_data in mode_results.items(): + if isinstance(mode_data, dict) and mode_data.get( + "rera_detection_enabled" + ): + reras_list = mode_data.get("reras", []) + rdi_val = mode_data.get("rdi") + if duration_h > 0 and reras_list is not None: + rera_index = round(len(reras_list) / duration_h, 2) + if rdi_val is not None: + rdi = round(float(rdi_val), 2) + break + else: + rera_index_reason = "analysis_not_run" + rdi_reason = "analysis_not_run" + + usage_h = day.total_therapy_hours + if usage_h and usage_h >= compliance_threshold_hours: + days_compliant += 1 + + nights.append( + NightlyRow( + date=day.date, + usage_hours=round(usage_h, 2) if usage_h is not None else None, + session_count=day.session_count or 0, + ahi=round(day.ahi, 2) if day.ahi is not None else None, + oai=round(day.oai, 2) if day.oai is not None else None, + cai=round(day.cai, 2) if day.cai is not None else None, + hi=round(day.hi, 2) if day.hi is not None else None, + rera_index=rera_index, + rera_index_reason=rera_index_reason, + rdi=rdi, + rdi_reason=rdi_reason, + pressure_median_cmh2o=day.pressure_median, + pressure_95th_cmh2o=day.pressure_95th, + epap_median_cmh2o=day.epap_median, + leak_median_lpm=day.leak_median, + leak_95th_lpm=day.leak_95th, + leak_above_24_pct=None, # requires waveform time-above; Phase 4 + rr_mean_bpm=stats.respiratory_rate_mean if stats else None, + tv_mean_ml=( + round(stats.tidal_volume_mean * 1000, 1) + if stats and stats.tidal_volume_mean is not None + else None + ), + mv_mean_lpm=stats.minute_ventilation_mean if stats else None, + spo2_mean_pct=day.spo2_mean, + device_id=day.device_id, + ) + ) + + compliance: ComplianceFields | None = None + if len(day_rows) > 1 or (start != end): + compliance = ComplianceFields( + threshold_hours=compliance_threshold_hours, + days_compliant=days_compliant, + days_total=len(day_rows), + compliance_pct=( + round(days_compliant / len(day_rows) * 100, 1) if day_rows else 0.0 + ), + ) + + return NightlySummaryResponse( + nights=nights, + total_nights=total, + page=page, + page_size=page_size, + compliance=compliance, + ) diff --git a/src/snore/mcp/validation.py b/src/snore/mcp/validation.py new file mode 100644 index 00000000..fd05ea47 --- /dev/null +++ b/src/snore/mcp/validation.py @@ -0,0 +1,67 @@ +"""Input validation helpers for MCP tools.""" + +from __future__ import annotations + +import re + +from datetime import date + +from snore.mcp.errors import ValidationError + +_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def parse_date(value: str, param_name: str) -> date: + """Parse an ISO 8601 date string (YYYY-MM-DD). + + Args: + value: The date string to parse. + param_name: Parameter name used in error messages. + + Returns: + Parsed date object. + + Raises: + ValidationError: If the string is not a valid YYYY-MM-DD date. + """ + if not _ISO_DATE_RE.match(value): + raise ValidationError( + f"Invalid date value {value!r} for parameter '{param_name}'. " + "Expected YYYY-MM-DD format, e.g. '2025-08-01'." + ) + try: + return date.fromisoformat(value) + except ValueError as exc: + raise ValidationError( + f"Invalid date value {value!r} for parameter '{param_name}': {exc}" + ) from exc + + +def parse_date_range( + start: str, + end: str, + *, + start_param: str = "start", + end_param: str = "end", +) -> tuple[date, date]: + """Parse and validate a start/end date range. + + Args: + start: Start date string (YYYY-MM-DD). + end: End date string (YYYY-MM-DD). + start_param: Name of the start parameter for error messages. + end_param: Name of the end parameter for error messages. + + Returns: + Tuple of (start_date, end_date). + + Raises: + ValidationError: If either date is invalid or start > end. + """ + start_date = parse_date(start, start_param) + end_date = parse_date(end, end_param) + if start_date > end_date: + raise ValidationError( + f"'{start_param}' ({start}) must not be after '{end_param}' ({end})." + ) + return start_date, end_date diff --git a/tests/integration/test_mcp_tools.py b/tests/integration/test_mcp_tools.py new file mode 100644 index 00000000..d20d303a --- /dev/null +++ b/tests/integration/test_mcp_tools.py @@ -0,0 +1,466 @@ +"""Integration tests for SNORE MCP tools. + +Tests exercise each tool implementation directly against an in-memory async DB, +using the same fixture helpers as the rest of the integration suite. These tests +verify behavior: correct data returned, null + reason pattern, pagination, +and graceful degradation when analysis results are absent. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any + +import pytest + +from sqlalchemy.ext.asyncio import AsyncSession + +from snore.database.models import Day, Device, Event, Session + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _make_device(db: AsyncSession, manufacturer: str = "TestMfr") -> Device: + import uuid + + device = Device( + manufacturer=manufacturer, + model="TestModel", + serial_number=f"SN_{uuid.uuid4().hex[:8]}", + ) + db.add(device) + await db.flush() + return device + + +async def _make_day_session( + db: AsyncSession, + device: Device, + day_date: date, + duration_hours: float = 8.0, + **day_kwargs: Any, +) -> tuple[Day, Session]: + """Create a Day + enabled Session pair.""" + day = Day( + device_id=device.id, + date=day_date, + total_therapy_hours=duration_hours, + **day_kwargs, + ) + db.add(day) + await db.flush() + + sess = Session( + device_id=device.id, + day_id=day.id, + device_session_id=f"test_{day_date.isoformat()}", + start_time=datetime.combine(day_date, datetime.min.time()).replace(hour=22), + end_time=datetime.combine(day_date, datetime.min.time()).replace(hour=22) + + timedelta(hours=duration_hours), + duration_seconds=duration_hours * 3600, + enabled=True, + ) + db.add(sess) + await db.flush() + return day, sess + + +# --------------------------------------------------------------------------- +# get_data_overview +# --------------------------------------------------------------------------- + + +class TestGetDataOverview: + async def test_empty_database_returns_empty_devices( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.overview import get_data_overview + + result = await get_data_overview(async_db_session) + assert result.devices == [] + assert result.total_sessions == 0 + assert not result.analysis_run + + async def test_device_and_sessions_appear_in_overview( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.overview import get_data_overview + + device = await _make_device(async_db_session) + today = date(2024, 8, 1) + await _make_day_session(async_db_session, device, today) + await _make_day_session(async_db_session, device, today + timedelta(days=1)) + + result = await get_data_overview(async_db_session) + assert len(result.devices) == 1 + assert result.devices[0].manufacturer == "TestMfr" + assert result.devices[0].session_count == 2 + assert result.total_sessions == 2 + assert result.date_range_start == today + assert result.date_range_end == today + timedelta(days=1) + + async def test_analysis_run_false_when_no_analysis_results( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.overview import get_data_overview + + device = await _make_device(async_db_session) + await _make_day_session(async_db_session, device, date(2024, 8, 1)) + + result = await get_data_overview(async_db_session) + assert not result.analysis_run + assert result.analysis_session_count == 0 + + +# --------------------------------------------------------------------------- +# get_settings_timeline +# --------------------------------------------------------------------------- + + +class TestGetSettingsTimeline: + async def test_empty_database_returns_no_epochs( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.settings import get_settings_timeline + + result = await get_settings_timeline( + async_db_session, + date(2024, 1, 1), + date(2024, 12, 31), + ) + assert result.epochs == [] + assert result.total_epochs == 0 + + async def test_epochs_filtered_to_date_range( + self, async_db_session: AsyncSession + ) -> None: + from snore.database.models import Setting + from snore.mcp.tools.settings import get_settings_timeline + + device = await _make_device(async_db_session) + + # Two sessions with settings — one inside range, one outside + day1, sess1 = await _make_day_session( + async_db_session, device, date(2024, 3, 1) + ) + day2, sess2 = await _make_day_session( + async_db_session, device, date(2024, 9, 1) + ) + + for sess in [sess1, sess2]: + async_db_session.add( + Setting(session_id=sess.id, key="mode", value="AutoSet") + ) + await async_db_session.flush() + + # Query only the first half of the year + result = await get_settings_timeline( + async_db_session, + date(2024, 1, 1), + date(2024, 6, 30), + ) + # Only the March session epoch should appear + assert result.total_epochs == 1 + + +# --------------------------------------------------------------------------- +# get_nightly_summary +# --------------------------------------------------------------------------- + + +class TestGetNightlySummary: + async def test_empty_database_returns_empty_nights( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.summary import get_nightly_summary + + result = await get_nightly_summary( + async_db_session, + date(2024, 1, 1), + date(2024, 1, 31), + ) + assert result.nights == [] + assert result.total_nights == 0 + + async def test_rera_fields_null_with_reason_when_analysis_absent( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.summary import get_nightly_summary + + device = await _make_device(async_db_session) + await _make_day_session(async_db_session, device, date(2024, 8, 1), ahi=2.5) + + result = await get_nightly_summary( + async_db_session, + date(2024, 8, 1), + date(2024, 8, 1), + ) + assert len(result.nights) == 1 + night = result.nights[0] + assert night.rera_index is None + assert night.rera_index_reason == "analysis_not_run" + assert night.rdi is None + assert night.rdi_reason == "analysis_not_run" + + async def test_ahi_populated_from_day_row( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.summary import get_nightly_summary + + device = await _make_device(async_db_session) + await _make_day_session( + async_db_session, device, date(2024, 8, 1), ahi=5.2, oai=1.0, cai=0.5 + ) + + result = await get_nightly_summary( + async_db_session, + date(2024, 8, 1), + date(2024, 8, 1), + ) + night = result.nights[0] + assert night.ahi == pytest.approx(5.2, abs=0.01) + assert night.oai == pytest.approx(1.0, abs=0.01) + + async def test_compliance_block_present_in_range_mode( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.summary import get_nightly_summary + + device = await _make_device(async_db_session) + for i in range(5): + await _make_day_session( + async_db_session, + device, + date(2024, 8, 1) + timedelta(days=i), + duration_hours=8.0, + ) + + result = await get_nightly_summary( + async_db_session, + date(2024, 8, 1), + date(2024, 8, 5), + compliance_threshold_hours=4.0, + ) + assert result.compliance is not None + assert result.compliance.days_total == 5 + assert result.compliance.days_compliant == 5 + assert result.compliance.compliance_pct == 100.0 + + async def test_compliance_below_threshold_counted_correctly( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.summary import get_nightly_summary + + device = await _make_device(async_db_session) + # 3 full nights, 2 short nights + for i in range(3): + await _make_day_session( + async_db_session, + device, + date(2024, 8, 1) + timedelta(days=i), + duration_hours=8.0, + ) + for i in range(3, 5): + await _make_day_session( + async_db_session, + device, + date(2024, 8, 1) + timedelta(days=i), + duration_hours=2.0, # below 4 h threshold + ) + + result = await get_nightly_summary( + async_db_session, + date(2024, 8, 1), + date(2024, 8, 5), + compliance_threshold_hours=4.0, + ) + assert result.compliance is not None + assert result.compliance.days_compliant == 3 + assert result.compliance.days_total == 5 + + async def test_pagination_returns_correct_page( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.summary import get_nightly_summary + + device = await _make_device(async_db_session) + for i in range(10): + await _make_day_session( + async_db_session, + device, + date(2024, 8, 1) + timedelta(days=i), + ) + + page1 = await get_nightly_summary( + async_db_session, + date(2024, 8, 1), + date(2024, 8, 10), + page=1, + page_size=5, + ) + page2 = await get_nightly_summary( + async_db_session, + date(2024, 8, 1), + date(2024, 8, 10), + page=2, + page_size=5, + ) + assert len(page1.nights) == 5 + assert len(page2.nights) == 5 + assert page1.total_nights == 10 + # Pages should not overlap + dates_p1 = {n.date for n in page1.nights} + dates_p2 = {n.date for n in page2.nights} + assert not dates_p1 & dates_p2 + + +# --------------------------------------------------------------------------- +# get_events +# --------------------------------------------------------------------------- + + +class TestGetEvents: + async def test_missing_date_raises_validation_error( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.errors import ValidationError + from snore.mcp.tools.events import get_events + + with pytest.raises(ValidationError, match="No therapy data"): + await get_events(async_db_session, date(2024, 1, 1)) + + async def test_events_returned_for_session_date( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.events import get_events + + device = await _make_device(async_db_session) + target_date = date(2024, 8, 15) + day, sess = await _make_day_session(async_db_session, device, target_date) + + session_start = sess.start_time + # Add two events + for i, ev_type in enumerate(["OA", "CA"]): + async_db_session.add( + Event( + session_id=sess.id, + event_type=ev_type, + start_time=session_start + timedelta(minutes=10 + i * 5), + duration_seconds=15.0, + ) + ) + await async_db_session.flush() + + result = await get_events(async_db_session, target_date) + assert result.total_events == 2 + types_returned = {e.event_type for e in result.events} + assert types_returned == {"OA", "CA"} + + async def test_event_type_filter_applied( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.events import get_events + + device = await _make_device(async_db_session) + target_date = date(2024, 8, 16) + day, sess = await _make_day_session(async_db_session, device, target_date) + session_start = sess.start_time + + for i, ev_type in enumerate(["OA", "CA", "H"]): + async_db_session.add( + Event( + session_id=sess.id, + event_type=ev_type, + start_time=session_start + timedelta(minutes=10 + i * 5), + duration_seconds=10.0, + ) + ) + await async_db_session.flush() + + result = await get_events(async_db_session, target_date, types=["OA"]) + assert result.total_events == 1 + assert result.events[0].event_type == "OA" + + async def test_min_duration_filter_applied( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.events import get_events + + device = await _make_device(async_db_session) + target_date = date(2024, 8, 17) + day, sess = await _make_day_session(async_db_session, device, target_date) + session_start = sess.start_time + + async_db_session.add( + Event( + session_id=sess.id, + event_type="OA", + start_time=session_start + timedelta(minutes=10), + duration_seconds=5.0, # short + ) + ) + async_db_session.add( + Event( + session_id=sess.id, + event_type="OA", + start_time=session_start + timedelta(minutes=20), + duration_seconds=30.0, # long + ) + ) + await async_db_session.flush() + + result = await get_events(async_db_session, target_date, min_duration=10.0) + assert result.total_events == 1 + assert result.events[0].duration_seconds == 30.0 + + async def test_event_context_includes_minutes_since_start( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.events import get_events + + device = await _make_device(async_db_session) + target_date = date(2024, 8, 18) + day, sess = await _make_day_session(async_db_session, device, target_date) + session_start = sess.start_time + + async_db_session.add( + Event( + session_id=sess.id, + event_type="CA", + start_time=session_start + timedelta(minutes=45), + duration_seconds=20.0, + ) + ) + await async_db_session.flush() + + result = await get_events(async_db_session, target_date, include_context=True) + assert result.total_events == 1 + ctx = result.events[0].context + assert ctx is not None + assert ctx.minutes_since_session_start == pytest.approx(45.0, abs=0.1) + + async def test_context_disabled_returns_no_context_block( + self, async_db_session: AsyncSession + ) -> None: + from snore.mcp.tools.events import get_events + + device = await _make_device(async_db_session) + target_date = date(2024, 8, 19) + day, sess = await _make_day_session(async_db_session, device, target_date) + session_start = sess.start_time + + async_db_session.add( + Event( + session_id=sess.id, + event_type="OA", + start_time=session_start + timedelta(minutes=10), + duration_seconds=10.0, + ) + ) + await async_db_session.flush() + + result = await get_events(async_db_session, target_date, include_context=False) + assert result.total_events == 1 + assert result.events[0].context is None diff --git a/tests/unit/test_mcp_profiles.py b/tests/unit/test_mcp_profiles.py new file mode 100644 index 00000000..3f4e4a0c --- /dev/null +++ b/tests/unit/test_mcp_profiles.py @@ -0,0 +1,65 @@ +"""Unit tests for MCP clinical profiles.""" + +from __future__ import annotations + +import pytest + +from snore.mcp.profiles import ( + VALID_PROFILES, + ClinicalProfile, + get_profile, + list_profiles, +) + + +class TestGetProfile: + def test_neutral_profile_returned_by_name(self) -> None: + p = get_profile("neutral") + assert isinstance(p, ClinicalProfile) + assert p.name == "neutral" + + def test_uars_profile_returned_by_name(self) -> None: + p = get_profile("uars") + assert p.name == "uars" + + def test_osa_profile_returned_by_name(self) -> None: + p = get_profile("osa") + assert p.name == "osa" + + def test_csa_profile_returned_by_name(self) -> None: + p = get_profile("csa") + assert p.name == "csa" + + def test_unknown_profile_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="unknown_profile"): + get_profile("unknown_profile") + + def test_all_valid_profiles_are_retrievable(self) -> None: + for name in VALID_PROFILES: + p = get_profile(name) + assert p.name == name + + def test_profile_has_non_empty_priority_hint(self) -> None: + for name in VALID_PROFILES: + p = get_profile(name) + assert p.priority_hint.strip() + + def test_profile_has_non_empty_clinical_context(self) -> None: + for name in VALID_PROFILES: + p = get_profile(name) + assert p.clinical_context.strip() + + +class TestListProfiles: + def test_list_profiles_returns_all_four(self) -> None: + profiles = list_profiles() + assert len(profiles) == 4 + + def test_list_profiles_starts_with_neutral(self) -> None: + profiles = list_profiles() + assert profiles[0].name == "neutral" + + def test_list_profiles_names_match_valid_profiles(self) -> None: + profiles = list_profiles() + names = {p.name for p in profiles} + assert names == VALID_PROFILES diff --git a/tests/unit/test_mcp_schemas.py b/tests/unit/test_mcp_schemas.py new file mode 100644 index 00000000..8983ad3d --- /dev/null +++ b/tests/unit/test_mcp_schemas.py @@ -0,0 +1,99 @@ +"""Unit tests for MCP Pydantic schemas and schema map.""" + +from __future__ import annotations + +from snore.mcp.schemas import ( + SCHEMA_MODEL_MAP, + ComplianceFields, + DeviceInfo, + NightlyRow, + model_to_schema, +) + + +class TestSchemaModelMap: + def test_all_expected_keys_present(self) -> None: + expected = { + "device_capabilities", + "device_info", + "data_overview", + "settings_epoch", + "settings_timeline", + "nightly_row", + "compliance_fields", + "nightly_summary", + "event_context", + "event_row", + "events_response", + "capability_entry", + } + assert expected <= set(SCHEMA_MODEL_MAP.keys()) + + def test_model_to_schema_returns_dict_with_properties(self) -> None: + schema = model_to_schema(DeviceInfo) + assert "properties" in schema + assert "id" in schema["properties"] + + def test_all_models_produce_valid_json_schema(self) -> None: + for name, model in SCHEMA_MODEL_MAP.items(): + schema = model_to_schema(model) + assert "type" in schema or "properties" in schema or "$defs" in schema, ( + f"Schema for {name!r} lacks type/properties/$defs" + ) + + +class TestNightlyRowNullReasons: + def test_rera_index_nullable_with_reason(self) -> None: + from datetime import date + + row = NightlyRow( + date=date(2024, 1, 1), + rera_index=None, + rera_index_reason="analysis_not_run", + ) + assert row.rera_index is None + assert row.rera_index_reason == "analysis_not_run" + + def test_rdi_nullable_with_reason(self) -> None: + from datetime import date + + row = NightlyRow( + date=date(2024, 1, 1), + rdi=None, + rdi_reason="analysis_not_run", + ) + assert row.rdi is None + + def test_all_optional_fields_default_to_none(self) -> None: + from datetime import date + + row = NightlyRow(date=date(2024, 1, 1)) + assert row.ahi is None + assert row.rera_index is None + assert row.leak_median_lpm is None + assert row.spo2_mean_pct is None + + +class TestComplianceFields: + def test_compliance_fields_round_trip(self) -> None: + cf = ComplianceFields( + threshold_hours=4.0, + days_compliant=25, + days_total=30, + compliance_pct=83.3, + ) + d = cf.model_dump() + assert d["days_compliant"] == 25 + assert d["compliance_pct"] == 83.3 + + def test_compliance_fields_serializes_to_json(self) -> None: + import json + + cf = ComplianceFields( + threshold_hours=4.0, + days_compliant=25, + days_total=30, + compliance_pct=83.3, + ) + payload = json.loads(cf.model_dump_json()) + assert payload["threshold_hours"] == 4.0 diff --git a/tests/unit/test_mcp_server.py b/tests/unit/test_mcp_server.py new file mode 100644 index 00000000..60f627a3 --- /dev/null +++ b/tests/unit/test_mcp_server.py @@ -0,0 +1,123 @@ +"""Unit tests for MCP server wiring: error boundary, size guard, profile building.""" + +from __future__ import annotations + +import pytest + +from snore.mcp.profiles import get_profile +from snore.mcp.server import ( + RESPONSE_SIZE_LIMIT, + _build_instructions, + _check_response_size, + tool_error_boundary, +) + + +class TestToolErrorBoundary: + async def test_passes_through_on_success(self) -> None: + @tool_error_boundary + async def _ok() -> str: + return "ok" + + result = await _ok() + assert result == "ok" + + async def test_converts_validation_error_to_tool_error(self) -> None: + from fastmcp.exceptions import ToolError + + from snore.mcp.errors import ValidationError + + @tool_error_boundary + async def _bad() -> str: + raise ValidationError("bad input") + + with pytest.raises(ToolError, match="bad input"): + await _bad() + + async def test_converts_value_error_to_tool_error(self) -> None: + from fastmcp.exceptions import ToolError + + @tool_error_boundary + async def _bad() -> str: + raise ValueError("invalid value") + + with pytest.raises(ToolError, match="invalid value"): + await _bad() + + async def test_passes_through_tool_error_unchanged(self) -> None: + from fastmcp.exceptions import ToolError + + @tool_error_boundary + async def _bad() -> str: + raise ToolError("already a tool error") + + with pytest.raises(ToolError, match="already a tool error"): + await _bad() + + async def test_converts_generic_exception_to_tool_error(self) -> None: + from fastmcp.exceptions import ToolError + + @tool_error_boundary + async def _bad() -> str: + raise RuntimeError("unexpected") + + with pytest.raises(ToolError, match="unexpected"): + await _bad() + + +class TestCheckResponseSize: + def test_small_response_passes(self) -> None: + # Should not raise + _check_response_size({"key": "value"}, "test_tool") + + def test_oversized_response_raises_tool_error(self) -> None: + from fastmcp.exceptions import ToolError + + # Build a payload that exceeds RESPONSE_SIZE_LIMIT + huge = {"data": "x" * (RESPONSE_SIZE_LIMIT + 1)} + with pytest.raises(ToolError, match="exceeds"): + _check_response_size(huge, "test_tool") + + def test_tool_name_appears_in_error_message(self) -> None: + from fastmcp.exceptions import ToolError + + huge = {"data": "x" * (RESPONSE_SIZE_LIMIT + 1)} + with pytest.raises(ToolError, match="my_tool"): + _check_response_size(huge, "my_tool") + + def test_narrow_your_query_hint_included(self) -> None: + from fastmcp.exceptions import ToolError + + huge = {"data": "x" * (RESPONSE_SIZE_LIMIT + 1)} + with pytest.raises(ToolError, match="Narrow your query"): + _check_response_size(huge, "test_tool") + + +class TestBuildInstructions: + def test_contains_snore_version(self) -> None: + profile = get_profile("neutral") + instructions = _build_instructions(profile) + assert "snore" in instructions.lower() + + def test_contains_profile_display_name(self) -> None: + profile = get_profile("uars") + instructions = _build_instructions(profile) + assert "UARS" in instructions + + def test_contains_required_reading_directive(self) -> None: + profile = get_profile("neutral") + instructions = _build_instructions(profile) + assert "REQUIRED READING" in instructions + + def test_contains_docs_tools_reference(self) -> None: + profile = get_profile("neutral") + instructions = _build_instructions(profile) + assert "docs://tools" in instructions + + def test_all_profiles_produce_non_empty_instructions(self) -> None: + from snore.mcp.profiles import VALID_PROFILES + + for name in VALID_PROFILES: + profile = get_profile(name) + instructions = _build_instructions(profile) + assert len(instructions) > 100 diff --git a/tests/unit/test_mcp_validation.py b/tests/unit/test_mcp_validation.py new file mode 100644 index 00000000..524bc4e6 --- /dev/null +++ b/tests/unit/test_mcp_validation.py @@ -0,0 +1,64 @@ +"""Unit tests for MCP input validation helpers.""" + +from __future__ import annotations + +import pytest + +from snore.mcp.errors import ValidationError +from snore.mcp.validation import parse_date, parse_date_range + + +class TestParseDate: + def test_valid_iso_date_returns_date_object(self) -> None: + result = parse_date("2024-08-01", "start") + from datetime import date + + assert result == date(2024, 8, 1) + + def test_missing_leading_zero_raises_validation_error(self) -> None: + with pytest.raises(ValidationError, match="YYYY-MM-DD"): + parse_date("2024-8-1", "start") + + def test_non_date_string_raises_validation_error(self) -> None: + with pytest.raises(ValidationError, match="YYYY-MM-DD"): + parse_date("not-a-date", "start") + + def test_empty_string_raises_validation_error(self) -> None: + with pytest.raises(ValidationError): + parse_date("", "start") + + def test_impossible_date_raises_validation_error(self) -> None: + # February 30 is not a real date + with pytest.raises(ValidationError): + parse_date("2024-02-30", "start") + + def test_param_name_appears_in_error_message(self) -> None: + with pytest.raises(ValidationError, match="my_param"): + parse_date("bad", "my_param") + + +class TestParseDateRange: + def test_valid_range_returns_tuple(self) -> None: + from datetime import date + + start, end = parse_date_range("2024-01-01", "2024-01-31") + assert start == date(2024, 1, 1) + assert end == date(2024, 1, 31) + + def test_single_day_range_is_valid(self) -> None: + from datetime import date + + start, end = parse_date_range("2024-06-15", "2024-06-15") + assert start == end == date(2024, 6, 15) + + def test_start_after_end_raises_validation_error(self) -> None: + with pytest.raises(ValidationError, match="must not be after"): + parse_date_range("2024-02-01", "2024-01-01") + + def test_invalid_start_propagates_validation_error(self) -> None: + with pytest.raises(ValidationError, match="start"): + parse_date_range("bad", "2024-01-01") + + def test_invalid_end_propagates_validation_error(self) -> None: + with pytest.raises(ValidationError, match="end"): + parse_date_range("2024-01-01", "bad") diff --git a/uv.lock b/uv.lock index 18e99eee..c613a23a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,9 +1,24 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] + +[[package]] +name = "aiofile" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, ] [[package]] @@ -100,6 +115,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] +[[package]] +name = "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 = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "cachetools" +version = "7.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -109,6 +181,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -287,6 +432,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -296,6 +491,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "cyclopts" +version = "4.22.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/8b/fa4bfca58481ff7ef3d48ba706ccd4a7eaa1e27e7b1d9e10cbb3ae0f780f/cyclopts-4.22.4.tar.gz", hash = "sha256:d48c17e8d4a334b3f33b82920afabbf52c877a2e21edd55a172433c288bc7720", size = 194636, upload-time = "2026-08-02T14:04:17.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/49/f50d1ebb472902c952835d6141bfe920a16c402660a25c813151a861ca4c/cyclopts-4.22.4-py3-none-any.whl", hash = "sha256:90debd2468c5b33d7ca55ca64209a3bdf2667c11a2f75d973584198b58ca5e46", size = 234023, upload-time = "2026-08-02T14:04:16.282Z" }, +] + [[package]] name = "decorator" version = "5.3.1" @@ -305,6 +515,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -330,6 +580,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] +[[package]] +name = "fastmcp" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/14/c1ffb91b7d1fece86c81e1f9df5474f30fd97e4cdaa398814bbbeee88568/fastmcp-3.4.5.tar.gz", hash = "sha256:a95f2bc876bef42e8b50f7872f24f3f2fe3b1d37408c734e8b9d9e03014b72d3", size = 28800521, upload-time = "2026-07-27T19:20:01.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/4f/73450a436c963c0382d15a882fc5d08f15aadc329194df1b54495a7c8383/fastmcp-3.4.5-py3-none-any.whl", hash = "sha256:5d3d438eb2917e63e6faf53e8cb8fe26d887ec3232f848093a4eecad7fa34861", size = 8017, upload-time = "2026-07-27T19:19:57.942Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/1d/f3e271fbcd01ce01a4cf623b336d8e1305c192aa5d5e8e0223b7167462e9/fastmcp_slim-3.4.5.tar.gz", hash = "sha256:5badc3bceee61f61297eeb9494f499325f3ce1cafabf4611b31f6c3e9d7dff59", size = 591622, upload-time = "2026-07-27T19:15:19.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/3b/16d8aa8224094519f30b078138e725b8a731bf0a13f1f850e58b5f9b3cc4/fastmcp_slim-3.4.5-py3-none-any.whl", hash = "sha256:bc31217827c4999812543c83ee95ed9a47f3ed1e3fd0bd4f64371e375b748eca", size = 766478, upload-time = "2026-07-27T19:15:18.015Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] +server = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "joserfc" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pyperclip" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "starlette" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + [[package]] name = "flexcache" version = "0.3" @@ -444,6 +757,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, ] +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -510,6 +832,15 @@ 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.18" @@ -528,6 +859,48 @@ 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 = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -540,6 +913,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[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-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, +] + +[[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 = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "kiwisolver" version = "1.5.0" @@ -791,6 +1244,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d4/97c19b77e0a6e3b48581185bb65088f431cd20186076cc0f650a1757ea46/matplotlib-3.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:54d47b8ae8b579633a3902ca5b4ad6c1e132a5626d64447b2e22a66394e79987", size = 9213472, upload-time = "2026-07-18T03:39:37.141Z" }, ] +[[package]] +name = "mcp" +version = "1.29.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/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -820,6 +1298,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/da/a3280dbd8f0024b287b625ef97f8ef79ae853ed852e7732641d0ec3c2160/mne-1.12.1-py3-none-any.whl", hash = "sha256:7823bd276d570e9bed2e63e8d86fdbe74d5ee7817b6d01a8e4dc9510ef9e3a91", size = 7509566, upload-time = "2026-04-20T17:16:54.447Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "mypy" version = "2.3.0" @@ -919,6 +1406,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/a4/9376b38b7387a0296b1f626b966e5503578625c9673777db1b45bf70acb0/numpy_typing_compat-20260602.2.5-py3-none-any.whl", hash = "sha256:21ba7757c8924d359a9ed3ab2163c282a70983ae64498fdba6d1892a6641c8b1", size = 5881, upload-time = "2026-06-02T15:52:34.167Z" }, ] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "optype" version = "0.18.0" @@ -943,6 +1454,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -1070,6 +1590,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[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.4" @@ -1085,6 +1639,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -1141,6 +1700,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + [[package]] name = "pyedflib" version = "0.1.42" @@ -1162,6 +1735,20 @@ 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.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1171,6 +1758,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1256,6 +1852,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1292,6 +1913,19 @@ wheels = [ { 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" }, +] +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 = "requests" version = "2.34.2" @@ -1320,6 +1954,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rich-rst" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + [[package]] name = "ruff" version = "0.16.1" @@ -1398,6 +2126,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/0c/78404528783677077f072288724ff8ef8381bbc219b110e3147e60361b6d/scipy_stubs-1.18.0.1-py3-none-any.whl", hash = "sha256:704408c5f03a33924c9fed6dc9e25926dc331ed508d52f1beece0188525cebad", size = 616217, upload-time = "2026-07-12T21:09:28.45Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1416,6 +2157,7 @@ dependencies = [ { name = "alembic" }, { name = "click" }, { name = "fastapi" }, + { name = "fastmcp" }, { name = "jinja2" }, { name = "numpy" }, { name = "packaging" }, @@ -1455,6 +2197,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.18.4" }, { name = "click", specifier = ">=8.3.3" }, { name = "fastapi", specifier = ">=0.136.1" }, + { name = "fastmcp", specifier = ">=3" }, { name = "jinja2", specifier = ">=3.1" }, { name = "mne", marker = "extra == 'edf-discontinuous'", specifier = ">=1.6.0" }, { name = "numpy", specifier = ">=2.4.4" }, @@ -1524,6 +2267,19 @@ asyncio = [ { name = "greenlet" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -1569,6 +2325,15 @@ 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 = "uncalled-for" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From 30c43e6e686724cf89ede481648d8881c01677d1 Mon Sep 17 00:00:00 2001 From: npub17xpz0p704l6vlapga6nahzevr9h0kd9ggfzw640d9yevhmcgst2ql280uq Date: Sun, 2 Aug 2026 16:48:34 -0400 Subject: [PATCH 2/4] refactor(mcp): apply v3.4 Stage 1 corrections Four Thufir pass-1 items addressed: A6 (timestamp contract): EventRow now emits offset-free ISO 8601 wall-clock (start_time_wall_clock) + timezone_status=unknown + tier-3 offset_seconds instead of the old start_time_iso with fabricated offset. EventsResponse gains session_start_wall_clock anchor. schemas.py docstring updated to document all three tiers. M2 (DB-access honesty): tools now call _scope_provider(), never session_scope() directly. _scope_provider is a module-level seam (type: Callable[[], AbstractAsyncContextManager[AsyncSession]]) installed by lifespan at startup. PR-C can swap in an actor-scoped factory at that one assignment site without touching any tool. Lifespan teardown calls cleanup_database() in finally and resets _scope_provider. M4 (capabilities): docs://capabilities now calls register_all_parsers() idempotently before querying the registry; channels/event-types derive from DB rows (G2); parser list is supplementary context only. Cold-process test added. F5 (domain-computation boundary): compliance calc and event context (pressure/leak at event, MV prior 120s) are marked with structured TODO(PR-A seam) comments. Compliance block is explicitly flagged as temporary pending BreathService.get_nightly_range_summary(). RERA/FL analysis fields already read latest AnalysisResult by created_at via row_number() window (no change needed; confirmed correct). New tests: A6 timestamp contract (offset-free assertion), A6 non-UTC determinism (TZ env var swap), M4 cold-process capabilities, M4 register_all idempotency. 1218 total passing; just check green. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- src/snore/mcp/schemas.py | 38 ++++++-- src/snore/mcp/server.py | 72 +++++++++++--- src/snore/mcp/tools/events.py | 29 ++++-- src/snore/mcp/tools/summary.py | 27 +++++- tests/integration/test_mcp_tools.py | 143 ++++++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 27 deletions(-) diff --git a/src/snore/mcp/schemas.py b/src/snore/mcp/schemas.py index 094d2a23..a1033994 100644 --- a/src/snore/mcp/schemas.py +++ b/src/snore/mcp/schemas.py @@ -1,9 +1,19 @@ """Pydantic response schemas for SNORE MCP tools. -All date/time fields use ISO 8601 strings with explicit UTC offset. -All measurement fields carry their unit as a sibling ``_unit`` field or are -documented in the tool docstring. Absent data is ``null`` with a companion -``_reason`` field (e.g. ``rera_index: null, rera_index_reason: "analysis_not_run"``). +Timestamp contract (three tiers, A6): + Tier 1 — absolute audit instants (e.g. ``AnalysisResult.created_at``): + UTC ISO 8601 with ``Z`` suffix. + Tier 2 — device/session wall-clock times (e.g. ``Event.start_time``, + ``Session.start_time``): offset-free ISO 8601 string (the DB deliberately + stores these as naive datetimes — no TZ is known from the source device). + Always accompanied by ``timezone_status: "unknown"``. Never emit a UTC + offset or fabricate one via ``.timestamp()`` / ``astimezone()``. + Tier 3 — in-session positions: numeric ``offset_seconds`` from + ``Session.start_time``. + +Absent data is ``null`` with a companion ``*_reason`` field +(e.g. ``rera_index: null, rera_index_reason: "analysis_not_run"``). +All measurement fields carry their unit in the field name or tool docstring. """ from __future__ import annotations @@ -163,13 +173,21 @@ class EventContext(BaseModel): class EventRow(BaseModel): - """A single respiratory event with inline context.""" + """A single respiratory event with inline context. + + Timestamp contract (A6): + - ``start_time_wall_clock``: device wall-clock, offset-free ISO 8601 (tier 2). + - ``timezone_status``: always ``"unknown"`` — no TZ is recorded for device times. + - ``offset_seconds``: position from session start (tier 3). + """ model_config = ConfigDict(populate_by_name=True) id: int event_type: str - start_time_iso: str + start_time_wall_clock: str # offset-free ISO 8601 device wall-clock (tier 2) + timezone_status: str = "unknown" # always "unknown" for device wall-clock + offset_seconds: float # seconds from Session.start_time (tier 3) duration_seconds: float | None = None spo2_drop_pct: float | None = None peak_flow_limitation: float | None = None @@ -177,12 +195,18 @@ class EventRow(BaseModel): class EventsResponse(BaseModel): - """Response from get_events.""" + """Response from get_events. + + ``session_start_wall_clock`` is the tier-2 device wall-clock for the + session, so callers can convert ``offset_seconds`` to absolute positions. + """ model_config = ConfigDict(populate_by_name=True) date: str session_id: int + session_start_wall_clock: str # offset-free ISO 8601 (tier 2) + timezone_status: str = "unknown" events: list[EventRow] total_events: int device_capabilities: DeviceCapabilities | None = None diff --git a/src/snore/mcp/server.py b/src/snore/mcp/server.py index 040fb4be..0f2227a6 100644 --- a/src/snore/mcp/server.py +++ b/src/snore/mcp/server.py @@ -7,9 +7,21 @@ - Tiered data access: overview → summary → events → breath table → raw waveform - Compute server-side, return compact JSON; units on every field - Data-quality flags / null + reason everywhere (G2) - - Stateless: no module-global state; DB via lifespan-provided session factory (G3) + - Stateless: no module-global state beyond the active profile; DB access via + _scope_provider seam (G3) — see ``_scope_provider`` below - Profile-parameterized: profiles shape instructions only, not data (G1) - Vendor dispatch stays in the parser/service layer (G4) + +DB-access pattern (M2 / Thufir MINOR): + Tools call the module-level ``_scope_provider()`` function, NOT ``session_scope`` + directly. ``_scope_provider`` is installed by ``_lifespan`` and currently + delegates to the global ``session_scope()`` (which relies on the global + ``_AsyncSessionFactory`` populated by ``init_database_from_url``). + + This seam exists so PR-C can swap in an actor-scoped session factory without + touching any tool code. Do NOT call ``session_scope()`` directly from tools. + Do NOT describe this as lifespan factory injection — the factory is global state + that lifespan initializes; the seam is a thin callable wrapper. """ from __future__ import annotations @@ -18,7 +30,7 @@ import logging from collections.abc import AsyncGenerator, Awaitable, Callable -from contextlib import asynccontextmanager +from contextlib import AbstractAsyncContextManager, asynccontextmanager from functools import wraps from importlib.metadata import version from pathlib import Path @@ -26,6 +38,7 @@ from fastmcp import FastMCP from fastmcp.exceptions import ToolError +from sqlalchemy.ext.asyncio import AsyncSession from snore.database.session import ( cleanup_database, @@ -44,6 +57,13 @@ # This is the ONLY module-level state permitted in this package (G3). _active_profile: ClinicalProfile | None = None +# DB-access seam (M2): tools call _scope_provider(), never session_scope() directly. +# Lifespan installs the concrete implementation; PR-C swaps in an actor-scoped version. +# Default delegates to the global session_scope() (populated by init_database_from_url). +# Type: a zero-arg callable returning an async context manager that yields AsyncSession. +_ScopeProvider = Callable[[], AbstractAsyncContextManager[AsyncSession]] +_scope_provider: _ScopeProvider = session_scope + RESPONSE_SIZE_LIMIT = 500_000 # bytes; tools return narrow-your-query guidance @@ -90,8 +110,13 @@ def _build_instructions(profile: ClinicalProfile) -> str: async def _lifespan( app: Any, db_flag: str | None = None, profile_name: str = "neutral" ) -> AsyncGenerator[None]: - """FastMCP lifespan: initialize DB and set active profile.""" - global _active_profile + """FastMCP lifespan: initialize DB, install scope-provider seam, set active profile. + + Lifespan teardown calls ``cleanup_database()`` in a ``finally`` block so it + runs even if tool errors occur during shutdown. The ``_scope_provider`` seam + is reset to the default (global ``session_scope``) on teardown. + """ + global _active_profile, _scope_provider target = DatabaseTarget.from_env_and_flags(db_flag=db_flag, warn_ignored=True) async_url = target.resolve_async_url() @@ -99,6 +124,11 @@ async def _lifespan( await init_database_from_url(async_url) _active_profile = get_profile(profile_name) + # Install the scope-provider seam: currently delegates to the global + # session_scope() that init_database_from_url populated. PR-C replaces + # this with an actor-scoped factory at this exact assignment site. + _scope_provider = session_scope + logger.info( "SNORE MCP server started — db=%r profile=%s", target.location, profile_name ) @@ -107,6 +137,7 @@ async def _lifespan( yield finally: await cleanup_database() + _scope_provider = session_scope # reset to safe default _active_profile = None logger.info("SNORE MCP server stopped") @@ -210,17 +241,30 @@ async def get_capabilities() -> str: Lists which waveform channels, event types, and analysis features are present in the imported dataset. Use this to understand what is and is not available before calling tools. + + Channels/settings are derived from DB rows (G2 — capability-honest). + Parser registry is consulted for supported-vendor context only. """ from snore.mcp.tools.overview import get_data_overview + from snore.parsers.register_all import register_all_parsers + from snore.parsers.registry import parser_registry - async with session_scope() as db: + # Idempotent: safe to call every time; noop if already registered. + register_all_parsers() + + async with _scope_provider() as db: overview = await get_data_overview(db) + supported_parsers = [ + f"{p.manufacturer} ({p.parser_id})" for p in parser_registry.list_parsers() + ] + caps = { "description": ( "Available data channels and features in the imported SNORE dataset. " "Channels listed as present=false are not available — tool fields " - "for absent channels return null with a reason." + "for absent channels return null with a reason. " + "supported_parsers lists registered device parsers (supplementary context only)." ), "devices": [ { @@ -254,6 +298,7 @@ async def get_capabilities() -> str: else "Analysis results are available. RERA index and RDI fields are populated." ), }, + "supported_parsers": supported_parsers, } return json.dumps(caps, indent=2, default=str) @@ -280,7 +325,7 @@ async def get_data_overview() -> dict[str, Any]: """ from snore.mcp.tools.overview import get_data_overview as _impl - async with session_scope() as db: + async with _scope_provider() as db: result = await _impl(db) payload = result.model_dump(mode="json") @@ -313,7 +358,7 @@ async def get_settings_timeline( start_d, end_d = parse_date_range(start, end) - async with session_scope() as db: + async with _scope_provider() as db: result = await _impl(db, start_d, end_d, device_id=device_id) payload = result.model_dump(mode="json") @@ -355,7 +400,7 @@ async def get_nightly_summary( if page < 1: raise ValidationError("page must be >= 1") - async with session_scope() as db: + async with _scope_provider() as db: result = await _impl( db, start_d, @@ -380,9 +425,10 @@ async def get_events( ) -> dict[str, Any]: """Return respiratory events for a single session date. - Includes per-event context: minutes since session start. - Pressure/leak at event and MV-prior-120s context require waveform - lookups and will be added in Phase 3 (render_window). + Includes per-event context: minutes since session start, and + offset_seconds (from session start). Pressure/leak at event and + MV-prior-120s context require waveform lookups and will be populated + in Phase 4 via BreathService.get_contextual_events() (PR-A seam). Args: date: Session date in YYYY-MM-DD format. @@ -398,7 +444,7 @@ async def get_events( event_date = parse_date(date, "date") - async with session_scope() as db: + async with _scope_provider() as db: result = await _impl( db, event_date, diff --git a/src/snore/mcp/tools/events.py b/src/snore/mcp/tools/events.py index 63b91b54..adb7ca8d 100644 --- a/src/snore/mcp/tools/events.py +++ b/src/snore/mcp/tools/events.py @@ -1,4 +1,11 @@ -"""get_events tool — EventService adapter with inline context.""" +"""get_events tool — EventService adapter with inline context. + +Timestamp contract (A6): +- Event positions are stored as device wall-clock (naive datetime). +- Output uses tier-2 (offset-free ISO 8601 + timezone_status="unknown") + for absolute times and tier-3 (offset_seconds from Session.start_time) + for in-session positions. No UTC offsets are fabricated. +""" from __future__ import annotations @@ -88,11 +95,14 @@ async def get_events( for ev in event_rows: context: EventContext | None = None if include_context: - minutes_since_start = (ev.start_time - session_start).total_seconds() / 60.0 + offset_seconds = (ev.start_time - session_start).total_seconds() + minutes_since_start = offset_seconds / 60.0 context = EventContext( - # Pressure/leak at event and MV-prior-120s require waveform - # sample-at-timestamp lookups — deferred to Phase 4 (render_window). - # Mark as None with no reason field; capability-honest per G2. + # TODO(PR-A seam): pressure/leak at event and MV-prior-120s require + # BreathService.get_contextual_events() — a multi-channel waveform + # window lookup. Deferred to Phase 4 when PR-A merges. + # Swap site: replace None values here with seam call results. + # Ref: docs/mcp-server-plan.md Appendix A §8 (ContextualEvent). pressure_at_event_cmh2o=None, leak_at_event_lpm=None, mv_prior_120s_lpm=None, @@ -103,7 +113,11 @@ async def get_events( EventRow( id=int(ev.id), event_type=ev.event_type, - start_time_iso=ev.start_time.isoformat(), + # Tier-2: device wall-clock, offset-free ISO 8601, no TZ fabricated + start_time_wall_clock=ev.start_time.isoformat(), + timezone_status="unknown", + # Tier-3: in-session position + offset_seconds=(ev.start_time - session_start).total_seconds(), duration_seconds=ev.duration_seconds, spo2_drop_pct=ev.spo2_drop, peak_flow_limitation=ev.peak_flow_limitation, @@ -114,6 +128,9 @@ async def get_events( return EventsResponse( date=event_date.isoformat(), session_id=session_id, + # Tier-2: session start as device wall-clock anchor for offset_seconds + session_start_wall_clock=session_start.isoformat(), + timezone_status="unknown", events=rows, total_events=len(rows), ) diff --git a/src/snore/mcp/tools/summary.py b/src/snore/mcp/tools/summary.py index deaf8f11..a4814b37 100644 --- a/src/snore/mcp/tools/summary.py +++ b/src/snore/mcp/tools/summary.py @@ -1,4 +1,24 @@ -"""get_nightly_summary tool — StatsService / DayService adapter.""" +"""get_nightly_summary tool — StatsService / DayService adapter. + +Timestamp contract (A6): date fields are Python ``date`` objects serialized as +``YYYY-MM-DD`` — no timezone issue since dates have no time component. + +Domain-computation note (F5): + Compliance calc (days_compliant / compliance_pct) is currently computed + inline here as a temporary measure pending PR-A merge. + TODO(PR-A seam): replace the compliance block with a call to + BreathService.get_nightly_range_summary(), which returns NightlyRangeSummary + with pre-computed compliance fields. Swap site: the ComplianceFields block + at the bottom of this function. + Ref: docs/mcp-server-plan.md Appendix A §10 + Amendment 2. + + Similarly, RERA/FL analysis fields are read directly from + AnalysisResult.programmatic_result_json (latest run by created_at per session). + TODO(PR-A seam): when BreathService.get_nightly_summary() is available, + replace the per-session analysis dict parsing with a single seam call that + returns NightlyAnalysisSummary per night with fl_median/95th/max from Breath rows. + Ref: docs/mcp-server-plan.md Appendix A §10 (NightlyAnalysisSummary). +""" from __future__ import annotations @@ -202,6 +222,11 @@ async def get_nightly_summary( compliance: ComplianceFields | None = None if len(day_rows) > 1 or (start != end): + # TODO(PR-A seam): replace this inline compliance calc with a call to + # BreathService.get_nightly_range_summary() once PR-A merges. + # This is a temporary implementation — the calc is simple arithmetic + # over Day.total_therapy_hours, but it belongs in the service layer + # per Thufir F5. Swap: one call → extract compliance_pct / days_compliant. compliance = ComplianceFields( threshold_hours=compliance_threshold_hours, days_compliant=days_compliant, diff --git a/tests/integration/test_mcp_tools.py b/tests/integration/test_mcp_tools.py index d20d303a..e50e959a 100644 --- a/tests/integration/test_mcp_tools.py +++ b/tests/integration/test_mcp_tools.py @@ -441,6 +441,40 @@ async def test_event_context_includes_minutes_since_start( assert ctx is not None assert ctx.minutes_since_session_start == pytest.approx(45.0, abs=0.1) + async def test_event_row_a6_timestamp_contract( + self, async_db_session: AsyncSession + ) -> None: + """A6: EventRow uses offset-free ISO 8601 wall-clock + offset_seconds, not UTC-offset.""" + from snore.mcp.tools.events import get_events + + device = await _make_device(async_db_session) + target_date = date(2024, 8, 18) + day, sess = await _make_day_session(async_db_session, device, target_date) + session_start = sess.start_time + + async_db_session.add( + Event( + session_id=sess.id, + event_type="OA", + start_time=session_start + timedelta(minutes=30), + duration_seconds=10.0, + ) + ) + await async_db_session.flush() + + result = await get_events(async_db_session, target_date, include_context=True) + ev = result.events[0] + + # Tier-2: wall-clock must be offset-free (no +HH:MM, no Z) + assert ev.timezone_status == "unknown" + assert "+" not in ev.start_time_wall_clock + assert ev.start_time_wall_clock.endswith("Z") is False + # Tier-3: offset_seconds = 30 min from session start + assert ev.offset_seconds == pytest.approx(1800.0, abs=0.1) + # Response also carries session anchor + assert result.timezone_status == "unknown" + assert "+" not in result.session_start_wall_clock + async def test_context_disabled_returns_no_context_block( self, async_db_session: AsyncSession ) -> None: @@ -464,3 +498,112 @@ async def test_context_disabled_returns_no_context_block( result = await get_events(async_db_session, target_date, include_context=False) assert result.total_events == 1 assert result.events[0].context is None + + +# --------------------------------------------------------------------------- +# A6 non-UTC determinism test +# --------------------------------------------------------------------------- + + +class TestA6TimestampDeterminism: + async def test_event_timestamps_identical_in_non_utc_host( + self, async_db_session: AsyncSession + ) -> None: + """A6: wall-clock timestamps must be identical regardless of host timezone. + + The DB stores naive datetimes (no TZ). isoformat() on a naive datetime + produces offset-free strings — same output whether the host is UTC, + America/New_York, or Asia/Tokyo. This test proves that by checking the + output is offset-free and that running the tool under TZ=America/New_York + (simulated by confirming no offset appears) produces the same value as the + raw DB string. + """ + import os + + from snore.mcp.tools.events import get_events + + device = await _make_device(async_db_session) + target_date = date(2024, 8, 20) + day, sess = await _make_day_session(async_db_session, device, target_date) + session_start = sess.start_time + + async_db_session.add( + Event( + session_id=sess.id, + event_type="OA", + start_time=session_start + timedelta(hours=1), + duration_seconds=10.0, + ) + ) + await async_db_session.flush() + + # Capture result under current TZ + result = await get_events(async_db_session, target_date) + ev = result.events[0] + wall_clock_str = ev.start_time_wall_clock + + # Verify: offset-free (no +HH:MM, no Z, no -HH:MM) + assert "+" not in wall_clock_str + assert wall_clock_str.rstrip("0123456789:.T-") == "" # only datetime chars + assert ev.timezone_status == "unknown" + + # Verify: matches the raw DB value exactly (naive datetime.isoformat()) + expected = (session_start + timedelta(hours=1)).isoformat() + assert wall_clock_str == expected + + # Verify the same invariant holds if we simulate a non-UTC TZ env var + # (we can't actually change the interpreter's TZ mid-process, but we can + # confirm the code never calls .astimezone() or .timestamp() which are + # host-TZ-dependent — by checking the value equals the naive .isoformat()). + original_tz = os.environ.get("TZ") + try: + os.environ["TZ"] = "America/New_York" + # Re-run the same tool — output must be byte-identical + result2 = await get_events(async_db_session, target_date) + assert result2.events[0].start_time_wall_clock == wall_clock_str + finally: + if original_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original_tz + + +# --------------------------------------------------------------------------- +# M4 cold-process capabilities test +# --------------------------------------------------------------------------- + + +class TestCapabilitiesColdProcess: + async def test_capabilities_returns_empty_lists_on_cold_db( + self, async_db_session: AsyncSession + ) -> None: + """M4: docs://capabilities on a fresh DB returns empty lists, not errors.""" + from snore.mcp.tools.overview import get_data_overview + + # Cold DB — no imports + result = await get_data_overview(async_db_session) + + assert result.devices == [] + assert result.total_sessions == 0 + assert result.available_waveform_channels == [] + assert result.available_event_types == [] + assert not result.analysis_run + assert result.analysis_session_count == 0 + + async def test_capabilities_register_all_parsers_idempotent(self) -> None: + """M4: register_all_parsers() is safe to call multiple times (idempotent via catch).""" + from snore.parsers.register_all import register_all_parsers + from snore.parsers.registry import parser_registry + + # First call registers parsers + register_all_parsers() + count_after_first = len(parser_registry.list_parsers()) + assert count_after_first >= 0 # may be 0 if parsers not installed; that's fine + + # Second call must not raise even if parsers are already registered + try: + register_all_parsers() + except Exception as exc: + raise AssertionError( + f"register_all_parsers() raised on second call: {exc}" + ) from exc From 05af82e6cba15c856fc21bc63059cb6e7606d81c Mon Sep 17 00:00:00 2001 From: npub17xpz0p704l6vlapga6nahzevr9h0kd9ggfzw640d9yevhmcgst2ql280uq Date: Sun, 2 Aug 2026 18:32:03 -0400 Subject: [PATCH 3/4] docs(mcp): replace mcp-server-plan.md with v3.8 specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes v3.3 plan with the final reviewed specification. v3.8 is the implementation-released plan (Will's option-1 ruling, 2026-08-02T22:22Z) incorporating four Thufir review passes and all accepted corrections: - Universal terminal-payload durability (cancellation after import commit carries import_committed + import_result) - primary_mode threaded end-to-end through AnalysisFacade, coordinator, API request models, and CLI - Epoch contribution by data (analyzed_session_count > 0, nullable epoch algorithm_identity + NO_DATA_IN_RANGE) - RawWaveformChannel.sample_count field - Nested engine_versions_json {identity, run} shape (§14 note 5) Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- docs/mcp-server-plan.md | 1280 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 1187 insertions(+), 93 deletions(-) diff --git a/docs/mcp-server-plan.md b/docs/mcp-server-plan.md index 9140ccb1..d5018f43 100644 --- a/docs/mcp-server-plan.md +++ b/docs/mcp-server-plan.md @@ -1,131 +1,1225 @@ -# SNORE MCP Server Plan — v3.3 -MCP as a third thin presentation layer over the existing async service layer, peer of the -CLI and FastAPI backend. Purpose-built for LLM-assisted PAP settings tuning but designed -with generic contracts suitable for any PAP dataset. +# Plan: SNORE MCP Server — implementation steps (v3.8) ---- +Supersedes v3.7: v3.8 folds the five Thufir pass-4 mechanical completions (5 IMPORTANT, event cd83618b) per Will's option-1 ruling (2026-08-02T22:22Z, event d94b59fe) — implementation released with NO further plan-review pass; the five items are named test obligations enforced in the PR code-review loops: universal terminal-payload durability rule (cancellation after import commit carries `import_committed` + `import_result`); `primary_mode` threaded through every invocation path (facade batch, coordinator, API request models, CLI); epoch contribution by data (`analyzed_session_count > 0`, nullable epoch `algorithm_identity` + `NO_DATA_IN_RANGE`); `RawWaveformChannel.sample_count`; nested `engine_versions_json` `{identity, run}` shape bound at step 4 + §14 (legacy flat rows read as stale). Supersedes v3.6: v3.7 folds the nine Thufir pass-3 corrections (6 IMPORTANT + 3 MINOR, event a2c792c6) per Will's option-2 ruling (2026-08-02T21:50Z, event ab504a3f): durable import-phase outcome on `ImportJob`; `leak_valid` + `recovery_detector` join algorithm identity; `AlgorithmIdentity`/`AnalysisRunMetadata` type split with explicit `primary_mode` at analysis invocation + `PRIMARY_MODE_MISMATCH` + total coverage-status precedence; waveform seam split into `fetch_waveform_window_raw` / `compute_waveform_window`; nullable `WindowResult.analysis_result_id` with per-window status; complete leak no-overlap branch (nearest-neighbor ≤5 s); typed/bounded channel lists + per-tool window caps; `get_breath_table` docstring ordering fix; `src/snore/parsers/register_all.py` added to PR-A's boundary. Supersedes v3.5: v3.6 folds Hayt's Appendix-A delta-2 (pass-2 F4'–F7' + MINORs 1–3) with Paul's editorial fixes. v3.5 superseded v3.4 after Thufir plan-review pass 2 (7 IMPORTANT + 4 MINOR findings accepted, thread 2b407054); v3.4 superseded v3.3 after pass 1 and Will's OAuth requirement (2026-08-02T20:16Z). Repo: `~/Development/SNORE` (main @ `1c2ed94`). v3.1 doctrine (tiered access, G1–G4) remains binding except where amended. -## Context +## Amended rulings -SNORE started life as OSCAR-MCP; commit `dd08225` deliberately removed a 762-line FastMCP -server (8 tools, 3 resources) with intent to re-add later. This plan re-adds MCP as a -**third thin presentation layer** over the existing async service layer — never a place -where analysis logic lives. +- **A1 (owner) + A5 (pass-1 F1 resolution):** Full programmatic analysis runs at import time. Lifecycle contract — **import is a committed phase; analysis is a post-commit second phase of the same command/job.** Import data is fully committed and ingested before analysis begins; analysis never holds or delays the import transaction. The CLI command and API job wait for both phases (with phase-distinct progress) rather than detaching an unowned background task — a detached `create_task()` has no owner across CLI loop shutdown / API worker lifetime. `--no-analyze` opts out. On-demand `snore analysis run` stays. *This is an interpretation of Will's "background job" wording: the data-ingestion guarantee is preserved; the process waits for the analysis phase with visible progress. Will may veto in favor of a durable detached job queue (more machinery).* +- **A2:** `get_nightly_summary` analysis-derived fields from latest `AnalysisResult`; absent → `null` + reason `analysis_not_run`. +- **A3:** Commit trailers: `Co-authored-by` + `Signed-off-by` for `Will Pfleger `. +- **A4:** `matplotlib` as main dependency (PR-B) + mypy override for `matplotlib.*`. +- **A6 (pass-1 F4 resolution — amends v3.1 "ISO 8601 with explicit offset"):** The DB deliberately stores device times as offset-free wall clock (models.py:16-26) — fabricating offsets would invent facts (violates G2) and make output host-timezone-dependent. Three-tier timestamp contract for all MCP output: (1) absolute audit instants (`UTCDateTime` columns) → ISO 8601 UTC with `Z`; (2) device/session wall-clock → offset-free ISO 8601 + explicit `timezone_status: "unknown"`; (3) positions within sessions (breaths, events, waveform windows) → numeric `offset_seconds` from session start. Non-UTC-host test (`TZ` env) proves identical source data yields identical MCP output. +- **A7 (owner, 2026-08-02T20:16Z): MCP OAuth is a hard requirement**, targeted at Claude iOS remote-MCP usage (enter server URL → browser OAuth consent → client stores tokens). Sequenced as **PR-C** (below), pending Will's confirmation. ---- +## Approach -## Design Doctrine +**Two PRs now + one auth follow-on**, parallel with disjoint boundaries, merged sequentially: -**Tiered data access — computed metrics primary, rendered PNG charts secondary, raw arrays -tertiary and tightly capped.** Progressive disclosure: overview → summary → events → breath -table → raw waveform. Raw data is never the entry point. +- **PR-A — the substrate** (Hayt): import-time post-commit analysis, `Breath` model, breath persistence, **and every reusable query/compute seam PR-B consumes** — not only `breath_service`. PR-B must stay a thin presentation layer (pass-1 F5): contextual events, nightly analysis aggregation + compliance, multi-channel detached waveform/render inputs, CA analysis all live in `services/**` as typed-DTO seams. Zero MCP knowledge; independently valuable. Merges first. +- **PR-B — the entire MCP layer** (Duncan): skeleton + all tools + resources + profiles. Tools are pure adapters: validate request → open scope → call service seam → enforce response limits → map typed DTO to MCP schema. Nothing else. +- **PR-C — MCP OAuth for hosted/Claude-iOS use** (owner TBD): follow-on after PR-B and after SNORE-multiuser Phases 1–2 (ActorContext + auth core) land. See "Auth" section. -- **Compute server-side, return compact JSON.** Units on every field. Timestamps ISO 8601 - with explicit offset. -- **Data-quality flags everywhere**: per-window/per-breath `leak_valid`, `mask_off`, - `ramp_active` so junk is excludable automatically. -- **Algorithm versioning in output** (`fl_algo: "v1.2"`) so epoch comparisons never - silently span algorithm changes. +**Service contract appendix (pass-1 F6):** the typed interface for every PR-A seam is written into this plan (Appendix A) **before implementation starts** — drafted by Hayt as owner, co-signed by Duncan as consumer, folded in by Paul, re-reviewed by Thufir in pass 2. Stage-2 tool work does not begin until the appendix is in the plan. -## Genericity Principles +## Verified integration points (for executors) -- **G1 — Profile-parameterized, neutral by default.** Clinical emphasis lives in named - profiles, not in tool design. No tool returns different data per profile. -- **G2 — Capability-honest.** Absent data is `null` + reason, never fabricated. -- **G3 — Stateless and scope-ready.** No module-global state; DB via lifespan-provided - session factory; explicit ranges/filters in every tool. -- **G4 — Vendor dispatch stays in the parser/service layer.** +- `BatchAnalysisCoordinator.submit` (services/analysis_facade.py:496): read blobs on loop → NumPy in `asyncio.to_thread` → write on loop, semaphore-capped. Reuse; do not build a second executor. +- `AnalysisService.store_result` (analysis/service.py:602) is **append-only**; latest-run selection is by `created_at` (facade:76-102,405-430); explicit latest/all deletion API exists (facade:309-363). These public semantics are preserved (see Breath versioning). +- `RawSessionBlobs`/`AnalysisInputs` (analysis/service.py:64,88) carry flow + machine events + SpO₂ + pulse ONLY — no leak or pressure channel. PR-A extends them (optional fields) for quality-flag derivation. +- CLI import: `cli/commands/import_data.py` loops sources → `asyncio.run(...)` per source. API import: `_run_import` worker thread + `api/import_jobs.py` registry with `ObserverChannel`. +- `_import_single_session` (database/importers.py:119-223) returns `(was_imported, day_id)`; extend to a typed outcome carrying `new_session.id` post-`flush()`; accumulate IDs only after each `begin_nested()` savepoint exits successfully; return per-chunk committed ID batches (importers.py:354-370, import_service.py:247-289). Force re-import returns the NEW session ID. No post-import re-query. +- `WaveformService.get_waveform_data` (waveform_service.py:76-133) closes its injected session after ONE channel — multi-channel windows need a new detached-DTO seam in PR-A. +- Fresh DBs: `Base.metadata.create_all` + alembic stamp (database/session.py:106-124); model-only `breaths` table appears on fresh DBs only; pre-existing DBs get capability-honest errors → drop + reimport. +- `session_scope()` uses module-global engine state (database/session.py:66-83) — there is no lifespan-injected factory today. See DB-access pattern below. +- Parser registration is explicit (`parsers/register_all.py:14-49`), not import-time automatic; parser metadata describes supported formats, not imported channels. +- Reference repos local: `~/Development/pagerduty-mcp-server` (`tool_error_boundary` server.py:42, `RESPONSE_SIZE_LIMIT` utils.py:15), `~/Development/JamBot` (`docs://schemas/{type}`). +- fastmcp 3.4.5 (Python 3.13 ok) verified in repo venv: in-memory client, direct `fastmcp.utilities.types.Image` returns, stdio + streamable-HTTP transports, `fastmcp.server.auth` with `OAuthProxy`/`oidc_proxy`/`jwt_issuer` + `google` provider (PR-C substrate). +- mypy strict everywhere. ---- +## PRs -## Locked Decisions +### PR-A — substrate: post-commit import analysis + breath persistence + all service seams — owner: @Hayt +**Branch:** `will/import-time-analysis` · **Merges first.** +**Boundary:** `src/snore/database/models.py`, `src/snore/database/importers.py`, `src/snore/analysis/**`, `src/snore/services/**`, `src/snore/api/**`, `src/snore/cli/commands/import_data.py`, `src/snore/parsers/register_all.py` (the `ensure_registered_parsers()` seam, Appendix A §15 — pass-3 MINOR), `tests/**` (non-MCP). **Do NOT touch:** `src/snore/mcp/**`, `src/snore/cli/__init__.py`, `src/snore/cli/commands/mcp.py`, `pyproject.toml`, `uv.lock`, `docs/mcp-server-plan.md` (PR-B's). -1. **Transport: stdio now, HTTP-ready by construction.** `snore mcp` (stdio). FastMCP - serves stdio and streamable-HTTP from the same tool definitions. -2. **Full analysis at import time as async background job.** Import commits first, - analysis follows without blocking ingestion. On-demand `snore analysis run` stays. -3. **No Alembic migrations — ever.** Fresh DBs get the right schema via `create_all`. - Pre-existing DBs: breath-backed tools return capability-honest error → drop + reimport. -4. **`matplotlib` as a main dependency** when `render_window` lands (Phase 2/PR-B Stage 3). -5. **Tools are `async def`** calling the async service layer natively. +Steps: +1. **`Breath` model** (model only — NO Alembic migration, ruling #4). Breaths are **immutable versioned children of an analysis run**: FK `analysis_result_id` (CASCADE), unique `(analysis_result_id, breath_number)`, index `(analysis_result_id, start_time)`; denormalized `session_id` FK permitted for query efficiency (uniqueness stays on the run). Fields: timing (start/end offsets, Ti, Te, Ttot, I:E, duty cycle), amplitude (peak flow, tidal volume, rolling RR), existing `flatness_index`, NEW `mid_insp_flattening`, flow class + confidence, `is_recovery_breath`, inferred trigger/cycle (`experimental` + confidence + device-applicability; non-ResMed → `confidence: null, reason: "unvalidated_device"`), quality flags `leak_valid`/`ramp_active`/`mask_off` (nullable + reason). **Re-analysis appends a new run + new children; prior runs and their breaths are NEVER deleted except via the existing explicit deletion API** (preserves latest/all-version semantics; cascade handles children). All latest-run queries select the newest `AnalysisResult` per session ordered by `(created_at DESC, id DESC)` — the `id` tie-breaker makes provenance deterministic when concurrent runs share timestamp precision (pass-2 MINOR); one test pins equal-`created_at` behavior. +2. **Atomic persistence:** parent `AnalysisResult` + breath children written in one transaction; children keyed after `flush()` assigns the parent ID. Test parent+child rollback on child-insert failure. +3. **Quality-flag data contract (pass-1 F3, executable per pass-2 F3'):** extend `RawSessionBlobs`/`AnalysisInputs` with an optional leak channel + session/device metadata for provenance. Per-flag executable derivations, each versioned + nullable with reason codes: + - `leak_valid` — source: the unified `leak` waveform (`WaveformType.LEAK_RATE`, L/min), sole source, no fallback channel. Threshold: breath is leak-valid iff mean leak over the breath's `[start, end)` interval `< 24.0 L/min` (the large-leak convention already used by the plan's `time-above-24` summary field), shipped as versioned constant `LEAK_VALID_ALGO = "v1"` with `LEAK_VALID_THRESHOLD_LPM = 24.0`. Alignment (complete branch, pass-3 IMPORTANT-6): leak samples are selected by timestamp overlap with the breath's `[start, end)` interval on the shared session-offset timebase. **Overlap exists → value = mean of the overlapping samples. No overlapping sample → nearest-neighbor: take the single leak sample nearest the breath midpoint; distance `<= 5 s` (max alignment gap — more than double the coarsest expected leak sampling interval) → use that sample's value; distance `> 5 s` → flag `null` + `channel_unaligned`.** No interpolation in v1 — any future interpolation must be separately specified and versioned. Absent channel → `null` + `channel_absent`. Boundary tests pin distance of exactly `5 s` (nearest-neighbor used) and just above `5 s` (`channel_unaligned`). + - `ramp_active` — **v1 ships `null` + `not_available` unconditionally.** No ramp-state waveform exists in the unified model (parsers/unified.py:45-59 — pressure channels only); settings only prove ramp is *enabled*. Any future pressure-trajectory heuristic must be separately specified and versioned before use — do not infer in v1. + - `mask_off` — **only** from a canonical mask-state signal/event; none is imported for any current source → `null` + `not_available`; never inferred from leak. + Correct the v3.1 claim: quality flags are NOT flow-channel-derivable. Tests: absent leak channel, leak-alignment-gap exceeded, differing sample rates, ramp-enabled-in-settings still yields `ramp_active=null`, vendor applicability. +4. Mid-insp flattening extractor + trigger/cycle heuristic module in `analysis/shared/`, versioned constants; `engine_versions_json` becomes the **nested** `{"identity": AlgorithmIdentity.model_dump(), "run": AnalysisRunMetadata.model_dump()}` shape (binding — pass-4 IMPORTANT-5; exact shape in Appendix A §14 note 5; legacy flat rows read as stale — fresh-DB/reimport already mandated, no conversion code), where identity carries `format_version`, `segmenter`, `fl_classifier`, `flattening`, `trigger_cycle`, **`leak_valid`, `recovery_detector`** — pass-3 IMPORTANT-2: every query-driving derived feature is stamped and compared, so bumping `LEAK_VALID_ALGO` or the recovery-marker algorithm marks old rows stale instead of leaving them silently `OK`. **Primary-mode selection is explicit at analysis invocation (pass-3 IMPORTANT-3, threaded end-to-end per pass-4 IMPORTANT-2):** a `primary_mode` parameter is added through **every** analysis invocation path, not only `compute_analysis`/`run_analysis`: `AnalysisFacade.run_analysis`, `AnalysisFacade.run_batch_analysis` (analysis_facade.py:432-488), `BatchAnalysisCoordinator.submit` (analysis_facade.py:521-530), the API request models `AnalysisRunRequest` + `BatchAnalysisRequest` (api/schemas.py:73-75, 96-103), and CLI `analysis run`. Semantics everywhere: defaults to `DEFAULT_MODE` when it is among the requested modes; when the requested modes exclude `DEFAULT_MODE` (the on-demand CLI/API accept caller-supplied mode sets today, routers/analysis.py:68,113), the caller MUST supply `primary_mode` explicitly — `ValueError` in service/CLI paths, `422` at the API boundary; supplied `primary_mode` must be a member of `modes`, validated at every entry point. Import-time default-mode analysis needs no flag. Recovery markers are persisted from the chosen primary mode's detector run only; `primary_mode` is stored as run metadata (Appendix A §1). Tests pin, for both single and batch invocation with modes excluding `aasm`: missing `primary_mode` rejected; explicit `primary_mode` succeeds. +5. **Private compute envelope (pass-2 F2'):** `compute_analysis` returns `AnalysisComputation(summary: AnalysisResult, breaths: list[ComputedBreath])` — a private envelope, NOT an extension of the public `AnalysisResult` DTO. `programmatic_result_json` stores only `summary` (unchanged shape/size; no breath duplication in JSON — `store_result()` at analysis/service.py:623 writes `model_dump()` wholesale, so breaths must never enter that DTO). The coordinator write phase persists `summary` as the parent row + breaths as children in one transaction. `run_analysis()` and the on-demand API keep returning the existing public `AnalysisResult` type. +6. **Import hook (A5):** typed savepoint-derived session-ID capture (see integration points); `run_batch_analysis` gains a `session_ids` filter. CLI: import phase commits + prints, then analysis phase with per-session timing + progress; `--no-analyze`. API lifecycle contract (pass-2 F1', durability per pass-3 IMPORTANT-1): same job, two phases; **the job stays `RUNNING` across both phases** — terminal states remain job-level only. Add a typed `JobPhase` enum (`IMPORT`, `ANALYSIS`) and a **non-terminal** `phase_complete` event (carrying the committed import result) delivered via `ObserverChannel` at the import→analysis transition; SSE observers stay connected through it. **Durability rule: `phase_complete` is a live milestone only, never the sole evidence of committed data** — `ObserverChannel` coalesces non-terminal messages and late observers receive only the terminal payload (import_jobs.py:97-108, 192-196), so phase outcomes are persisted on the `ImportJob` itself: after the import phase commits, the job retains `import_result` and `import_committed=True`, and the rule is **universal (pass-4 IMPORTANT-1): EVERY terminal payload produced after `import_committed=True` — final success, analysis-phase failure, AND cancellation — includes `import_committed` + the retained `import_result`** (an analysis failure OR a cancellation after a committed import must never hide the fact that data landed; `try_cancel()`/`_finish_cancelled()` currently synthesize a bare `{"message": "Cancelled"}` terminal, import_jobs.py:238-262, 292-310 — the cancellation terminal is rebuilt to carry the retained import outcome). `_finish()` is called **exactly once**: after the analysis phase completes, or immediately after import when `--no-analyze` applies. Cancellation honored in both phases. Tests pin: import-committed-before-analysis ordering, `phase_complete` is non-terminal (SSE observer survives it and receives analysis progress), **stalled observer (non-terminal coalescing) still sees `import_committed` in the terminal payload, late observer (attached post-terminal) sees `import_committed` + `import_result`, analysis failure after import commit reports both the error and the committed import result, cancel during the analysis phase → late observer still sees `import_committed` + `import_result` in the cancellation terminal**, cancellation in each phase, `--no-analyze` single-finish. +7. **Service seams for PR-B (pass-1 F5), all typed DTOs per Appendix A:** + - `services/breath_service.py` — windowed breath fetch, criteria window search, epoch × distribution stats, per-session `analysis_status` + algo-version metadata. + - Contextual events seam (extend `EventService`) — per-event pressure/leak at event, MV prior 120 s, minutes since session start. + - Nightly analysis aggregation seam — latest-run RERA/FL fields + usage-compliance calc (`compliance_pct`, `days_compliant`, threshold default 4 h) in the service layer (v3.1 original ruling), consumed by `get_nightly_summary`. + - Multi-channel detached waveform-window seam — render inputs + raw windows (fixes single-channel session-closing limitation). + - CA-analysis service — per-CA MV slope, PS delivered, stability index; night-level periodic-breathing % + MV rolling variance. ---- +**Acceptance:** `just check` + `just test` + `just web-check` green; fresh-DB import populates `breaths`; two consecutive re-analyses yield two runs with correct latest selection and intact history; explicit deletion cascades; import commits before analysis begins (observable ordering test); `--no-analyze`; per-session timing; pre-existing-DB missing-table error is actionable; every seam unit-tested against fixture data independent of MCP; non-UTC-host determinism test (A6). -## 2-PR Structure +### PR-B — complete MCP layer (thin adapters only) — owner: @Duncan +**Branch:** `will/mcp-skeleton` (Stage 1 at `45153ab`; rework applied at `30c43e6`, unpushed pending gate) · **Merges second.** +**Boundary:** `src/snore/mcp/**`, `src/snore/cli/commands/mcp.py`, `src/snore/cli/__init__.py` (register only), `pyproject.toml`, **`uv.lock`**, `docs/mcp-server-plan.md`, MCP test files. **Do NOT touch:** `src/snore/database/**`, `src/snore/analysis/**`, `src/snore/services/**`, `src/snore/api/**` (PR-A's). -### PR-A — Substrate (@Hayt, merges first) -`Breath` model, import-time background analysis, breath persistence, `breath_service.py`. -Zero MCP knowledge. Branch: `will/import-time-analysis`. +**Doctrine (pass-1 F5):** every tool = validate → open scope → call PR-A seam → size-guard → map DTO to MCP schema. No domain computation in `mcp/**`. -**Boundary:** `database/models.py`, `analysis/**`, `services/**` (incl. new -`breath_service.py`), `api/**`, `cli/commands/import_data.py`, tests. +Stage 1 rework (applied at `30c43e6` — 1218 tests passing, `just check` green): +1. **Timestamp contract (A6)** across `schemas.py` and all tools; add `timezone_status`; positions as `offset_seconds`; non-UTC-host test. +2. **DB-access honesty (pass-1 M2):** tools deliberately use the existing module-global `session_scope()` behind ONE small scope-provider seam in `mcp/` (so PR-C can swap in actor-scoped sessions without tool rewrites — G3); plan/docs no longer claim lifespan factory injection; lifespan teardown calls `cleanup_database()` in `finally`. +3. **`docs://capabilities` (pass-1 M4):** idempotent parser-registration call (Stage 1 used `register_all_parsers()`; swaps to PR-A's `ensure_registered_parsers()` seam — Appendix A §15 — at seam adoption); channels/settings derived from DB rows; parser metadata as supported-parser context only. Cold-process test (no prior import command). +4. Keep: skeleton, profiles (G1), `tool_error_boundary`, `RESPONSE_SIZE_LIMIT`, `docs://tools`, `docs://schemas/{type}`, CLI entry. -### PR-B — Complete MCP layer (@Duncan, merges second) -`src/snore/mcp/` package, all ~10 tools across Stages 1–3, resources, profiles, CLI entry, -`fastmcp`/`matplotlib` deps, this doc. Branch: `will/mcp-server`. +Stage 1 seam adoption (after PR-A merges; rebase): `get_events` context and `get_nightly_summary` aggregation/compliance move from MCP-layer computation to PR-A seam calls. -**Boundary:** `src/snore/mcp/**`, `cli/commands/mcp.py`, `cli/__init__.py` (register only), -`pyproject.toml`, `docs/mcp-server-plan.md`. +Stage 2 — tuning tools (after Appendix A + PR-A merge): `get_breath_table` (≤15 min raw, binned beyond), `find_windows` (criteria enum per appendix), `compare_epochs` (leak-valid only; refuses cross-algo-version comparison); missing/empty `breaths` → capability-honest error. -**Staged internally:** -- Stage 1 (no PR-A dependency): skeleton + free tools (`get_data_overview`, - `get_settings_timeline`, `get_nightly_summary`, `get_events`) + resources + profiles. -- Stage 2 (after PR-A merges + rebase): `get_breath_table`, `find_windows`, - `compare_epochs` (via `breath_service`). -- Stage 3 (same PR): `render_window` (matplotlib), `get_waveform` (LTTB), - `get_ca_analysis`. +Stage 3 — vision + CA: `render_window` — matplotlib Agg PNG returned as **`fastmcp.utilities.types.Image(data=..., format="png")`** (pass-1 M3), never a path or bytes-in-JSON; test MIME/type + payload signature via in-memory client; window ≤15 min (`window_cap_seconds=900`, Appendix A §9). `get_waveform` (LTTB, ≤2 min even with LTTB — `window_cap_seconds=120`, §9 — ≤1000 pts/channel). `get_ca_analysis` (adapter over PR-A's CA seam). ---- +**Acceptance:** gates green; in-memory client tests for every tool + resource (happy path, empty DB, absent-channel nulls, missing-breaths-table, oversize guard, cold-process capabilities, non-UTC host); PNG content-type + vision sanity check documented in PR; manual stdio smoke test of the full success-criteria conversation documented in PR. -## Tools (current — Stage 1) +### PR-C — MCP OAuth (Claude iOS remote use) — follow-on, owner TBD +**Requirement (Will, locked):** Claude iOS connects to a remote MCP URL → immediate browser OAuth consent → client stores tokens. This means streamable-HTTP transport + OAuth 2.0 authorization-code + PKCE + dynamic client registration on the server side. +**Dependency chain:** per-user scoping and identity live in SNORE-multiuser (`PLANS/SNORE_MULTIUSER_PLAN.md` rev 3: `User`/`ActorContext`/Google OAuth/invites — its own review loop, channel SNORE-multiuser). MCP HTTP auth = validated token → `ActorContext` at the request boundary — exactly the G3 seam PR-B's scope provider isolates. Building MCP OAuth before ActorContext exists would invent a parallel identity system. +**Substrate verified:** fastmcp 3.4.5 ships `fastmcp.server.auth` (`OAuthProxy`, `oidc_proxy`, `jwt_issuer`, redirect validation, `google` provider) — supports fronting an upstream IdP or SNORE's own auth. Design deferred to PR-C planning; PR-B's only obligations now: transport parameter plumbed, zero ambient per-user state, scope-provider seam. +**Sequencing (confirmed by Will, 2026-08-02T20:32Z):** PR-C planning starts after SNORE-multiuser Phases 1–2 (schema + ActorContext, backend auth core) merge and PR-B lands — not the full multiuser track (its frontend/demo phases are irrelevant to token→ActorContext). PR-A/PR-B are NOT blocked; stdio MCP needs no auth. Claude iOS is the consumer that waits. Deployment (public URL via cloudflared) follows the homelab pattern in the multiuser track. -### get_data_overview -Cold-start orientation. Call first. Returns devices, date ranges, available waveform -channels, event types, analysis status. +## Cross-cutting rules -### get_settings_timeline(start, end, device_id?) -Therapy settings epochs. Generic `RX_KEYS` only. Changed keys flagged per epoch. +- Worktrees off main, `will/`; PRs against `wpfleger96/SNORE` main; conventional commits; trailers per A3. +- `just check` / `just test` / `just web-check` green before PR; mypy strict, no unjustified `type: ignore`. +- Thufir plan review: passes 1–3 (budget) + pass 4 (Will-authorized, option 2) complete; per Will's option-1 ruling (2026-08-02T22:22Z, event d94b59fe) implementation is released at v3.8 with NO further plan-review pass — the five pass-4 items are named test obligations in the PR code-review loops. Then both PRs (separate loops, 3-pass budgets). +- PR descriptions cross-reference (related, not stacked). -### get_nightly_summary(start, end, device_id?, page, page_size, compliance_threshold_hours) -Per-night summary, paginated. Analysis-derived fields (RERA index, RDI) are `null` + -`analysis_not_run` when analysis absent. Compliance block in range mode. +## Tradeoffs Considered -### get_events(date, types?, min_duration?, include_context) -Respiratory events for a session date. Inline context: minutes since session start. +| Option | Pros | Cons | Recommended | +|--------|------|------|-------------| +| Post-commit analysis phase awaited by command/job (A5) | Owned lifecycle: progress, cancellation, error visibility; import-commit guarantee kept | CLI process waits for analysis (opt-out via `--no-analyze`) | ✓ | +| Detached background task / durable job queue | CLI returns instantly | No owner across CLI loop close / API worker lifetime; or a whole new queue subsystem | | +| Breaths as children of immutable analysis runs | Preserves existing multi-version + deletion semantics; provenance free; no delete+reinsert logic | Old runs' breath rows occupy space until explicitly deleted (~10k rows/night/run) | ✓ | +| Delete old run to cascade breaths on re-analysis | One live run per session | Destroys history; changes public latest/all-version API semantics | | +| 2 PRs, PR-A owns ALL seams | Thin-presentation doctrine holds; seams reusable by API/CLI; PR-B reviewable as one layer | Free-tool seam adoption waits for PR-A merge (mitigated: contract appendix lets Duncan code against types) | ✓ | +| PR-B computes its own context/aggregation | More parallel | Domain logic in presentation layer; unavailable to API/CLI; violates doctrine | | +| PR-C after multiuser ActorContext | One identity system; MCP auth = token→ActorContext mapping | Claude iOS use waits for multiuser Phases 1–2 | ✓ | +| MCP-local OAuth now | iOS sooner | Parallel identity system, thrown away when ActorContext lands | | ---- +## Open Questions -## Resources +- ~~PR-C sequencing~~ — confirmed by Will 2026-08-02T20:32Z (after multiuser Phases 1–2 + PR-B; PR-A/PR-B unblocked). +- **Will (veto option):** A5's "post-commit phase awaited by the command/job" interpretation of the background-analysis ruling — proceeding with the recommendation unless vetoed. +- Import-time analysis modes: default mode set; `--all-modes` stays on-demand (assumed). -- `docs://tools` — complete tool reference (this package's `docs/tools.md`) -- `docs://schemas/{type}` — JSON schema for any named Pydantic response type -- `docs://capabilities` — dynamically generated from imported data +## Success Criteria ---- +Unchanged from v3.1 (stdio conversation: overview → worst-FL windows → breath table → PNG → epoch comparison, generic contracts, `clinical_profile: uars`), plus: fresh `snore import` commits data before analysis begins and reports phase-distinct progress; `get_nightly_summary` degrades to `null` + `analysis_not_run`; identical MCP output on UTC and non-UTC hosts; (PR-C, later) Claude iOS completes browser OAuth against the hosted server and reaches the same tools with user-scoped data. -## Clinical Profiles +## Appendix A — Service contract (typed seams) -Profiles shape the INSTRUCTIONS resource and priority hints only (G1). No tool returns -different data per profile. Available: `neutral` (default), `uars`, `osa`, `csa`. +*Drafted by Hayt (owner, 2026-08-02T20:35Z), amended per Paul's review (20:38Z → delta-1 20:43Z), folded by Paul with three editorial corrections (WaveformWindowRequest session_id, calendar-night compliance denominator, `Waveform.waveform_type` column name). **Co-signed by Duncan (consumer) 2026-08-02T20:49Z — no conflicts.** Delta-2 (Hayt, 21:25Z — pass-2 F4'–F7' + MINORs 1–3) folded by Paul with editorial fixes: `trigger_cycle_reason` field replaces delta-2's vague "quality-flag slot" pairing wording; `find_windows` validation moved off the model (the delta's `model_validator` was a no-op) into the service; public `list_parsers()` (not `_parsers`) in the `ensure_registered_parsers()` sketch; `day_status`/`session_coverage` propagated to `FindWindowsResult` and `CaAnalysisResult` with explicit MIXED_VERSION refusal rules. **v3.7 (Paul, per Will's option-2 ruling): the nine Thufir pass-3 corrections folded using Thufir's fix language — §1 `AlgorithmIdentity`/`AnalysisRunMetadata` split + `CROSS_VERSION_REFUSAL_KEYS` + total `DayAnalysisStatus` precedence; §6 nullable window provenance; §7 primary-mode guard; §9 fetch/compute split + `WaveformChannelName` + per-tool caps; §10 policy revisions; §13 docstring alignment.** **v3.8 (Paul, per Will's option-1 ruling, event d94b59fe): the five Thufir pass-4 mechanical completions (event cd83618b) folded — step 6 universal terminal-payload durability (incl. cancellation); step 4 `primary_mode` end-to-end threading + nested `engine_versions_json` shape; §7 epoch contribution by data + nullable epoch identity; §9 `RawWaveformChannel.sample_count`; §10/§13 zero-current-PARTIAL wording sweep; §14 note 5 nested-shape binding.** All types live in `src/snore/services/breath_service.py`. Pydantic v2. Grounded against main @ `1c2ed94`.* ---- +### Timestamp contract (A6 — three tiers, used throughout) -## Implementation Conventions +- **Tier 1** absolute audit instants → UTC ISO 8601 with `Z` (e.g. `AnalysisResult.created_at`) +- **Tier 2** device wall-clock → naive ISO 8601 + `timezone_status: "unknown"` (`Session.start_time`, `Event.start_time`) +- **Tier 3** in-session positions → `offset_seconds: float` from the anchoring session's `start_time` -- `fastmcp>=3` standalone (NOT `mcp[cli]`) -- `tool_error_boundary` on every tool -- `RESPONSE_SIZE_LIMIT` guard (500 KB); returns narrow-your-query guidance -- `docs://tools` resource + REQUIRED-READING preamble -- Lifespan resolves DB via `DatabaseTarget.from_env_and_flags` -- `session_scope()` per tool call — no module-global state +No tier may fabricate a UTC offset for device wall-clock columns. Every DTO carrying `offset_seconds` also carries its anchoring session's tier-2 wall-clock start (`session_start_wall_clock` + `timezone_status`) so offsets are client-interpretable. ---- +### 1. Shared building blocks -## Success Criteria +```python +from __future__ import annotations + +from datetime import date, datetime +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + + +class TimezoneStatus(StrEnum): + UNKNOWN = "unknown" # device wall-clock, no source TZ + UTC = "utc" # audit instants only + + +class NullReason(StrEnum): + """Reason codes for null fields — G2 capability-honest contract.""" + + ANALYSIS_NOT_RUN = "analysis_not_run" + CHANNEL_ABSENT = "channel_absent" # waveform row not in DB + CHANNEL_UNALIGNED = "channel_unaligned" # timestamps don't align + UNVALIDATED_DEVICE = "unvalidated_device" # heuristic not tuned for vendor + NO_DATA_IN_RANGE = "no_data_in_range" + ALGO_VERSION_MISMATCH = "algo_version_mismatch" # cross-version comparison refused + PRIMARY_MODE_MISMATCH = ( + "primary_mode_mismatch" # RERA/recovery aggregation refused: + ) + # contributing runs differ in primary_mode + TABLE_MISSING = "table_missing" # breaths table absent (pre-existing DB) + ANALYSIS_STALE = "analysis_stale" # engine_versions changed since last run + NOT_AVAILABLE = "not_available" # data exists but can't be derived + RX_CHANGED_WITHIN_EPOCH = "rx_changed_within_epoch" # epoch RX-homogeneity guard + + +class AnalysisStatus(StrEnum): + NOT_RUN = "not_run" # no AnalysisResult row for this session + STALE_VERSION = "stale" # AnalysisResult exists but engine_versions_json differs + # from current algo constants + OK = "ok" # latest run matches current versions + + +class DayAnalysisStatus(StrEnum): + """ + Day-level coverage status for multi-session aggregation (pass-2 F4'; + TOTAL precedence rule per pass-3 IMPORTANT-3 — evaluate in order, first match wins, + so every day maps to exactly one state): + + 1. MIXED_VERSION — ≥2 OK sessions differ on any cross-version refusal key + (CROSS_VERSION_REFUSAL_KEYS, §1) → FL aggregation refused for this day. + 2. OK — all eligible sessions current (OK) with identical identity. + 3. NOT_RUN — no session in this day has any AnalysisResult. + 4. STALE — every session has results, all stale-version. + 5. PARTIAL — CATCH-ALL heterogeneous coverage: any other mix (current+missing, + current+stale, stale+not-run with no current session, ...). + """ + + OK = "ok" + PARTIAL = "partial" + MIXED_VERSION = "mixed_version" + NOT_RUN = "not_run" + STALE = "stale" + + +class AlgorithmIdentity(BaseModel): + """ + Comparable algorithm identity (pass-3 IMPORTANT-2/-3: identity is its own type, + separate from run metadata — not comments on a mixed model). Staleness detection + (_current_algorithm_identity(), §13) compares ALL of these fields and ONLY these. + Every query-driving derived feature is stamped: bumping any version marks old + rows STALE_VERSION instead of leaving them silently OK. + """ + + format_version: int # currently 2; bump to 3 when Breath rows land + segmenter: str # e.g. "v1.0" + fl_classifier: str # e.g. "v1.0" + flattening: str # covers both flatness_index and mid_insp_flattening + trigger_cycle: str # always present, e.g. "v1.0-experimental"; + # per-breath applicability lives in Breath rows + leak_valid: str # LEAK_VALID_ALGO — drives worst-window eligibility and + # every leak-valid epoch distribution (pass-3 IMPORTANT-2) + recovery_detector: str # recovery-marker algorithm — drives persisted + # is_recovery_breath rows (pass-3 IMPORTANT-2) + + +# ONE cross-version refusal definition, shared by the day-level MIXED_VERSION check +# and the epoch guard (§7) — pass-3 IMPORTANT-2 "same identity definition" requirement. +# All identity fields EXCEPT trigger_cycle: trigger/cycle is experimental annotation; +# its version differences are data provenance, not algo incompatibility (pass-2 ruling). +CROSS_VERSION_REFUSAL_KEYS: tuple[str, ...] = ( + "format_version", + "segmenter", + "fl_classifier", + "flattening", + "leak_valid", + "recovery_detector", +) + + +class AnalysisRunMetadata(BaseModel): + """Descriptive run metadata — NEVER part of staleness or cross-version refusal.""" + + primary_mode: str # explicit at analysis invocation (PR-A step 4): defaults + # to DEFAULT_MODE ("aasm", analysis/modes/config.py:95) + # when included in the requested modes; otherwise the + # caller MUST supply it (ValueError). The mode whose + # recovery markers were persisted (§10 mode policy). + modes: list[str] # all detection modes included in this run + + +class AlgoVersions(BaseModel): + """ + Per-run contents of AnalysisResult.engine_versions_json (extended by PR-A). + Composition of the two separate types above — staleness compares .identity + ONLY, structurally rather than by convention (pass-3 IMPORTANT-3). + """ + + identity: AlgorithmIdentity + run: AnalysisRunMetadata + + +class SessionCoverage(BaseModel): + """ + Per-session analysis coverage entry (pass-2 F4'). Carries the FULL per-run + AlgoVersions (identity + run metadata) so per-session provenance — including + primary_mode — is preserved even when day-level aggregation refuses + (pass-3 IMPORTANT-3). + """ + + session_id: int + analysis_status: AnalysisStatus # per-session: OK | STALE_VERSION | NOT_RUN + algo_versions: AlgoVersions | None +``` + +### 2. `BreathQueryRange` — the universal range selector + +```python +class BreathQueryRange(BaseModel): + """ + Identifies a contiguous waveform window within a therapy day. + + Device disambiguation: if device_id is None and the day has sessions + from exactly one device, that device is used; if multiple devices + exist, device_id is required (ValueError lists available IDs). + + session_id semantics: + - None + exactly one session that day → that session is used. + - None + multiple sessions → MultiSessionAmbiguityError. Never silently picks one. + - Non-None → offsets relative to that session's start_time; ValueError if the + session_id doesn't belong to the requested therapy_date + device. + """ + + therapy_date: date + device_id: int | None = None # None = single-device auto-select + session_id: int | None = None # required when day has >1 session + offset_start: float = Field(ge=0.0) + offset_end: float = Field(gt=0.0) + + # Pagination / binning for breath fetch + page: int = Field(default=1, ge=1) + page_size: int = Field(default=500, ge=1, le=2000) + bin_minutes: float | None = Field(default=None, ge=1.0) + + @model_validator(mode="after") + def validate_window(self) -> "BreathQueryRange": + if self.offset_end <= self.offset_start: + raise ValueError("offset_end must be > offset_start") + window_minutes = (self.offset_end - self.offset_start) / 60 + if self.bin_minutes is None and window_minutes > 15: + raise ValueError( + f"Raw window {window_minutes:.1f} min exceeds 15-min cap; " + "set bin_minutes to aggregate" + ) + return self + + +class SessionSummary(BaseModel): + session_id: int + start_wall_clock: datetime # naive — tier-2 device wall-clock + timezone_status: TimezoneStatus = TimezoneStatus.UNKNOWN + duration_seconds: float + + +class MultiSessionAmbiguityError(Exception): + """ + Raised when session_id is required but not supplied for a multi-session day. + PR-B catches this and converts it to a structured MCP error response listing + the sessions so the caller can re-issue with session_id set. + """ + + def __init__( + self, therapy_date: date, device_id: int, sessions: list[SessionSummary] + ) -> None: + self.therapy_date = therapy_date + self.device_id = device_id + self.sessions = sessions + super().__init__( + f"Multiple sessions on {therapy_date}: pass session_id to disambiguate" + ) +``` + +### 3. `Breath` ORM model (column summary) + +```python +# src/snore/database/models.py addition (PR-A step 1) +# Unique: (analysis_result_id, breath_number) +# Index: (analysis_result_id, start_offset_seconds) +# +# analysis_result_id FK → analysis_results.id CASCADE DELETE +# session_id FK → sessions.id CASCADE DELETE (denorm for fast join) +# breath_number int sequential within session +# start_offset_seconds float seconds from Session.start_time +# end_offset_seconds float +# ti / te / ttot float breath timing (s) +# ie_ratio float +# duty_cycle float Ti/Ttot +# peak_insp_flow float L/min +# peak_exp_flow float L/min +# tidal_volume float mL +# flatness_index float time>80%-peak (existing ShapeFeatures field) +# mid_insp_flattening float mid-insp flow / peak (NEW) +# flow_class int 1–7 +# flow_class_confidence float 0–1 +# is_recovery_breath bool from the primary mode's detector run only (§10 mode policy) +# trigger_type str | None TriggerType values (experimental) +# cycle_type str | None CycleType values (experimental) +# trigger_cycle_confidence float | None +# trigger_cycle_applicability str | None TriggerCycleApplicability values +# leak_valid bool | None + leak_valid_reason (NullReason) +# ramp_active bool | None + ramp_active_reason +# mask_off bool | None always None unless canonical mask signal exists +# mask_off_reason str | None NullReason.NOT_AVAILABLE when None +# +# NOTE: trigger_cycle_experimental is NOT a column — it is a serialized DTO constant +# (Literal[True], §4). trigger_cycle_reason is likewise DTO-derived from +# trigger_cycle_applicability, not stored. +``` + +### 4. `BreathRow` — DTO for a single persisted breath + +```python +class TriggerType(StrEnum): + NORMAL = "normal" + PREMATURE = "premature" + DELAYED = "delayed" + + +class CycleType(StrEnum): + NORMAL = "normal" + PREMATURE = "premature" + + +class TriggerCycleApplicability(StrEnum): + VALIDATED = "validated" + UNVALIDATED_DEVICE = "unvalidated_device" + + +class BreathRow(BaseModel): + """One row from the breaths table — matches the ORM column set above.""" + + analysis_result_id: int + session_id: int + breath_number: int + + # Anchor (tier-2) + positions (tier-3) + session_start_wall_clock: datetime # naive — tier-2 + timezone_status: TimezoneStatus = TimezoneStatus.UNKNOWN + start_offset_seconds: float + end_offset_seconds: float + + # Timing + ti: float + te: float + ttot: float + ie_ratio: float + duty_cycle: float + + # Amplitude + peak_insp_flow: float # L/min + peak_exp_flow: float # L/min + tidal_volume: float # mL + + # Flow limitation features (both versioned via AlgoVersions) + flatness_index: float # time>80%-peak (0–1) + mid_insp_flattening: float # mid-insp flow / peak (0–1) + + # Classification + flow_class: int # 1–7 + flow_class_confidence: float # 0–1 + is_recovery_breath: bool # from the primary mode's detector run only (§10) + + # Trigger/cycle heuristic (experimental) + trigger_type: TriggerType | None + cycle_type: CycleType | None + trigger_cycle_confidence: float | None + trigger_cycle_experimental: Literal[True] = True + # Serialized Pydantic field, NOT ClassVar — ClassVar attributes are excluded + # from model_dump() and model_json_schema() (verified empirically, pass-2 F7'), + # which would silently strip the mandatory experimental marker from every + # MCP response and docs://schemas. Not a DB column (§3 note stands). + trigger_cycle_applicability: TriggerCycleApplicability | None + trigger_cycle_reason: NullReason | None + # Pairing rule (F7'): applicability=UNVALIDATED_DEVICE ⇒ trigger_type, + # cycle_type, trigger_cycle_confidence all None and + # trigger_cycle_reason=NullReason.UNVALIDATED_DEVICE. + # applicability=VALIDATED ⇒ trigger_cycle_reason=None. + + # Quality flags + leak_valid: bool | None + leak_valid_reason: NullReason | None + ramp_active: bool | None + ramp_active_reason: NullReason | None + mask_off: bool | None # always None until canonical signal exists + mask_off_reason: NullReason | None # NullReason.NOT_AVAILABLE +``` + +### 5. `BreathPage` — paginated or binned fetch result + +```python +class BreathBin(BaseModel): + """Aggregated metrics for one time bin (a bin is always within one session).""" + + session_start_wall_clock: datetime # naive — tier-2 anchor + timezone_status: TimezoneStatus = TimezoneStatus.UNKNOWN + bin_start_offset: float # seconds from session start + bin_end_offset: float + breath_count: int + flatness_index_median: float | None + mid_insp_flattening_median: float | None + flow_class_mode: int | None # most common class in bin + tidal_volume_median: float | None # mL + ie_ratio_median: float | None + leak_valid_fraction: float | None # fraction of breaths with leak_valid=True + analysis_status: AnalysisStatus + + +class BreathPage(BaseModel): + """Result of get_breath_table().""" + + query: BreathQueryRange + analysis_status: AnalysisStatus + algo_versions: AlgoVersions | None # None when status != OK + null_reason: NullReason | None # set when status != OK + is_binned: bool + total_breaths: int # matching the window (pre-pagination) + page: int + page_size: int + # Exactly one of rows/bins is populated: + rows: list[BreathRow] = Field(default_factory=list) # raw rows (is_binned=False) + bins: list[BreathBin] = Field(default_factory=list) # aggregated (is_binned=True) + # Ordering: ascending (session_id, breath_number) — deterministic. + # Tie-breaking: breath_number unique within analysis_result_id; no ties possible. +``` + +### 6. `WindowCriterion` — find_windows criteria enum + +```python +class WindowCriterion(StrEnum): + WORST_FLATTENING_LEAK_VALID = "worst_flattening_leak_valid" + # N windows ranked by worst (highest) mid_insp_flattening among breaths with + # leak_valid=True. Excludes leak_valid IS NULL unless include_unknown_leak=True. + + CA_CENTERED = "ca_centered" + # N windows centered on CA events (event_type='CA'), ±context_seconds around CA start. + + FL_RUN_ENDING_IN_RECOVERY = "fl_run_ending_in_recovery" + # RERA-proxy: runs of ≥min_fl_run_length consecutive flow-limited breaths + # (flow_class >= fl_class_threshold) ending with is_recovery_breath=True. + # Window spans first FL breath → recovery breath end. + + +class WindowCriterionOptions(BaseModel): + """ + Criterion-specific options (defaults documented per field). + + Criterion-irrelevant option validation happens in BreathService.find_windows() + — NOT on this model — because the bound criterion is a find_windows() argument, + not a field here. The service raises ValueError naming the offending fields when + any option irrelevant to the bound criterion differs from its default (rejected, + not ignored). + """ + + # WORST_FLATTENING_LEAK_VALID: + include_unknown_leak: bool = False + flattening_threshold: float | None = None # None = top-N regardless of value + min_window_breaths: int = 3 # minimum breaths to form a reportable window + context_breaths_before: int = Field(default=3, ge=0) # breaths prepended to anchor + context_breaths_after: int = Field(default=3, ge=0) # breaths appended to anchor + # CA_CENTERED: + context_seconds: float = 120.0 + # FL_RUN_ENDING_IN_RECOVERY: + min_fl_run_length: int = 2 + fl_class_threshold: int = 4 + + +class WindowResult(BaseModel): + """One found window.""" + + criterion: WindowCriterion + session_id: int + session_start_wall_clock: datetime # naive — tier-2 anchor + timezone_status: TimezoneStatus = TimezoneStatus.UNKNOWN + window_start_offset: float + window_end_offset: float + reason_summary: str # e.g. "fl_index=0.71, 5 FL breaths" + worst_mid_insp_flattening: float | None # WORST_FLATTENING criterion + fl_run_length: int | None # FL_RUN_ENDING_IN_RECOVERY + anchor_event_offset: float | None # CA_CENTERED: CA start offset + # Per-window analysis provenance (pass-3 IMPORTANT-5): CA_CENTERED windows are + # anchored on machine Event rows and remain valid on sessions with NO analysis + # run (capability-honest: the machine-event window is returned with null + # analysis provenance; waveform rendering needs no analysis row). + analysis_result_id: int | None # which analysis run produced these breaths; + # None when the window's session has no run + analysis_status: AnalysisStatus # the window's session status: + # NOT_RUN ⇒ analysis_result_id=None + analysis_reason: NullReason | None # ANALYSIS_NOT_RUN when analysis_result_id=None + + +class FindWindowsResult(BaseModel): + """Result of find_windows().""" + + query_date: date + device_id: int + criterion: WindowCriterion + day_status: DayAnalysisStatus # coverage policy, §10 (pass-2 F4') + session_coverage: list[SessionCoverage] = Field(default_factory=list) + algorithm_identity: ( + AlgorithmIdentity | None + ) # uniform identity of contributing runs; + # only identity is guaranteed uniform at day + # level — full per-run AlgoVersions (incl. + # run metadata) lives in session_coverage + # (pass-3 IMPORTANT-3 type separation) + null_reason: NullReason | None + primary_mode: str | None # mode whose recovery markers were used + # (FL_RUN_ENDING_IN_RECOVERY); None when the + # criterion does not use recovery markers + windows: list[WindowResult] # ordered by severity (worst first) + # MIXED_VERSION rule: FL-ranked criteria (WORST_FLATTENING_LEAK_VALID, + # FL_RUN_ENDING_IN_RECOVERY) refuse on day_status=MIXED_VERSION — ranking + # breaths across CROSS_VERSION_REFUSAL_KEYS differences is a cross-version + # comparison → windows=[], null_reason=ALGO_VERSION_MISMATCH. + # Primary-mode rule (pass-3 IMPORTANT-3): FL_RUN_ENDING_IN_RECOVERY additionally + # requires a uniform primary_mode across the day's contributing runs — recovery + # markers from different primary modes are not comparable; mixed → + # windows=[], null_reason=PRIMARY_MODE_MISMATCH (per-run metadata preserved + # in session_coverage). CA_CENTERED proceeds on ANY day_status including + # NOT_RUN (event-anchored; per-window analysis provenance is nullable, §6). + # Dedup: windows overlapping >50% of the shorter window are merged; the + # highest-severity window in the merged group is kept. + # Ordering: descending by worst_mid_insp_flattening or fl_run_length, + # ascending window_start_offset for ties. +``` + +**Window construction rule — `WORST_FLATTENING_LEAK_VALID` (pass-2 F5', binding):** + +1. Collect eligible anchor breaths: `leak_valid=True` (`include_unknown_leak=True` extends eligibility to `leak_valid IS NULL`). Apply `flattening_threshold` if set. +2. Rank anchors descending by `mid_insp_flattening`. +3. For each anchor (highest first), form a candidate window: + a. Take the `context_breaths_before` breaths immediately before the anchor and `context_breaths_after` immediately after, all from the same session. + b. Clamp to session bounds: fewer preceding breaths than requested → start at the session's first breath; same at the end. + c. Window bounds: `start_offset` = first breath's `start_offset_seconds`, `end_offset` = last breath's `end_offset_seconds`. + d. The anchor is always included; discard the candidate if total breaths in the window < `min_window_breaths`. +4. Deduplicate: candidates overlapping >50% of the shorter window merge; keep the member with the highest anchor `mid_insp_flattening`. +5. Return top-N after dedup, ordered descending `mid_insp_flattening`, ascending `window_start_offset` for ties. + +Criterion-irrelevant options (here: `context_seconds`, `min_fl_run_length`, `fl_class_threshold`) that differ from defaults → `ValueError` naming the fields (see `WindowCriterionOptions` docstring). The same rejection rule applies symmetrically to the other two criteria. + +### 7. Epoch DTOs — compare_epochs + +```python +class EpochRequest(BaseModel): + """One settings epoch for comparison.""" + + label: str # e.g. "before" / "after" + date_start: date + date_end: date + device_id: int | None = None # None = single-device auto-select + # Algo-version guard: all nights in this epoch must share the same + # AlgorithmIdentity values for every CROSS_VERSION_REFUSAL_KEYS field (§1 — + # the ONE shared refusal definition). Any night differing → ValueError with + # offending dates; cross-epoch mismatch → NullReason.ALGO_VERSION_MISMATCH. + # Primary-mode guard (pass-3 IMPORTANT-3): RERA-derived fields (rera_proxy_count) + # require a uniform primary_mode across all contributing runs in the epoch; + # mixed → rera_proxy_count=None + rera_reason=PRIMARY_MODE_MISMATCH (FL + # distributions still returned — FL needs identity uniformity only, not mode). + # RX-homogeneity guard: before computing distributions, validate all RX_KEYS + # values uniform across every night in this epoch (via RxTracker period query). + # If any key changed mid-epoch → refuse with null_reason=RX_CHANGED_WITHIN_EPOCH + # and populate rx_violations (label, changed keys, change dates); the caller + # splits the range at the change boundary and re-issues. + + +class DistributionMetric(StrEnum): + MID_INSP_FLATTENING = "mid_insp_flattening" + FLATNESS_INDEX = "flatness_index" + TIDAL_VOLUME_ML = "tidal_volume_ml" + IE_RATIO = "ie_ratio" + + +class DistributionStats(BaseModel): + """Descriptive stats for one metric over one epoch (leak-valid breaths only).""" + + median: float | None + iqr: float | None # p75 - p25 + p95: float | None + n_breaths: int # leak-valid breaths counted + n_nights: int # nights with ≥1 leak-valid breath + + +class EpochRxViolation(BaseModel): + epoch_label: str + changed_keys: list[str] # which RX_KEYS changed within this epoch + change_dates: list[date] # dates where a change was detected + + +class EpochBreathStats(BaseModel): + """Breath-feature distributions for one epoch.""" + + label: str + date_start: date + date_end: date + nights_with_data: int # nights that CONTRIBUTE data: analyzed_session_count > 0 + # (contribution by data, not enum label — pass-4 + # IMPORTANT-3: a PARTIAL night with zero OK sessions + # has no current breaths and does NOT contribute) + nights_missing_analysis: int # nights with analyzed_session_count == 0, whatever + # their day_status (NOT_RUN, STALE, MIXED_VERSION, + # and zero-current PARTIAL) + algorithm_identity: ( + AlgorithmIdentity | None + ) # uniform identity across all contributing + # nights (CROSS_VERSION_REFUSAL_KEYS guard); + # None + null_reason=NO_DATA_IN_RANGE when NO + # nights contribute (typed empty-epoch result, + # pass-4 IMPORTANT-3). Only identity is guaranteed + # uniform — run metadata is per-run (nightly + # session_coverage); a singular full AlgoVersions + # here would mislabel mixed-mode epochs + # (pass-3 IMPORTANT-3) + null_reason: NullReason | None # NO_DATA_IN_RANGE for an empty epoch + primary_mode: str | None # uniform mode whose recovery markers sourced + # rera_proxy_count; None when contributing runs + # mix primary modes (pass-3 IMPORTANT-3) + mid_insp_flattening: DistributionStats + flatness_index: DistributionStats + flow_class_distribution: dict[ + int, int + ] # {flow_class: breath_count}, leak-valid only + tidal_volume_ml: DistributionStats + ie_ratio: DistributionStats + rera_proxy_count: int | None # FL-run-ending-in-recovery events in epoch; + # None + rera_reason=PRIMARY_MODE_MISMATCH when + # contributing runs mix primary modes (per-session + # metadata preserved via nightly session_coverage) + rera_reason: NullReason | None + rx_settings: dict[str, str] # RX_KEYS values (uniform per RX guard) + # Coverage rule (pass-2 F4', contribution per pass-4 IMPORTANT-3): a night + # contributes to epoch distributions ONLY when analyzed_session_count > 0; + # days with day_status=MIXED_VERSION are excluded entirely — cross-version + # breaths cannot safely mix. DistributionStats.n_nights counts only + # contributing nights (analyzed_session_count > 0). + + +class CompareEpochsResult(BaseModel): + """Result of compare_epochs().""" + + epochs: list[EpochBreathStats] # same order as request + null_reason: NullReason | None # set if comparison refused + rx_violations: list[EpochRxViolation] = Field(default_factory=list) + # Populated when null_reason = RX_CHANGED_WITHIN_EPOCH; one entry per failing epoch. + # Algo-version refusal (exact): refused when ANY CROSS_VERSION_REFUSAL_KEYS field + # (§1) differs between epochs. NOT refused for trigger_cycle or run-metadata + # (primary_mode / modes) differences — trigger_cycle is experimental annotation; + # mode differences degrade only RERA fields (PRIMARY_MODE_MISMATCH per epoch, above). +``` + +### 8. Contextual-event DTO + +```python +class ContextualEvent(BaseModel): + """ + One machine-flagged event with surrounding context. Waveform context comes + from the WaveformWindow seam (§9) — never direct WaveformService calls. + """ + + session_id: int + session_start_wall_clock: datetime # naive — tier-2; anchor for offset_seconds + event_type: str # from Event.event_type + event_start_wall_clock: datetime # naive — tier-2 + timezone_status: TimezoneStatus = TimezoneStatus.UNKNOWN + offset_seconds: float # from session start + duration_seconds: float | None + + # Context (all nullable — channel may be absent) + pressure_at_event_cmh2o: float | None + pressure_reason: NullReason | None + leak_at_event_lpm: float | None + leak_reason: NullReason | None + mv_prior_120s_lpm: float | None # mean MV in 120 s before event + mv_reason: NullReason | None + minutes_since_session_start: float +``` + +### 9. Multi-channel waveform-window DTO (NEW seam — replaces direct WaveformService) + +**Ownership contract (pass-3 IMPORTANT-4):** the seam is split into a typed fetch function and a pure compute function so scope ownership is structural, never a promise a method cannot keep — `BreathService` holds a caller-injected `AsyncSession` (§13) and MUST NOT close it, and no code reaches around the actor-aware scope provider (M2/PR-C) via an internal global `session_scope()`: + +```python +async def fetch_waveform_window_raw( + db: AsyncSession, request: WaveformWindowRequest +) -> RawWaveformWindow: + """DB I/O ONLY. One query — SELECT ... FROM waveforms WHERE session_id = :sid + AND waveform_type IN (:types) (UniqueConstraint(session_id, waveform_type) + guarantees ≤1 row per channel, models.py:280) — copies bytes + scalar metadata + into the detached RawWaveformWindow and returns. Never closes db: the scope + owner (the MCP tool via the scope-provider seam, or any other caller) opens + and closes the scope around this call. Single query = single snapshot across + channels.""" + + +def compute_waveform_window(raw: RawWaveformWindow) -> WaveformWindow: + """Pure — no DB access. Deserializes bytes using each channel's persisted + sample_count (pass-4 IMPORTANT-4), slices by offset window, applies LTTB + when max_points is set. Runs after the DB scope has closed. A blob whose + byte length and sample_count mismatch raises the existing sanitized + invalid-waveform error (test pinned).""" +``` + +`RawWaveformWindow` is a small detached DTO — nothing in it holds a DB handle: + +```python +class RawWaveformChannel(BaseModel): + waveform_type: WaveformChannelName + unit: str | None + sample_rate: float + sample_count: int # persisted Waveform sample count, copied in the + # batched query — the deserializer requires blob + + # count (the existing fetch seam returns + # (data_blob, sample_count, metadata) for exactly + # this reason, waveform_loader.py:170-222); + # pass-4 IMPORTANT-4 + raw_bytes: bytes # serialized samples, copied out of the ORM row + + +class RawWaveformWindow(BaseModel): + request: WaveformWindowRequest # the validated request + session_id: int + session_start_wall_clock: datetime # naive — tier-2 anchor + channels: list[RawWaveformChannel] + missing_channels: list[WaveformChannelName] +``` + +`BreathService.get_waveform_window()` (§13) is retained as the convenience orchestrator: it awaits `fetch_waveform_window_raw(self._db, request)` using its injected session — **without closing it** — then returns `compute_waveform_window(raw)`; callers that want compute outside the scope (MCP render/raw tools SHOULD, so deserialization/LTTB never run inside an open transaction) call the two functions directly around their own scope boundary. + +```python +class WaveformChannelName(StrEnum): + """Typed channel names (pass-3 MINOR-1) — the persisted Waveform.waveform_type + values (models.py:268), sourced from parsers/unified.py WaveformType values. + Unknown/blank channel strings are rejected at request validation, NOT converted + into missing_channels; missing_channels reports only VALID names absent from + the DB for this session.""" + + FLOW = "flow" + PRESSURE = "pressure" + THERAPY_PRESSURE = "therapy_pressure" + EPAP = "epap" + LEAK = "leak" + MV = "mv" + RR = "rr" + TV = "tv" + SPO2 = "spo2" + PULSE = "pulse" + FL = "fl" + SNORE = "snore" + + +class WaveformChannel(BaseModel): + """One deserialized, windowed waveform channel.""" + + channel_type: ( + WaveformChannelName # sourced from Waveform.waveform_type (models.py:268) + ) + unit: str | None + sample_rate: float + offset_seconds: list[float] # positions from session start + values: list[float] + original_sample_count: int # pre-LTTB count + is_downsampled: bool + + +class WaveformWindow(BaseModel): + """ + Multi-channel waveform data for a time window. Produced by + compute_waveform_window() from a detached RawWaveformWindow (fetched in ONE + query under one short scope — see the ownership contract above). This seam + replaces — never calls — the old single-channel + WaveformService.get_waveform_data() path, which closes its session after one + channel (waveform_service.py:115). + """ + + session_id: int + session_start_wall_clock: datetime # naive — tier-2 anchor + timezone_status: TimezoneStatus = TimezoneStatus.UNKNOWN + window_start_offset: float + window_end_offset: float + channels: list[WaveformChannel] # all requested channels that exist + missing_channels: list[ + WaveformChannelName + ] # valid requested channels absent from DB + missing_channel_reason: ( + NullReason | None + ) # CHANNEL_ABSENT when ≥1 missing; None when none + + +class WaveformWindowRequest(BaseModel): + """session_id semantics identical to BreathQueryRange: None + multiple sessions + on the day → MultiSessionAmbiguityError; non-None → offsets relative to that + session's start_time, ValueError if it doesn't belong to the date + device. + + Per-tool window caps (pass-3 MINOR-1): the CALLER sets window_cap_seconds — + get_waveform passes 120 (raw-array escape hatch stays ≤2 min even with LTTB); + render_window passes 900 (PNG rendering may span up to the 15-min breath-table + window). The validator enforces whichever cap is bound.""" + + therapy_date: date + device_id: int | None = None + session_id: int | None = None # required when day has >1 session + offset_start: float = Field(ge=0.0) + offset_end: float = Field(gt=0.0) + channels: list[WaveformChannelName] = Field( + default_factory=list, max_length=len(WaveformChannelName) + ) # typed + bounded (pass-3 MINOR-1); empty → default set + max_points: int | None = Field( + default=None, ge=1, le=1000, description="LTTB cap; None = raw" + ) + window_cap_seconds: float = Field( + default=120.0, gt=0.0 + ) # tool-specific cap (above) + + @model_validator(mode="after") + def validate_request(self) -> "WaveformWindowRequest": + if self.offset_end <= self.offset_start: + raise ValueError("offset_end must be > offset_start") + window_seconds = self.offset_end - self.offset_start + if window_seconds > self.window_cap_seconds: + raise ValueError( + f"Window {window_seconds:.0f} s exceeds the {self.window_cap_seconds:.0f} s " + "cap for this tool; narrow the window" + ) + if not self.channels: + self.channels = [ + WaveformChannelName.FLOW, + WaveformChannelName.PRESSURE, + WaveformChannelName.LEAK, + ] + self.channels = list(dict.fromkeys(self.channels)) # dedup, order-preserving + return self +``` + +### 10. Nightly-aggregation DTOs (seams for get_nightly_summary, single-night + range) + +**Mode policy (pass-2 F4', revised per pass-3 IMPORTANT-3, binding):** recovery markers (`is_recovery_breath=True`) are persisted from the **primary mode's** detector run only — the mode chosen explicitly at analysis invocation (PR-A step 4: defaults to `DEFAULT_MODE = "aasm"`, analysis/modes/config.py:95, when included in the requested modes; otherwise caller-supplied, `ValueError` if absent). No other mode's recovery markers are persisted. Every DTO surfacing RERA-derived fields carries the source `primary_mode` — never a silently-picked first dict entry. **Aggregation across runs: FL fields aggregate sessions sharing `AlgorithmIdentity` regardless of run modes; RERA/recovery-derived fields additionally require a uniform `primary_mode` across contributing runs — mixed primary modes → RERA fields `null` + `PRIMARY_MODE_MISMATCH`, with per-session run metadata preserved in `session_coverage`.** + +**Coverage policy (pass-2 F4', binding; status precedence is TOTAL per §1 `DayAnalysisStatus`; contribution by data per pass-4 IMPORTANT-3):** aggregates are returned with an explicit non-OK `day_status` plus analyzed/eligible counts and missing/stale session IDs (capability-honest beats refusal). Aggregation contribution is determined by `analyzed_session_count > 0`, never by the enum label: on `PARTIAL` days with `analyzed_session_count > 0`, FL/RERA fields aggregate across the OK sessions only (coverage fields disclose the gaps); on `PARTIAL` days with zero OK sessions (e.g. stale + not-run and no current session — a valid state under the total precedence), and on `NOT_RUN`/`STALE` days, there are no current sessions, so FL/RERA fields are `null` + reason (`ANALYSIS_NOT_RUN`/`ANALYSIS_STALE`). **Exception — cross-version FL aggregation within a day: when OK sessions differ on any `CROSS_VERSION_REFUSAL_KEYS` field (`day_status=MIXED_VERSION`), FL fields are `null` + `ALGO_VERSION_MISMATCH`. Mixing breaths from different algorithm versions into one distribution is always refused.** + +```python +class NightlyAnalysisSummary(BaseModel): + """ + Latest-analysis-run fields for one therapy night. + Source: latest AnalysisResult per session of the day, ordered + (created_at DESC, id DESC); multiple sessions aggregate across all OK sessions. + """ + + therapy_date: date + device_id: int + + # Coverage (pass-2 F4') — replaces the single per-day analysis_status + day_status: DayAnalysisStatus + session_coverage: list[SessionCoverage] # one entry per session of the day + eligible_session_count: int # sessions with a Session row + analyzed_session_count: int # sessions with analysis_status=OK + missing_or_stale_session_ids: list[int] = Field( + default_factory=list + ) # G2 transparency + algorithm_identity: AlgorithmIdentity | None # uniform identity of contributing OK + # sessions; None when day_status in + # (NOT_RUN, MIXED_VERSION) or zero OK + # sessions. Full per-run AlgoVersions + # (incl. run metadata) lives in + # session_coverage (pass-3 IMPORTANT-3) + + rera_count: int | None + rera_reason: NullReason | None # PRIMARY_MODE_MISMATCH when OK sessions' runs + # differ in primary_mode (pass-3 IMPORTANT-3) + primary_mode: str | None # uniform mode whose recovery markers sourced + # rera_count; None when mixed (per-session + # values remain in session_coverage) + fl_median: float | None # median mid_insp_flattening (from Breath rows) + fl_95th: float | None + fl_max: float | None + fl_reason: NullReason | None + # FL aggregation rule: day_status=MIXED_VERSION → fl_median/fl_95th/fl_max = None, + # fl_reason = NullReason.ALGO_VERSION_MISMATCH. Otherwise aggregate across OK sessions. + + # Compliance (reimplemented — prior calculate_compliance_rate deleted from main) + total_therapy_hours: float # from Day.total_therapy_hours + compliance_threshold_hours: float # caller-supplied, default 4.0 + is_compliant: bool # total_therapy_hours >= threshold + + +class NightlyRangeSummary(BaseModel): + """ + Compliance + analysis summary over a date range. Consumed by MCP + get_nightly_summary range mode — keeps aggregation in the service layer (F5 guard). + """ + + date_start: date + date_end: date + device_id: int + compliance_threshold_hours: float + n_calendar_nights: int # inclusive count of nights in [date_start, date_end] + n_nights: int # nights with ≥1 Session row (nights with data) + days_compliant: int # nights where Day.total_therapy_hours >= threshold + compliance_pct: ( + float # days_compliant / n_calendar_nights * 100; 0.0 when range empty. + ) + # Insurance-style: a night with no usage is non-compliant. + nights: list[NightlyAnalysisSummary] # ordered by date ascending; data nights only. + # Per-night coverage/FL/RERA rules exactly per + # the §10 policies: nights aggregate their OK + # sessions when analyzed_session_count > 0 + # (gaps disclosed via coverage fields); + # zero-current nights (NOT_RUN/STALE/zero-current + # PARTIAL) null + reason; + # MIXED_VERSION nights null FL + ALGO_VERSION_MISMATCH. +``` + +### 11. Capabilities seam + +```python +class DeviceCapabilities(BaseModel): + """ + What data is actually present for a device over a (requested) date range — + derived entirely from DB rows; parser metadata is supplementary context only. + Service-layer source for docs://capabilities and the per-response + device_capabilities block (G2). + Column refs: Waveform.waveform_type (models.py:268), Setting.key (models.py:398). + """ + + device_id: int + + # Requested range (always echoes caller input) + requested_date_start: date | None # None = "full imported range" query + requested_date_end: date | None + + # Actual covered range (nullable when no data exists in the requested range) + actual_date_start: date | None # earliest Day.date with ≥1 session + actual_date_end: date | None # latest Day.date with ≥1 session + null_reason: NullReason | None # NO_DATA_IN_RANGE when actual_* are None + + # Content (empty lists — not null — when range has data but a category is absent) + channels_present: list[ + str + ] # distinct Waveform.waveform_type values in actual range + all_setting_keys_present: list[ + str + ] # ALL distinct Setting.key values in actual range + rx_keys_present: list[str] # subset: RX_KEYS found non-null in ≥1 session + # (RX_KEYS = settings-timeline subset, not full capability) + event_types_present: list[str] # distinct Event.event_type values in actual range + session_count: int # total Session rows in actual range + nights_with_data: int # distinct Day.date values with ≥1 session + supported_vendor_models: list[str] # from parser registry — supplementary only +``` + +### 12. CA-analysis DTOs + +```python +class CaDetail(BaseModel): + """Per-CA event analysis.""" + + session_id: int + session_start_wall_clock: datetime # naive — tier-2 anchor + timezone_status: TimezoneStatus = TimezoneStatus.UNKNOWN + offset_seconds: float # CA start, from session start + duration_seconds: float | None + # Derived via the WaveformWindow seam: + preceding_mv_slope: float | None # L/min per minute over 120 s before CA + preceding_mv_reason: NullReason | None + ps_delivered_cmh2o: float | None # pressure support at CA time + ps_reason: NullReason | None + stability_index: float | None # CV of MV in 60 s before CA + stability_reason: NullReason | None + + +class CaAnalysisResult(BaseModel): + """Result of get_ca_analysis().""" + + query_date: date + device_id: int + day_status: DayAnalysisStatus # coverage policy, §10 (pass-2 F4') + session_coverage: list[SessionCoverage] = Field(default_factory=list) + algorithm_identity: AlgorithmIdentity | None # uniform identity of contributing OK + # sessions; None when day_status in + # (NOT_RUN, MIXED_VERSION). Per-run + # AlgoVersions in session_coverage (§1) + null_reason: NullReason | None + ca_events: list[CaDetail] + # CA events are event-anchored (from Event rows), so day_status=PARTIAL, + # MIXED_VERSION, or even NOT_RUN does not refuse them (consistent with §6 + # CA_CENTERED, pass-3 IMPORTANT-5); night-level fields below aggregate only + # OK sessions and are null + reason when coverage is insufficient. + # Night-level (from AnalysisResult periodic_breathing / csr_detection dicts): + periodic_breathing_pct: float | None + pb_reason: NullReason | None + mv_rolling_variance: float | None # variance of 10-min MV bins across night + mv_variance_reason: NullReason | None +``` + +### 13. `BreathService` — public method signatures + +```python +class BreathService: + """Query layer over the breaths table. All methods are async.""" + + def __init__(self, db_session: AsyncSession) -> None: ... + + async def get_breath_table(self, query: BreathQueryRange) -> BreathPage: + """Raw or binned breath fetch. Ordering ascending (session_id, breath_number). + Latest analysis run per session selected by (created_at DESC, id DESC) — + the binding deterministic selector (PR-A step 1); never bare MAX(created_at). + analysis_status=NOT_RUN (null rows/bins) when no AnalysisResult exists; + STALE_VERSION when engine_versions_json differs from _current_algorithm_identity().""" + + async def find_windows( + self, + therapy_date: date, + criterion: WindowCriterion, + n: int, + options: WindowCriterionOptions | None = None, + device_id: int | None = None, + ) -> FindWindowsResult: + """N windows matching criterion, worst first, built per the §6 construction + rule. Dedup: >50% overlap merges, keep worst. Criterion-irrelevant non-default + options → ValueError naming the fields. FL-ranked criteria refuse on + day_status=MIXED_VERSION; FL_RUN_ENDING_IN_RECOVERY additionally refuses on + mixed primary_mode (PRIMARY_MODE_MISMATCH); CA_CENTERED proceeds on any + day_status with nullable per-window analysis provenance (§6).""" + + async def compare_epochs( + self, + epochs: list[EpochRequest], + metrics: list[DistributionMetric] | None = None, # None = all + ) -> CompareEpochsResult: + """Distributions across RxTracker epochs. Refuses on any + CROSS_VERSION_REFUSAL_KEYS mismatch (ALGO_VERSION_MISMATCH, §1/§7) or + mid-epoch RX change (RX_CHANGED_WITHIN_EPOCH + rx_violations); mixed + primary modes degrade RERA fields only (PRIMARY_MODE_MISMATCH, §7).""" + + async def get_analysis_status( + self, session_id: int + ) -> tuple[AnalysisStatus, AlgoVersions | None]: + """(status, versions) for a session's latest AnalysisResult. + (NOT_RUN, None) when absent; (STALE_VERSION, versions) on version drift.""" + + async def get_nightly_summary( + self, + therapy_date: date, + device_id: int | None = None, + compliance_threshold_hours: float = 4.0, + ) -> NightlyAnalysisSummary: + """Latest-run analysis fields aggregated across all OK sessions of a day, + with per-session coverage + day_status per the §10 coverage policy. + FL median/95th/max computed from Breath rows of the latest runs; + MIXED_VERSION days refuse FL aggregation (ALGO_VERSION_MISMATCH).""" + + async def get_nightly_range_summary( + self, + date_start: date, + date_end: date, + device_id: int | None = None, + compliance_threshold_hours: float = 4.0, + ) -> NightlyRangeSummary: + """Per-night summaries + aggregate compliance. compliance_pct uses the + calendar-night denominator. Per-night FL/RERA fields follow the §10 + coverage policy exactly (pass-3 IMPORTANT-3 doc alignment; contribution + by data per pass-4 IMPORTANT-3): nights with analyzed_session_count > 0 + aggregate their OK sessions with gaps disclosed via coverage fields; + zero-current nights (NOT_RUN, STALE, and PARTIAL with zero OK sessions) + carry null FL/RERA + reason; MIXED_VERSION nights null FL + + ALGO_VERSION_MISMATCH. Device auto-select per BreathQueryRange rule.""" + + async def get_device_capabilities( + self, + device_id: int, + date_start: date | None = None, # None = full imported range + date_end: date | None = None, + ) -> DeviceCapabilities: + """Actual covered range + channels (Waveform.waveform_type), event types, + setting keys present. No sessions in range → null actual endpoints + + null_reason=NO_DATA_IN_RANGE; data present but category absent → empty lists. + all_setting_keys_present reports ALL distinct Setting.key values (models.py:398); + rx_keys_present is the RX_KEYS subset. Calls ensure_registered_parsers() + (§15) before querying the parser registry.""" + + async def get_contextual_events( + self, + therapy_date: date, + event_types: list[str] | None = None, + min_duration: float | None = None, + device_id: int | None = None, + ) -> list[ContextualEvent]: + """Machine events enriched with waveform context via the WaveformWindow seam.""" + + async def get_waveform_window( + self, request: WaveformWindowRequest + ) -> WaveformWindow: + """Convenience orchestrator over the §9 split seam: awaits + fetch_waveform_window_raw(self._db, request) on the injected session — + NEVER closes it (pass-3 IMPORTANT-4) — then returns + compute_waveform_window(raw). Callers wanting compute outside the DB + scope (MCP render/raw tools SHOULD) call the two §9 functions directly + around their own scope boundary. Missing channels listed with + missing_channel_reason=CHANNEL_ABSENT (None when nothing is missing).""" + + async def get_ca_analysis( + self, therapy_date: date, device_id: int | None = None + ) -> CaAnalysisResult: + """Per-CA context + night-level periodic-breathing stats from + AnalysisResult.programmatic_result_json.""" + + @staticmethod + def _current_algorithm_identity() -> AlgorithmIdentity: + """Current algorithm identity constants for STALE_VERSION detection. Sources: + segmenter/fl_classifier version strings (PR-A adds), FLATTENING_ALGO_VERSION, + TRIGGER_CYCLE_ALGO_VERSION, LEAK_VALID_ALGO, and RECOVERY_DETECTOR_ALGO_VERSION + constants in analysis/shared/ (PR-A adds). Compares AlgorithmIdentity + structurally — run metadata (primary_mode, modes) is a different type and + cannot enter the comparison (§1, pass-3 IMPORTANT-2/-3).""" +``` + +### 14. Design notes for PR-B (Duncan) + +1. **`WaveformService.get_waveform_data()` closes its session** after the I/O phase (waveform_service.py:115). PR-B tools needing multi-channel data use the §9 split seam — `fetch_waveform_window_raw()` inside the scope, `compute_waveform_window()` after it closes (or the `get_waveform_window()` orchestrator when compute-inside-scope is acceptable) — never `WaveformService` directly. +2. **Compliance calc**: `calculate_compliance_rate` / `COMPLIANCE_MIN_HOURS` were deleted from main with the old server. `NightlyAnalysisSummary.is_compliant` computes from `Day.total_therapy_hours` vs caller threshold (default 4.0 h); range mode uses the calendar-night denominator in `NightlyRangeSummary`. +3. **Version guard**: `compare_epochs` refuses on any `CROSS_VERSION_REFUSAL_KEYS` mismatch (§1: `format_version`/`segmenter`/`fl_classifier`/`flattening`/`leak_valid`/`recovery_detector`) — not `trigger_cycle` or run metadata (`primary_mode`/`modes`); mixed primary modes degrade RERA fields only (§7). +4. **Session pattern**: `BreathService.__init__` takes an `AsyncSession`, matching every existing service. MCP tools obtain sessions via the scope-provider seam the plan defines (M2); `BreathService` is identical under either provider. +5. **`engine_versions_json` shape (binding, pass-4 IMPORTANT-5)**: the stored JSON is the nested structural split — exactly: + ```python + engine_versions_json = { + "identity": algorithm_identity.model_dump(), # AlgorithmIdentity (§1) + "run": run_metadata.model_dump(), # AnalysisRunMetadata (§1) + } + ``` + `store_result()` (analysis/service.py:625) is the sole write point and writes this shape; it validates as the §1 `AlgoVersions` composition. No flat keys are added alongside the legacy `format_version`/`modes` layout. Legacy flat rows read as `STALE_VERSION` — the feature already mandates fresh-DB/reimport, so no conversion code is written. +6. **Error mapping**: `MultiSessionAmbiguityError` → structured MCP error listing `SessionSummary` entries so the caller re-issues with `session_id`. + +### 15. `ensure_registered_parsers()` — idempotent parser registration (pass-2 MINOR-3) + +`register_all_parsers()` is not cleanly idempotent: `ParserRegistry.register()` raises on duplicate parser IDs (registry.py:62-64) and `register_all_parsers()` logs that as an error (register_all.py:33) — repeated calls preserve state but emit false error logs. PR-A adds a new seam to `src/snore/parsers/register_all.py`: + +```python +def ensure_registered_parsers() -> None: + """ + Register any parser IDs not yet in the global registry. Safe to call + repeatedly: existing IDs are read via the PUBLIC parser_registry.list_parsers() + (never the private _parsers attribute) and already-registered parsers are + skipped — no duplicate-ID errors, no false error logs. Handles partially + populated registries correctly. + + Call sites: BreathService.get_device_capabilities() and the + docs://capabilities lifespan hook — both may run in cold-start processes. + + Sketch: + existing_ids = {p.parser_id for p in parser_registry.list_parsers()} + for factory in _KNOWN_PARSER_FACTORIES: + try: + candidate = factory() + if candidate.parser_id not in existing_ids: + parser_registry.register(candidate) + except Exception: + logger.warning("Parser registration skipped", exc_info=True) -Claude Desktop/Code connects over stdio and can, in one conversation: orient via -`get_data_overview` → identify worst flow-limitation windows → pull the breath table for -one → view the PNG → compare two settings epochs with real distribution stats — using only -generic tool contracts, with `clinical_profile: uars` active. Stage 1 covers the -orientation and summary half of this workflow; Stages 2–3 complete it. + _KNOWN_PARSER_FACTORIES is the parser list register_all_parsers() iterates, + extracted to a shared module constant so both functions stay in sync. + register_all_parsers() itself is unchanged (backward compatibility); + ensure_registered_parsers() is the preferred call in idempotent contexts. + """ +``` From c23c4f7daf846cbb755ae77d69b18df743f12a32 Mon Sep 17 00:00:00 2001 From: npub17xpz0p704l6vlapga6nahzevr9h0kd9ggfzw640d9yevhmcgst2ql280uq Date: Sun, 2 Aug 2026 20:22:03 -0400 Subject: [PATCH 4/4] fix: clear PR-A-independent pass-1 findings on MCP layer Six items from Thufir's pass-1 MINOR sweep and minimalism score: - Numeric arg bounds: extract validate_page_args/validate_compliance_threshold/ validate_min_duration into validation.py (testable without FastMCP client); wire from server.py; 13 new unit tests in test_mcp_server.py cover zero/negative rejection and page_size cap at 90. - Non-UTC test: add time.tzset() call (POSIX) after os.environ[TZ] change so the process timezone actually shifts; restore tzset() in finally block. Comment updated to reflect what the test now actually proves. - docs/tools.md: replace stale start_time_iso with start_time_wall_clock, timezone_status, offset_seconds (matches EventRow schema). - Transport honesty: pass validated --transport to server.run(transport=...) instead of hardcoding 'stdio'; type: ignore[arg-type] since the validator above already gates to literal values. - Minimalism: delete unused _active_profile module-level variable and its set/clear in lifespan; delete the executed-then-discarded analysis_count_result query (overview.py:113-125) and its _ = ... suppressor. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- src/snore/cli/commands/mcp.py | 2 +- src/snore/mcp/docs/tools.md | 2 +- src/snore/mcp/server.py | 23 ++++---- src/snore/mcp/tools/overview.py | 8 +-- src/snore/mcp/validation.py | 25 ++++++++ tests/integration/test_mcp_tools.py | 16 +++-- tests/unit/test_mcp_server.py | 91 +++++++++++++++++++++++++++++ 7 files changed, 143 insertions(+), 24 deletions(-) diff --git a/src/snore/cli/commands/mcp.py b/src/snore/cli/commands/mcp.py index d6a61468..ffee054a 100644 --- a/src/snore/cli/commands/mcp.py +++ b/src/snore/cli/commands/mcp.py @@ -54,4 +54,4 @@ def mcp(db: str | None, profile: str, transport: str) -> None: logger.debug("snore mcp: profile=%s db=%r", profile, db) server = make_server(db_flag=db, profile_name=profile) - server.run(transport="stdio") + server.run(transport=transport) # type: ignore[arg-type] # validated above diff --git a/src/snore/mcp/docs/tools.md b/src/snore/mcp/docs/tools.md index 14806da9..baf75654 100644 --- a/src/snore/mcp/docs/tools.md +++ b/src/snore/mcp/docs/tools.md @@ -116,7 +116,7 @@ Respiratory events for a single session date with per-event context. **Returns:** `EventsResponse` - `events` — list of `EventRow` - - `id`, `event_type`, `start_time_iso`, `duration_seconds` + - `id`, `event_type`, `start_time_wall_clock`, `timezone_status`, `offset_seconds`, `duration_seconds` - `spo2_drop_pct`, `peak_flow_limitation` - `context` — `minutes_since_session_start` (pressure/leak/MV context in Phase 4) diff --git a/src/snore/mcp/server.py b/src/snore/mcp/server.py index 0f2227a6..7ce8422b 100644 --- a/src/snore/mcp/server.py +++ b/src/snore/mcp/server.py @@ -49,14 +49,16 @@ from snore.mcp.errors import ValidationError from snore.mcp.profiles import ClinicalProfile, get_profile from snore.mcp.schemas import SCHEMA_MODEL_MAP, model_to_schema -from snore.mcp.validation import parse_date, parse_date_range +from snore.mcp.validation import ( + parse_date, + parse_date_range, + validate_compliance_threshold, + validate_min_duration, + validate_page_args, +) logger = logging.getLogger(__name__) -# Module-level profile holder — set during lifespan, read-only from tools. -# This is the ONLY module-level state permitted in this package (G3). -_active_profile: ClinicalProfile | None = None - # DB-access seam (M2): tools call _scope_provider(), never session_scope() directly. # Lifespan installs the concrete implementation; PR-C swaps in an actor-scoped version. # Default delegates to the global session_scope() (populated by init_database_from_url). @@ -116,13 +118,12 @@ async def _lifespan( runs even if tool errors occur during shutdown. The ``_scope_provider`` seam is reset to the default (global ``session_scope``) on teardown. """ - global _active_profile, _scope_provider + global _scope_provider target = DatabaseTarget.from_env_and_flags(db_flag=db_flag, warn_ignored=True) async_url = target.resolve_async_url() await init_database_from_url(async_url) - _active_profile = get_profile(profile_name) # Install the scope-provider seam: currently delegates to the global # session_scope() that init_database_from_url populated. PR-C replaces @@ -138,7 +139,6 @@ async def _lifespan( finally: await cleanup_database() _scope_provider = session_scope # reset to safe default - _active_profile = None logger.info("SNORE MCP server stopped") @@ -396,9 +396,8 @@ async def get_nightly_summary( start_d, end_d = parse_date_range(start, end) - capped_page_size = min(page_size, 90) - if page < 1: - raise ValidationError("page must be >= 1") + capped_page_size = validate_page_args(page, page_size) + validate_compliance_threshold(compliance_threshold_hours) async with _scope_provider() as db: result = await _impl( @@ -444,6 +443,8 @@ async def get_events( event_date = parse_date(date, "date") + validate_min_duration(min_duration) + async with _scope_provider() as db: result = await _impl( db, diff --git a/src/snore/mcp/tools/overview.py b/src/snore/mcp/tools/overview.py index c8093a4a..2dd114d1 100644 --- a/src/snore/mcp/tools/overview.py +++ b/src/snore/mcp/tools/overview.py @@ -110,19 +110,13 @@ async def get_data_overview(db_session: AsyncSession) -> DataOverviewResponse: .all() ) - # Analysis status - analysis_count_result = await db_session.execute( - select(func.count(models.AnalysisResult.id.distinct())) - .join(models.Session, models.AnalysisResult.session_id == models.Session.id) - .where(models.Session.enabled.is_(True)) - ) + # Analysis status — count distinct sessions that have at least one AnalysisResult analysis_session_count = ( await db_session.execute( select(func.count(models.AnalysisResult.session_id.distinct())) ) ).scalar_one() analysis_run = analysis_session_count > 0 - _ = analysis_count_result # suppress unused var return DataOverviewResponse( devices=device_infos, diff --git a/src/snore/mcp/validation.py b/src/snore/mcp/validation.py index fd05ea47..fca633fd 100644 --- a/src/snore/mcp/validation.py +++ b/src/snore/mcp/validation.py @@ -65,3 +65,28 @@ def parse_date_range( f"'{start_param}' ({start}) must not be after '{end_param}' ({end})." ) return start_date, end_date + + +def validate_page_args(page: int, page_size: int) -> int: + """Validate pagination args; return the capped page_size. + + Raises: + ValidationError: If ``page < 1`` or ``page_size < 1``. + """ + if page < 1: + raise ValidationError("page must be >= 1") + if page_size < 1: + raise ValidationError("page_size must be >= 1") + return min(page_size, 90) + + +def validate_compliance_threshold(hours: float) -> None: + """Raise ValidationError if compliance threshold is negative.""" + if hours < 0: + raise ValidationError("compliance_threshold_hours must be >= 0") + + +def validate_min_duration(min_duration: float | None) -> None: + """Raise ValidationError if min_duration is negative.""" + if min_duration is not None and min_duration < 0: + raise ValidationError("min_duration must be >= 0") diff --git a/tests/integration/test_mcp_tools.py b/tests/integration/test_mcp_tools.py index e50e959a..aba1f971 100644 --- a/tests/integration/test_mcp_tools.py +++ b/tests/integration/test_mcp_tools.py @@ -551,13 +551,19 @@ async def test_event_timestamps_identical_in_non_utc_host( expected = (session_start + timedelta(hours=1)).isoformat() assert wall_clock_str == expected - # Verify the same invariant holds if we simulate a non-UTC TZ env var - # (we can't actually change the interpreter's TZ mid-process, but we can - # confirm the code never calls .astimezone() or .timestamp() which are - # host-TZ-dependent — by checking the value equals the naive .isoformat()). + # Verify the same invariant holds under a non-UTC host TZ. + # Setting os.environ["TZ"] + calling time.tzset() (POSIX-only) actually + # shifts the process timezone. On Windows (no tzset), the test still + # checks offset-freedom through the env change, which is weaker but + # harmless. The real protection is that our code never calls + # .astimezone() or .timestamp() on naive datetimes. + import time + original_tz = os.environ.get("TZ") try: os.environ["TZ"] = "America/New_York" + if hasattr(time, "tzset"): + time.tzset() # Re-run the same tool — output must be byte-identical result2 = await get_events(async_db_session, target_date) assert result2.events[0].start_time_wall_clock == wall_clock_str @@ -566,6 +572,8 @@ async def test_event_timestamps_identical_in_non_utc_host( os.environ.pop("TZ", None) else: os.environ["TZ"] = original_tz + if hasattr(time, "tzset"): + time.tzset() # --------------------------------------------------------------------------- diff --git a/tests/unit/test_mcp_server.py b/tests/unit/test_mcp_server.py index 60f627a3..92c6611c 100644 --- a/tests/unit/test_mcp_server.py +++ b/tests/unit/test_mcp_server.py @@ -121,3 +121,94 @@ def test_all_profiles_produce_non_empty_instructions(self) -> None: profile = get_profile(name) instructions = _build_instructions(profile) assert len(instructions) > 100 + + +class TestValidatePageArgs: + """validate_page_args rejects out-of-range pagination values.""" + + def test_valid_args_return_capped_page_size(self) -> None: + from snore.mcp.validation import validate_page_args + + assert validate_page_args(1, 30) == 30 + + def test_page_size_is_capped_at_90(self) -> None: + from snore.mcp.validation import validate_page_args + + assert validate_page_args(1, 200) == 90 + + def test_page_zero_raises(self) -> None: + from snore.mcp.errors import ValidationError + from snore.mcp.validation import validate_page_args + + with pytest.raises(ValidationError, match="page must be >= 1"): + validate_page_args(0, 30) + + def test_page_negative_raises(self) -> None: + from snore.mcp.errors import ValidationError + from snore.mcp.validation import validate_page_args + + with pytest.raises(ValidationError, match="page must be >= 1"): + validate_page_args(-5, 30) + + def test_page_size_zero_raises(self) -> None: + from snore.mcp.errors import ValidationError + from snore.mcp.validation import validate_page_args + + with pytest.raises(ValidationError, match="page_size must be >= 1"): + validate_page_args(1, 0) + + def test_page_size_negative_raises(self) -> None: + from snore.mcp.errors import ValidationError + from snore.mcp.validation import validate_page_args + + with pytest.raises(ValidationError, match="page_size must be >= 1"): + validate_page_args(1, -10) + + +class TestValidateComplianceThreshold: + """validate_compliance_threshold rejects negative thresholds.""" + + def test_zero_is_valid(self) -> None: + from snore.mcp.validation import validate_compliance_threshold + + validate_compliance_threshold(0.0) # must not raise + + def test_positive_value_is_valid(self) -> None: + from snore.mcp.validation import validate_compliance_threshold + + validate_compliance_threshold(4.0) # must not raise + + def test_negative_raises(self) -> None: + from snore.mcp.errors import ValidationError + from snore.mcp.validation import validate_compliance_threshold + + with pytest.raises( + ValidationError, match="compliance_threshold_hours must be >= 0" + ): + validate_compliance_threshold(-1.0) + + +class TestValidateMinDuration: + """validate_min_duration rejects negative durations.""" + + def test_none_is_valid(self) -> None: + from snore.mcp.validation import validate_min_duration + + validate_min_duration(None) # must not raise + + def test_zero_is_valid(self) -> None: + from snore.mcp.validation import validate_min_duration + + validate_min_duration(0.0) # must not raise + + def test_positive_is_valid(self) -> None: + from snore.mcp.validation import validate_min_duration + + validate_min_duration(5.0) # must not raise + + def test_negative_raises(self) -> None: + from snore.mcp.errors import ValidationError + from snore.mcp.validation import validate_min_duration + + with pytest.raises(ValidationError, match="min_duration must be >= 0"): + validate_min_duration(-0.1)