From 4345353fc74b02d84d2a185ae4b3190645cc8afd Mon Sep 17 00:00:00 2001 From: mengerj Date: Tue, 23 Jun 2026 09:54:51 +0200 Subject: [PATCH 1/3] mapping out the project explicitly, including more riguirous code quality checks and more tests --- .github/workflows/ci.yml | 44 +++++++++ CLAUDE.md | 114 ++++++++++++++++++++++ docs/ARCHITECTURE.md | 141 +++++++++++++++++++++++++++ docs/ROADMAP.md | 82 ++++++++++++++++ pyproject.toml | 23 +++++ src/parce/agent/curator.py | 2 +- src/parce/graph/builder.py | 63 ++++++------ src/parce/main.py | 16 +-- src/parce/models/graph_schema.py | 8 +- src/parce/models/narrative.py | 4 +- src/parce/tools/cellxgene_fetcher.py | 23 +++-- src/parce/tools/ncbi_fetcher.py | 7 +- tests/test_builder.py | 2 +- tests/test_cellxgene_integration.py | 3 +- tests/test_graph_schema.py | 8 +- tests/test_integration.py | 4 +- tests/test_orchestration.py | 2 +- uv.lock | 128 ++++++++++++++++++++++++ 18 files changed, 616 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/ROADMAP.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..73aba26 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same ref to save CI minutes. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: lint • types • tests (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Sync dependencies (locked) + run: uv sync --extra dev --locked + + - name: Ruff lint + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + - name: Mypy + run: uv run mypy src/parce + + # Unit tests only. Integration tests hit live Azure / Census and are + # excluded here; run them locally with `uv run pytest -m integration`. + - name: Pytest (unit) + run: uv run pytest -m "not integration" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8706ef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# CLAUDE.md + +Guidance for Claude Code (and humans) working in this repository. Read this +first, then [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design and +[docs/ROADMAP.md](docs/ROADMAP.md) for what to do next. + +## What PARCE is + +PARCE harvests **public omics experiments** from heterogeneous repositories and +normalizes them into a single, ontology-grounded **knowledge graph (KG)**. The +KG is the training substrate for a downstream multi-omics autoregressive model: +each study contributes biological *context* (assay, tissue, disease, organism) +and sample-level *covariates* (condition, perturbation, timepoint, subject), +plus URIs to the raw data that an OQAE later tokenizes. + +Two non-negotiable principles drive the design: + +1. **Context is design, not outcome.** Only store metadata that describes how an + experiment was *designed* — assay, tissue, disease, organism, perturbation. + Never store data-*inferred* annotations (e.g. cell type called from + expression). They leak the very signal the downstream model must learn. +2. **One canonical schema, many sources.** Every repository — structured or not — + is mapped into the same Pydantic KG schema and the same ontology IDs. Sources + link to each other *only* through shared ontology entity nodes. + +## Where intelligence lives + +The pipeline is **deterministic by default**. An LLM (Azure AI Foundry agent) is +used for exactly one job: **structured extraction and normalization of +free-text metadata** from unstructured sources (e.g. GEO, PRIDE). It is *never* +used to write prose/narrative, and it is constrained to emit the canonical +Pydantic schema via `response_format`. If a source already provides structured, +ontology-grounded metadata (e.g. CELLxGENE), there is **no LLM in its path**. + +When you reach for the LLM, ask: "could a deterministic API call or an ontology +lookup do this?" If yes, do that instead. + +## Project layout (target) + +``` +src/parce/ + models/ # Canonical Pydantic KG schema (nodes, edges, sample covariates) + sources/ # One adapter per repository: discover() + fetch() -> RawRecord + normalize/ # RawRecord -> canonical nodes (deterministic OR agent-backed) + ontology/ # Shared free-text -> ontology-ID resolver (+ cache) + agent/ # Azure extraction agent (structured output only) + graph/ # Assemble + merge canonical nodes into one KG + config/ # pydantic-settings configuration + main.py # CLI entry point / orchestrator +tests/ # Unit tests (offline) + integration tests (marked) +docs/ # ARCHITECTURE.md, ROADMAP.md, AGENT_SESSION_PROMPT.md +data_pipelines/ # Future: Spark/ADLS batch jobs +``` + +Dependency direction is one-way: `sources`, `normalize`, `agent`, `graph` all +depend on `models`; `models` depends on nothing. No import cycles. + +## Coding conventions + +- **Python ≥ 3.11**, `src`-layout, `from __future__ import annotations` in every + module. Full type hints on public functions. +- **Pydantic v2** for all data at boundaries; KG models set + `model_config = ConfigDict(extra="forbid")`. +- **Tooling (all enforced in CI):** + - `ruff check .` — lint (rules: E, F, I, UP, B, SIM, C4, RUF). + - `ruff format .` — formatting (line length 100). Never hand-format. + - `mypy src/parce` — types. The stable core is checked; IO modules under + `parce.tools.*` / `parce.agent.*` are temporarily exempt via + `pyproject.toml` overrides. **Remove a module's exemption when you migrate + it to the adapter interface** — do not add new exemptions. + - `pytest -m "not integration"` — unit tests, must stay offline. +- **Tests:** unit tests must not touch the network, Azure, or Census — mock + them. Anything that needs live credentials or downloads is marked + `@pytest.mark.integration` and excluded from CI. +- **Config & secrets:** all config via `pydantic-settings` (`config/settings.py`) + and `.env`. Never hardcode endpoints or commit secrets; update `.env.example` + when you add a setting. `data/` is gitignored. +- **Logging, not printing,** inside library code (stdlib `logging`). `print` is + for CLI user-facing summaries only. +- **Resilience:** network calls to external repos/LLMs use bounded retries with + jittered backoff (see the existing helpers in `main.py`). + +## Environment & commands + +uv-managed. Common commands: + +```bash +uv sync --extra dev # install (incl. dev tools) +uv run parce # run the CLI +uv run ruff check . && uv run ruff format --check . +uv run mypy src/parce +uv run pytest -m "not integration" # unit tests (CI gate) +uv run pytest -m integration # live tests (needs Azure/Census) +uv lock # refresh lockfile after changing deps +``` + +Before opening a PR, all four gates (ruff check, ruff format --check, mypy, +pytest unit) must pass locally — they are exactly what CI runs. + +## Working agreement (every session) + +This repo is built across many short, fresh Claude Code sessions. To stay +coherent: + +1. **Start** by reading `CLAUDE.md`, `docs/ARCHITECTURE.md`, and + `docs/ROADMAP.md`. The roadmap's **"Next up"** marker is your task. +2. **Scope** one roadmap item per session/PR. Work on a feature branch, never + commit directly to `main`. +3. **Finish** by updating `docs/ROADMAP.md`: tick the completed checklist items, + append a dated entry to the **Session Log**, and move the **"Next up"** + marker. The next session relies entirely on this — leave it accurate. +4. Keep `ARCHITECTURE.md` in sync if you change a design decision; record *why*. + +Full session protocol: [docs/AGENT_SESSION_PROMPT.md](docs/AGENT_SESSION_PROMPT.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..b090857 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,141 @@ +# PARCE Architecture + +> Status: design baseline for the `restructure-context-metadata` line of work. +> This document is the source of truth for *why* the code is shaped the way it +> is. Update it (with rationale) whenever a design decision changes. + +## 1. Goal + +Produce a single, ontology-grounded **knowledge graph** of public omics +experiments spanning **multiple modalities**, suitable as training data for a +downstream multi-omics autoregressive model. For each study the KG records: + +- **Biological context** (study-level): assay/technology, tissue, disease, + organism — the *design* variables. +- **Sample-level covariates**: condition, perturbation, timepoint, subject, and + a URI to the raw data for that sample. + +The downstream model treats samples within a study as a **context-conditioned +set** (not a sequence), and links studies across modalities through **shared +context**. Two consequences shape this repo: + +- **Context must be design, not data-inferred outcome.** Cell type, cluster + labels, etc. are excluded — they are downstream of the raw signal and would + leak the learning target. +- **Cross-study/cross-modality linking flows through shared ontology nodes.** A + bulk RNA-seq study and a single-cell study that both touch `MONDO:0005061` + become connected in the graph. This is the data-layer realization of the + model's "shared context space." + +## 2. The modality / structure gradient + +We deliberately ingest sources spanning a gradient of metadata structure, so the +extraction logic has a real but tractable job and the "one KG, many sources" +claim is proven early. + +| Tier | Source | Modality | Metadata | LLM in path? | +|------|--------|----------|----------|--------------| +| Anchor | **CELLxGENE Census** | scRNA-seq | structured, ontology-grounded | No (deterministic) | +| Extraction start | **GEO** (NCBI) | bulk RNA-seq (+ ATAC/ChIP) | semi-structured free text (`characteristics_ch1`) | Yes (extraction agent) | +| Cross-modality | **PRIDE / ProteomeXchange** | proteomics (intensities) | free-text + partial SDRF | Yes (extraction agent) | + +GEO is the first extraction target because (a) its metadata is the canonical +messy case, and (b) it overlaps biologically with CELLxGENE, so cross-source KG +edges appear with only two sources connected. PRIDE then proves modality +generality. (ENCODE is a clean structured alternative if a deterministic second +modality is wanted before proteomics.) + +## 3. Layered design + +``` + ┌─────────────────────────────────────────────┐ + per source → │ SourceAdapter: discover(query) -> [ref] │ + │ fetch(ref) -> RawRecord │ + └───────────────────────┬─────────────────────┘ + │ RawRecord (source-shaped) + ┌───────────────────────▼─────────────────────┐ + per source → │ Normalizer: RawRecord -> canonical nodes │ + │ • structured source -> deterministic map │ + │ • unstructured -> Azure extraction │ + │ agent (response_ │ + │ format = schema) │ + └───────────────────────┬─────────────────────┘ + │ canonical nodes w/ free-text terms + ┌───────────────────────▼─────────────────────┐ + shared → │ OntologyResolver: text -> UBERON/MONDO/... │ + │ deterministic (OLS/text2term) + cache, │ + │ LLM only as fallback for hard cases │ + └───────────────────────┬─────────────────────┘ + │ ontology-grounded nodes + ┌───────────────────────▼─────────────────────┐ + shared → │ GraphBuilder/Merger: assemble + merge into │ + │ one KG; entities deduped by ontology ID │ + └─────────────────────────────────────────────┘ +``` + +### Why this shape + +- **The canonical schema is the contract.** The deterministic path and the + agent path emit the *same* Pydantic models. Per-source variation collapses to + "which adapter + is the normalizer deterministic or agent-backed". Everything + downstream is identical and source-agnostic. +- **The LLM is boxed in.** It lives only inside unstructured normalizers and is + constrained by `response_format` to fill canonical fields — it cannot emit + prose. This keeps the system testable and reproducible, and makes the agent's + output directly comparable to the deterministic path. +- **Ontology resolution is a shared stage, not per-adapter.** All sources must + land on the same IDs or the graph won't link. Deterministic resolvers are + tried first (OLS, text2term); the LLM is a fallback for ambiguous strings. + +## 4. Canonical KG schema (target) + +Source-agnostic nodes (Pydantic v2, `extra="forbid"`): + +- `StudyNode` — `study_id` (DOI/accession), `title`, `source` (provenance), + `modality`. *(No `experimental_narrative`; that field is removed.)* +- `DatasetNode` — `dataset_id`, `data_uri`, `assay`, `cell_count`/size, + parent study. +- `SampleNode` — `sample_id`, `data_uri`, and **design covariates**: `condition`, + `perturbation`, `timepoint`, `subject`, `organism`. (Reintroduced; the prior + schema was dataset-level only.) +- `BiologicalEntityNode` — `entity_type` ∈ {Disease, Tissue, Species, + Perturbation, Assay}, `ontology_id`, `name`. **CellType is intentionally + absent.** +- `GraphEdge` — typed, directed: `EXTRACTED_FROM`, `MEASURED_WITH`, + `HAS_CONDITION`, `STUDIES`, `HAS_SAMPLE`, etc. + +Cross-source links are *emergent*: two studies share an edge target +(`ontology_id`) rather than any source-specific key. + +## 5. Coding style & architecture choices + +- **Language/runtime:** Python ≥ 3.11, `src`-layout, `from __future__ import + annotations` everywhere, full type hints on public APIs. +- **Data modeling:** Pydantic v2 at every boundary; KG models forbid extra + fields. Schemas double as agent `response_format` and downstream validation. +- **Determinism boundary:** library code is pure/deterministic except inside + `normalize` (for unstructured sources) and `agent`. Network IO is isolated in + `sources`/`agent`/`ontology` so the core is unit-testable offline. +- **Interfaces over inheritance:** `SourceAdapter` and `Normalizer` are small + `Protocol`/ABC contracts. Adding a source = new adapter + normalizer, no edits + to orchestration or graph code. +- **Errors & resilience:** external calls use bounded retries with jittered + exponential backoff; transient vs. terminal errors are distinguished. +- **Config:** `pydantic-settings` + `.env`; never hardcode endpoints/secrets. +- **Logging:** stdlib `logging` in libraries; `print` only for CLI summaries. +- **Quality gates (CI-enforced):** `ruff check`, `ruff format --check`, + `mypy src/parce`, `pytest -m "not integration"`. See `pyproject.toml`. +- **Tests:** offline unit tests by default; live/credentialed tests carry the + `integration` marker and are excluded from CI. + +## 6. Open questions (track, don't silently decide) + +- **Sample granularity for CELLxGENE.** Census is per-cell/dataset, not + per-sample in the GEO sense. Defer mapping cxg to `SampleNode` until needed; + keep it dataset-level for now. +- **Ontology resolver dependency.** text2term vs. a thin OLS REST client — + decide when implementing `ontology/` (PR4); prefer the lighter dependency. +- **Graph persistence/export format** for the modeling step (per-study context + + sample manifest + URIs). Specified in a later PR. +- **Multi-omics is core, not optional.** CELLxGENE alone cannot carry the + vision; PRIDE (and beyond) is required, not a nice-to-have. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..5136d7b --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,82 @@ +# PARCE Roadmap + +Living plan. **Each session reads this to find its task and updates it before +finishing.** See [AGENT_SESSION_PROMPT.md](AGENT_SESSION_PROMPT.md) for the +protocol and [ARCHITECTURE.md](ARCHITECTURE.md) for the design. + +--- + +## ▶ Next up + +**PR 2 — Canonical KG schema refactor.** Introduce the source-agnostic schema +(`StudyNode`, `DatasetNode`, `SampleNode`, `BiologicalEntityNode`, `GraphEdge`), +reintroduce sample-level covariates, and **remove the narrative field and the +CellType entity**. Migrate `graph/builder.py` and tests. No new source yet. + +--- + +## PR sequence + +Each PR is one branch, one focused scope, green CI, and a roadmap update. + +- [x] **PR 1 — Foundations & tooling.** CLAUDE.md, docs (architecture, roadmap, + session prompt), GitHub Actions CI (ruff, mypy, pytest), ruff rule set + mypy + config in `pyproject.toml`, code formatted to baseline. No behavior change. +- [ ] **PR 2 — Canonical KG schema.** Source-agnostic nodes/edges; add + `SampleNode` with design covariates; drop `experimental_narrative` and + `CellType`. Migrate builder + tests. *(Next up.)* +- [ ] **PR 3 — Source-adapter interface + cheap CELLxGENE adapter.** Define + `SourceAdapter` / `Normalizer` protocols in `sources/` + `normalize/`. Refactor + CELLxGENE into a deterministic adapter. **Remove the LLM/Azure narrative path + entirely** (delete `agent/prompts.py` narrative role, `models/narrative.py`, + the step-2 block in `main.py`). Drop cell-type extraction. Remove the + `parce.tools.*` mypy exemption as those modules move under `sources/`. +- [ ] **PR 4 — Ontology resolver.** Shared `ontology/` stage: free-text → + UBERON/MONDO/assay IDs (deterministic OLS/text2term + on-disk cache), LLM + fallback for hard cases. Wire into normalizers. +- [ ] **PR 5 — GEO extraction agent (vertical slice).** GEO adapter + (E-utilities/GEOparse) + Azure extraction normalizer emitting the canonical + schema via `response_format`; extract sample covariates from + `characteristics_ch1`. Integration test (marked). This is the agent's real + job; remove the `parce.agent.*` mypy exemption. +- [ ] **PR 6 — Cross-source KG merge.** Merge CELLxGENE + GEO into one graph + linked through shared ontology entities; dedup; provenance on edges. Assert a + cross-source edge exists in tests. +- [ ] **PR 7 — PRIDE proteomics adapter.** Second modality; prove the interface + is modality-general. Adapter + extraction normalizer + integration test. +- [ ] **PR 8 — KG export for modeling.** Serialize per-study context + sample + manifest + data URIs in the form the downstream OQAE/model consumes. + +## Backlog / ideas + +- ENCODE adapter (clean structured epigenomics) if a deterministic second + modality is wanted. +- Imaging modality (IDR / Human Protein Atlas) — needs a different OQAE encoder. +- Graph database backend (Neo4j) vs. flat JSON export — revisit at PR 8. +- Discovery agent: given a research theme, propose seed DOIs/accessions across + repositories. + +--- + +## Session Log + +Newest first. One entry per working session: what changed, decisions made, and +what the next session should know. Keep entries short and factual. + +### 2026-06-23 — PR 1: Foundations & tooling +- Branch `restructure-context-metadata` off `main` (post-cxg-merge). +- Decision: the LLM is repurposed from **narrative writing** to **structured + extraction from unstructured metadata**; Azure is kept to serve that (and as a + learning goal). Deterministic-by-default everywhere else. +- Decision: multi-modality is core. Source gradient chosen: CELLxGENE (anchor, + no LLM) → GEO (first extraction target) → PRIDE (cross-modality proof). +- Decision: context = design variables only; **cell type excluded** (data- + inferred → leakage). Sample-level covariates to be reintroduced into the KG. +- Added: `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/ROADMAP.md`, + `docs/AGENT_SESSION_PROMPT.md`, `.github/workflows/ci.yml`. +- Tooling: ruff rule set (E,F,I,UP,B,SIM,C4,RUF) + `ruff format`; mypy with + pydantic plugin, `src/parce` checked, `parce.tools.*`/`parce.agent.*` + temporarily exempt (remove on migration). Formatted all files. +- Gates green locally: ruff check, ruff format --check, mypy, 43 unit tests. +- **Next session:** PR 2 (canonical KG schema). No code behavior changed yet; + `main.py` still runs the old narrative pipeline until PR 3. diff --git a/pyproject.toml b/pyproject.toml index 3bd15a3..4e4e219 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ dev = [ "pytest", "pytest-asyncio", + "mypy", "ruff", ] @@ -47,3 +48,25 @@ markers = [ [tool.ruff] target-version = "py311" line-length = 100 + +[tool.ruff.lint] +# E/F = pyflakes+pycodestyle, I = isort, UP = pyupgrade, B = bugbear, +# SIM = simplify, C4 = comprehensions, RUF = ruff-specific. +select = ["E", "F", "I", "UP", "B", "SIM", "C4", "RUF"] + +[tool.mypy] +python_version = "3.11" +plugins = ["pydantic.mypy"] +# Third-party omics/agent libs ship no type stubs; don't fail on their imports. +ignore_missing_imports = true +warn_redundant_casts = true +warn_unused_ignores = true +check_untyped_defs = true + +# The IO-boundary modules (source fetchers, Azure agent glue) are slated for +# rewrite in the source-adapter refactor (see docs/ROADMAP.md). Until then they +# are exempt from type checking so CI tracks the stable core. Remove an entry +# here as each module is migrated to the adapter interface. +[[tool.mypy.overrides]] +module = ["parce.tools.*", "parce.agent.*"] +ignore_errors = true diff --git a/src/parce/agent/curator.py b/src/parce/agent/curator.py index e44b2e3..83a8ac6 100644 --- a/src/parce/agent/curator.py +++ b/src/parce/agent/curator.py @@ -11,8 +11,8 @@ from __future__ import annotations +from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import AsyncIterator from agent_framework import Agent from agent_framework.azure import AzureAIAgentClient diff --git a/src/parce/graph/builder.py b/src/parce/graph/builder.py index fafde51..10cec84 100644 --- a/src/parce/graph/builder.py +++ b/src/parce/graph/builder.py @@ -70,18 +70,22 @@ def build_knowledge_graph( for ds in cellxgene_data.get("datasets", []): dataset_id = ds["dataset_id"] - datasets.append(DatasetNode( - dataset_id=dataset_id, - uri=ds["h5ad_uri"], - modality=ds.get("modality", "unknown"), - cell_count=ds["cell_count"], - )) - - edges.append(GraphEdge( - source_id=dataset_id, - target_id=doi, - relation_type="EXTRACTED_FROM", - )) + datasets.append( + DatasetNode( + dataset_id=dataset_id, + uri=ds["h5ad_uri"], + modality=ds.get("modality", "unknown"), + cell_count=ds["cell_count"], + ) + ) + + edges.append( + GraphEdge( + source_id=dataset_id, + target_id=doi, + relation_type="EXTRACTED_FROM", + ) + ) ontology = ds.get("ontology_summary", {}) @@ -110,23 +114,24 @@ def build_knowledge_graph( name=name, ) - edges.append(GraphEdge( - source_id=dataset_id, - target_id=ont_id, - relation_type=relation, - )) + edges.append( + GraphEdge( + source_id=dataset_id, + target_id=ont_id, + relation_type=relation, + ) + ) # Publication -> Species edges for ont_id in species_seen: - if ont_id in _ORGANISM_ONTOLOGY: - resolved_id = _ORGANISM_ONTOLOGY[ont_id][0] - else: - resolved_id = ont_id - edges.append(GraphEdge( - source_id=doi, - target_id=resolved_id, - relation_type="STUDIES", - )) + resolved_id = _ORGANISM_ONTOLOGY[ont_id][0] if ont_id in _ORGANISM_ONTOLOGY else ont_id + edges.append( + GraphEdge( + source_id=doi, + target_id=resolved_id, + relation_type="STUDIES", + ) + ) kg = KnowledgeGraphOutput( publications=[publication], @@ -137,7 +142,9 @@ def build_knowledge_graph( logger.info( "Built KG: publications=%d datasets=%d entities=%d edges=%d", - len(kg.publications), len(kg.datasets), - len(kg.biological_entities), len(kg.edges), + len(kg.publications), + len(kg.datasets), + len(kg.biological_entities), + len(kg.edges), ) return kg diff --git a/src/parce/main.py b/src/parce/main.py index 7686dee..13513f1 100644 --- a/src/parce/main.py +++ b/src/parce/main.py @@ -44,14 +44,13 @@ def _is_transient(exc: BaseException) -> bool: if isinstance(exc, _TRANSIENT_EXCEPTIONS): return True from azure.core.exceptions import HttpResponseError - if isinstance(exc, HttpResponseError) and exc.status_code in _TRANSIENT_STATUS_CODES: - return True - return False + + return isinstance(exc, HttpResponseError) and exc.status_code in _TRANSIENT_STATUS_CODES def _backoff_delay(attempt: int) -> float: """Exponential backoff with full jitter.""" - delay = min(_BASE_DELAY * (2 ** attempt), _MAX_DELAY) + delay = min(_BASE_DELAY * (2**attempt), _MAX_DELAY) return random.uniform(0, delay) @@ -68,7 +67,9 @@ def _build_narrative_prompt(paper_data: dict, cellxgene_data: dict) -> str: lines.append(f"\n## CELLxGENE Datasets ({len(datasets)} total)") for ds in datasets: ontology = ds.get("ontology_summary", {}) - parts = [f"- **{ds['dataset_id']}**: {ds.get('modality', '?')}, {ds['cell_count']:,} cells"] + parts = [ + f"- **{ds['dataset_id']}**: {ds.get('modality', '?')}, {ds['cell_count']:,} cells" + ] for category in ("cell_types", "tissues", "diseases", "assays"): terms = ontology.get(category, []) if terms: @@ -177,7 +178,10 @@ async def run(doi: str = _DEFAULT_DOI) -> None: delay = _backoff_delay(attempt) logger.warning( "Transient error on attempt %d/%d, retrying in %.1fs: %s", - attempt + 1, settings.max_retries, delay, exc, + attempt + 1, + settings.max_retries, + delay, + exc, ) await asyncio.sleep(delay) continue diff --git a/src/parce/models/graph_schema.py b/src/parce/models/graph_schema.py index bc9bff5..bdf316e 100644 --- a/src/parce/models/graph_schema.py +++ b/src/parce/models/graph_schema.py @@ -28,7 +28,9 @@ class PublicationNode(BaseModel): model_config = ConfigDict(extra="forbid") - doi: str = Field(..., description="Digital Object Identifier (e.g. 10.1038/s41586-023-05869-0).") + doi: str = Field( + ..., description="Digital Object Identifier (e.g. 10.1038/s41586-023-05869-0)." + ) title: str = Field(..., description="Publication title.") abstract: str = Field(..., description="Full abstract text.") experimental_narrative: str = Field( @@ -79,7 +81,9 @@ class GraphEdge(BaseModel): model_config = ConfigDict(extra="forbid") - source_id: str = Field(..., description="Identifier of the source node (DOI or dataset_id or ontology_id).") + source_id: str = Field( + ..., description="Identifier of the source node (DOI or dataset_id or ontology_id)." + ) target_id: str = Field(..., description="Identifier of the target node.") relation_type: str = Field( ..., diff --git a/src/parce/models/narrative.py b/src/parce/models/narrative.py index e682cac..122a7c0 100644 --- a/src/parce/models/narrative.py +++ b/src/parce/models/narrative.py @@ -40,7 +40,9 @@ class SampleRecord(BaseModel): sample_id: str = Field(..., description="Repository sample accession (e.g. GSM1234567).") organism: str = Field(..., description="Species name (e.g. Mus musculus).") strain: str | None = Field(default=None, description="Strain or genetic background.") - cell_type: str | None = Field(default=None, description="Cell type profiled (e.g. CD8+ T cell).") + cell_type: str | None = Field( + default=None, description="Cell type profiled (e.g. CD8+ T cell)." + ) tissue: str | None = Field(default=None, description="Tissue of origin (e.g. spleen).") condition: str | None = Field( default=None, diff --git a/src/parce/tools/cellxgene_fetcher.py b/src/parce/tools/cellxgene_fetcher.py index de3207c..8d99502 100644 --- a/src/parce/tools/cellxgene_fetcher.py +++ b/src/parce/tools/cellxgene_fetcher.py @@ -72,7 +72,10 @@ def _summarise_ontology_terms(census, dataset_id: str) -> dict: elapsed = time.perf_counter() - t0 logger.info( "Census obs loaded dataset_id=%s organism=%s rows=%d (%.2fs)", - dataset_id, organism, len(obs), elapsed, + dataset_id, + organism, + len(obs), + elapsed, ) if obs.empty: continue @@ -92,7 +95,9 @@ def _summarise_ontology_terms(census, dataset_id: str) -> dict: last_error = f"{type(exc).__name__}: {exc}" logger.info( "Failed get_obs for dataset_id=%s organism=%s (%s)", - dataset_id, organism, last_error, + dataset_id, + organism, + last_error, ) continue @@ -119,7 +124,8 @@ def _process_single_dataset(census, row) -> dict: h5ad_uri = uri_info["uri"] logger.info( "Resolved H5AD URI dataset_id=%s (%.2fs)", - dataset_id, time.perf_counter() - t_uri, + dataset_id, + time.perf_counter() - t_uri, ) except Exception: h5ad_uri = f"s3://cellxgene-data-public/cell-census/h5ads/{dataset_id}.h5ad" @@ -151,7 +157,8 @@ def fetch_cellxgene_datasets(doi: str, *, max_workers: int = 4) -> dict: datasets_df = census["census_info"]["datasets"].read().concat().to_pandas() logger.info( "Loaded Census datasets table rows=%d (%.2fs)", - len(datasets_df), time.perf_counter() - t0, + len(datasets_df), + time.perf_counter() - t0, ) matched = datasets_df[datasets_df["collection_doi"] == doi] @@ -160,7 +167,9 @@ def fetch_cellxgene_datasets(doi: str, *, max_workers: int = 4) -> dict: return {"doi": doi, "datasets": [], "error": f"No datasets found for DOI {doi}"} total = len(matched) - logger.info("Matched DOI=%s datasets=%d, processing with %d workers", doi, total, max_workers) + logger.info( + "Matched DOI=%s datasets=%d, processing with %d workers", doi, total, max_workers + ) rows = [row for _, row in matched.iterrows()] @@ -180,7 +189,9 @@ def fetch_cellxgene_datasets(doi: str, *, max_workers: int = 4) -> dict: logger.info( "CELLxGENE fetch complete DOI=%s datasets=%d total_time=%.2fs", - doi, len(results), time.perf_counter() - t_all, + doi, + len(results), + time.perf_counter() - t_all, ) return {"doi": doi, "datasets": results} finally: diff --git a/src/parce/tools/ncbi_fetcher.py b/src/parce/tools/ncbi_fetcher.py index f05349d..f3a8de3 100644 --- a/src/parce/tools/ncbi_fetcher.py +++ b/src/parce/tools/ncbi_fetcher.py @@ -40,7 +40,12 @@ def fetch_paper_metadata(doi: str) -> dict: results = data.get("resultList", {}).get("result", []) if not results: logger.warning("No publication found for DOI=%s", doi) - return {"doi": doi, "title": "", "abstract": "", "error": f"No publication found for DOI {doi}"} + return { + "doi": doi, + "title": "", + "abstract": "", + "error": f"No publication found for DOI {doi}", + } paper = results[0] return { diff --git a/tests/test_builder.py b/tests/test_builder.py index fe3832a..fd81939 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -5,7 +5,6 @@ from parce.graph.builder import build_knowledge_graph from parce.models.graph_schema import EntityType - _PAPER_DATA = { "doi": "10.1234/test", "title": "Test Study", @@ -148,5 +147,6 @@ def test_roundtrip_json(self): kg = build_knowledge_graph(_PAPER_DATA, _CELLXGENE_DATA, _NARRATIVE) json_str = kg.model_dump_json() from parce.models.graph_schema import KnowledgeGraphOutput + restored = KnowledgeGraphOutput.model_validate_json(json_str) assert restored == kg diff --git a/tests/test_cellxgene_integration.py b/tests/test_cellxgene_integration.py index c6dde85..a87443a 100644 --- a/tests/test_cellxgene_integration.py +++ b/tests/test_cellxgene_integration.py @@ -68,7 +68,6 @@ async def test_fetch_cellxgene_datasets_structured_terms(self): async def test_modality_populated(self): payload = fetch_cellxgene_datasets(_TEST_DOI) any_modality = any( - ds.get("modality") and ds["modality"] != "unknown" - for ds in payload["datasets"] + ds.get("modality") and ds["modality"] != "unknown" for ds in payload["datasets"] ) assert any_modality, "At least one dataset should have a known modality" diff --git a/tests/test_graph_schema.py b/tests/test_graph_schema.py index de63559..39d75b2 100644 --- a/tests/test_graph_schema.py +++ b/tests/test_graph_schema.py @@ -62,9 +62,7 @@ def test_valid(self): def test_extra_forbidden(self): with pytest.raises(ValidationError): - DatasetNode( - dataset_id="x", uri="s3://x", modality="m", cell_count=1, oops=True - ) + DatasetNode(dataset_id="x", uri="s3://x", modality="m", cell_count=1, oops=True) class TestBiologicalEntityNode: @@ -86,9 +84,7 @@ def test_string_coercion(self): def test_invalid_entity_type(self): with pytest.raises(ValidationError): - BiologicalEntityNode( - entity_type="NotAType", ontology_id="X", name="bad" - ) + BiologicalEntityNode(entity_type="NotAType", ontology_id="X", name="bad") def test_extra_forbidden(self): with pytest.raises(ValidationError): diff --git a/tests/test_integration.py b/tests/test_integration.py index 312bb4d..8ecdf1d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -34,9 +34,7 @@ class TestAzureCredential: async def test_credential_get_token(self): """AzureCliCredential can obtain a token for the Azure AI scope.""" async with AzureCliCredential() as credential: - token = await credential.get_token( - "https://cognitiveservices.azure.com/.default" - ) + token = await credential.get_token("https://cognitiveservices.azure.com/.default") assert token.token assert len(token.token) > 0 diff --git a/tests/test_orchestration.py b/tests/test_orchestration.py index 499c220..e2f1ab2 100644 --- a/tests/test_orchestration.py +++ b/tests/test_orchestration.py @@ -13,7 +13,6 @@ from parce.main import _build_narrative_prompt, run - _MOCK_PAPER = { "doi": "10.1234/mock", "title": "Mock Study", @@ -98,6 +97,7 @@ async def test_full_pipeline(self, _mock_tools, _mock_agent, tmp_path): assert out_file.exists() import json + kg = json.loads(out_file.read_text()) assert len(kg["publications"]) == 1 assert kg["publications"][0]["doi"] == "10.1234/mock" diff --git a/uv.lock b/uv.lock index 1b6596f..1d37e0b 100644 --- a/uv.lock +++ b/uv.lock @@ -575,6 +575,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/d3/54cd560804a8c2b898824778e86c13c2a14600bc83532a9c4f69f2f469c3/array_api_compat-1.14.0-py3-none-any.whl", hash = "sha256:ed5af1f9b6595a199c942505f281ec994892556b6efc24679a0501e87a7d6279", size = 60124, upload-time = "2026-02-26T12:02:41.127Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -1823,6 +1847,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl", hash = "sha256:5a8ea50e3e3bcbcdec3447b77034fd0d32cb2cf4089db799238708e4d7e0098d", size = 10182, upload-time = "2025-11-03T13:21:11.102Z" }, ] +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, +] + [[package]] name = "llvmlite" version = "0.46.0" @@ -2180,6 +2251,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "natsort" version = "8.4.0" @@ -2502,6 +2619,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -2514,6 +2632,7 @@ requires-dist = [ { name = "azure-ai-projects", specifier = ">=2.0.1" }, { name = "azure-identity", specifier = ">=1.25.3" }, { name = "cellxgene-census", specifier = ">=1.17.0" }, + { name = "mypy", marker = "extra == 'dev'" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'" }, @@ -2524,6 +2643,15 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "patsy" version = "1.0.2" From 6eb2fc9ade4b428db7ddfd3a4ad811756cf4759a Mon Sep 17 00:00:00 2001 From: mengerj Date: Tue, 23 Jun 2026 10:03:25 +0200 Subject: [PATCH 2/3] Make orchestration tests hermetic; fix CI failure The TestRunOrchestration tests called run() which constructs Settings() with a required AZURE_AI_PROJECT_ENDPOINT and no default. They only passed locally because a developer .env supplied it; on CI (no .env) they raised ValidationError. Patch parce.main.Settings via an autouse fixture so the mocked-agent tests never read the environment. Document the hermeticity requirement in CLAUDE.md (verify unit tests with .env moved aside). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 6 +++++- tests/test_orchestration.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b8706ef..09d3008 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,11 @@ depend on `models`; `models` depends on nothing. No import cycles. - `pytest -m "not integration"` — unit tests, must stay offline. - **Tests:** unit tests must not touch the network, Azure, or Census — mock them. Anything that needs live credentials or downloads is marked - `@pytest.mark.integration` and excluded from CI. + `@pytest.mark.integration` and excluded from CI. **Unit tests must not depend + on a local `.env`** — no unit test may construct `Settings()` unmocked. + Verify hermeticity before reporting green: run the unit suite with `.env` + moved aside (`mv .env .env.bak && uv run pytest -m "not integration"; mv + .env.bak .env`), which reproduces the CI runner exactly. - **Config & secrets:** all config via `pydantic-settings` (`config/settings.py`) and `.env`. Never hardcode endpoints or commit secrets; update `.env.example` when you add a setting. `data/` is gitignored. diff --git a/tests/test_orchestration.py b/tests/test_orchestration.py index e2f1ab2..c03b10d 100644 --- a/tests/test_orchestration.py +++ b/tests/test_orchestration.py @@ -64,6 +64,16 @@ def test_empty_datasets(self): class TestRunOrchestration: + @pytest.fixture(autouse=True) + def _mock_settings(self): + # The agent is mocked, so real Azure config is irrelevant here. Patch + # Settings so the test never reads the environment / .env file and stays + # hermetic (CI has no .env). max_retries must be a real int for range(). + fake = MagicMock() + fake.max_retries = 3 + with patch("parce.main.Settings", return_value=fake): + yield fake + @pytest.fixture def _mock_tools(self): with ( From 9f8403ca3b65c04acfa9404f226d5c30c118f399 Mon Sep 17 00:00:00 2001 From: mengerj Date: Tue, 23 Jun 2026 10:08:06 +0200 Subject: [PATCH 3/3] Bump CI actions to checkout@v5 and setup-uv@v6 Silences the Node 20 deprecation warning (v4/v5 were being force-run on Node 24). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73aba26..7b859a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,10 @@ jobs: matrix: python-version: ["3.11", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true