From f92fbc823e3a041cb65cfa108c82fc31dec323f1 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Wed, 3 Dec 2025 13:15:16 +0000 Subject: [PATCH 01/48] CI pipeline --- .github/workflows/ci.yml | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..926703c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff mypy + pip install -e ".[dev]" + - name: Run Ruff linter + run: ruff check src/ + - name: Run Ruff formatter check + run: ruff format --check src/ + - name: Run MyPy + run: mypy src/omophub + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run unit tests with coverage + run: | + pytest tests/unit --cov=omophub --cov-report=xml --cov-report=term-missing + - name: Upload coverage to Codecov + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false + + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run integration tests + env: + TEST_API_KEY: ${{ secrets.TEST_API_KEY }} + run: | + pytest tests/integration -v From a213047a5a8ac6e535ee532c10d7e82dd2cf4041 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Wed, 3 Dec 2025 13:15:59 +0000 Subject: [PATCH 02/48] Publish action --- .github/workflows/publish.yml | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1d37f44 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for hatch-vcs to get version from tags + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: release-dists + path: dist/ + + publish: + runs-on: ubuntu-latest + needs: build + environment: + name: pypi + url: https://pypi.org/project/omophub/ + permissions: + id-token: write # Required for trusted publishing + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From c289a9f735ac740dd95b395c47af946aa2bf8f40 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Wed, 3 Dec 2025 13:23:20 +0000 Subject: [PATCH 03/48] Refactor type hinting for vocabulary_ids to use builtins.list for consistency --- src/omophub/resources/vocabularies.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/omophub/resources/vocabularies.py b/src/omophub/resources/vocabularies.py index 456cfda..6edcf5b 100644 --- a/src/omophub/resources/vocabularies.py +++ b/src/omophub/resources/vocabularies.py @@ -2,6 +2,7 @@ from __future__ import annotations +import builtins from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -92,7 +93,7 @@ def stats(self, vocabulary_id: str) -> VocabularyStats: def domains( self, *, - vocabulary_ids: list[str] | None = None, + vocabulary_ids: builtins.list[str] | None = None, page: int = 1, page_size: int = 50, ) -> dict[str, Any]: @@ -202,7 +203,7 @@ async def stats(self, vocabulary_id: str) -> VocabularyStats: async def domains( self, *, - vocabulary_ids: list[str] | None = None, + vocabulary_ids: builtins.list[str] | None = None, page: int = 1, page_size: int = 50, ) -> dict[str, Any]: From 2d4673b3781fb8695c9a060a3995f9b26cecda85 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Wed, 3 Dec 2025 13:28:04 +0000 Subject: [PATCH 04/48] Import builtins conditionally in type checking for improved clarity --- src/omophub/resources/vocabularies.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/omophub/resources/vocabularies.py b/src/omophub/resources/vocabularies.py index 6edcf5b..e37dc0b 100644 --- a/src/omophub/resources/vocabularies.py +++ b/src/omophub/resources/vocabularies.py @@ -2,10 +2,11 @@ from __future__ import annotations -import builtins from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + import builtins + from .._request import AsyncRequest, Request from ..types.vocabulary import Vocabulary, VocabularyStats From e4aa3a25f4ab6af44267d9e7fb5ba7cb9839c917 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Thu, 4 Dec 2025 17:02:33 +0000 Subject: [PATCH 05/48] Codecov settings in CI --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 926703c..f3fd817 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,8 @@ jobs: with: files: ./coverage.xml fail_ci_if_error: false - + token: ${{ secrets.CODECOV_TOKEN }} + integration: runs-on: ubuntu-latest steps: From b7d0202f0f60a8ede23e5db31f194b7a7ab7df5d Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Thu, 4 Dec 2025 17:19:49 +0000 Subject: [PATCH 06/48] Update README.md to include Codecov badge and change License badge color --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9733ec1..37af929 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # OMOPHub Python SDK +[![Codecov test coverage](https://codecov.io/gh/omopHub/omophub-python/branch/main/graph/badge.svg)](https://app.codecov.io/gh/omopHub/omophub-python?branch=main) [![PyPI version](https://badge.fury.io/py/omophub.svg)](https://badge.fury.io/py/omophub) [![Python Versions](https://img.shields.io/pypi/pyversions/omophub.svg)](https://pypi.org/project/omophub/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) The official Python SDK for [OMOPHub](https://omophub.com) - a medical vocabulary API providing access to OHDSI ATHENA standardized vocabularies including SNOMED CT, ICD-10, RxNorm, LOINC, and 90+ other medical terminologies. From fb404ad70b25638b047673a8ec5051b50b63a551 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Tue, 9 Dec 2025 18:09:55 +0000 Subject: [PATCH 07/48] Update CHANGELOG for v0.2.0: add parameters to `concepts.get_by_code()` for synonyms and relationships, and update User-Agent header format. --- CHANGELOG.md | 13 +++++++++- src/omophub/_http.py | 4 +-- src/omophub/resources/concepts.py | 42 ++++++++++++++++++++++++++++--- tests/conftest.py | 10 ++++++++ tests/unit/test_http.py | 4 +-- 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7746d..6ed374c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2025-12-09 + +### Added + +- `include_synonyms` and `include_relationships` parameters to `concepts.get_by_code()` method for retrieving concept synonyms and relationships in a single request. + +### Changed + +- User-Agent header updated to `OMOPHub-SDK-Python/{version}`. + ## [0.1.0] - 2025-12-01 ### Added @@ -27,5 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full type hints and PEP 561 compliance - HTTP/2 support via httpx -[Unreleased]: https://github.com/omopHub/omophub-python/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/omopHub/omophub-python/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/omopHub/omophub-python/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/omopHub/omophub-python/releases/tag/v0.1.0 diff --git a/src/omophub/_http.py b/src/omophub/_http.py index abab848..94392ce 100644 --- a/src/omophub/_http.py +++ b/src/omophub/_http.py @@ -103,7 +103,7 @@ def _get_default_headers(self) -> dict[str, str]: return { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": f"omophub-python/{get_version()}", + "User-Agent": f"OMOPHub-SDK-Python/{get_version()}", } def request( @@ -186,7 +186,7 @@ def _get_default_headers(self) -> dict[str, str]: return { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": f"omophub-python/{get_version()}", + "User-Agent": f"OMOPHub-SDK-Python/{get_version()}", } async def request( diff --git a/src/omophub/resources/concepts.py b/src/omophub/resources/concepts.py index d197a4e..76e8a65 100644 --- a/src/omophub/resources/concepts.py +++ b/src/omophub/resources/concepts.py @@ -94,17 +94,31 @@ def get_by_code( self, vocabulary_id: str, concept_code: str, + *, + include_relationships: bool = False, + include_synonyms: bool = False, ) -> Concept: """Get a concept by vocabulary and code. Args: vocabulary_id: The vocabulary ID (e.g., "SNOMED", "ICD10CM") concept_code: The concept code within the vocabulary + include_relationships: Include related concepts + include_synonyms: Include concept synonyms Returns: - The concept data with mappings + The concept data with optional relationships and synonyms """ - return self._request.get(f"/concepts/by-code/{vocabulary_id}/{concept_code}") + params: dict[str, Any] = {} + if include_relationships: + params["include_relationships"] = "true" + if include_synonyms: + params["include_synonyms"] = "true" + + return self._request.get( + f"/concepts/by-code/{vocabulary_id}/{concept_code}", + params=params or None, + ) def batch( self, @@ -274,10 +288,30 @@ async def get_by_code( self, vocabulary_id: str, concept_code: str, + *, + include_relationships: bool = False, + include_synonyms: bool = False, ) -> Concept: - """Get a concept by vocabulary and code.""" + """Get a concept by vocabulary and code. + + Args: + vocabulary_id: The vocabulary ID (e.g., "SNOMED", "ICD10CM") + concept_code: The concept code within the vocabulary + include_relationships: Include related concepts + include_synonyms: Include concept synonyms + + Returns: + The concept data with optional relationships and synonyms + """ + params: dict[str, Any] = {} + if include_relationships: + params["include_relationships"] = "true" + if include_synonyms: + params["include_synonyms"] = "true" + return await self._request.get( - f"/concepts/by-code/{vocabulary_id}/{concept_code}" + f"/concepts/by-code/{vocabulary_id}/{concept_code}", + params=params or None, ) async def batch( diff --git a/tests/conftest.py b/tests/conftest.py index 2af7506..b5233ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import time from typing import Any import pytest @@ -135,6 +136,15 @@ def mock_error_response() -> dict[str, Any]: } +@pytest.fixture(autouse=True) +def rate_limit_delay(request: pytest.FixtureRequest) -> None: + """Add delay between integration tests to avoid rate limiting.""" + yield + # Only delay for integration tests + if "integration" in request.keywords: + time.sleep(1) + + # Well-known test concept IDs for integration tests DIABETES_CONCEPT_ID = 201826 # Type 2 diabetes mellitus (SNOMED) ASPIRIN_CONCEPT_ID = 1112807 # Aspirin (RxNorm) diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 694fefc..781db40 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -201,7 +201,7 @@ def test_default_headers(self) -> None: assert "Content-Type" in headers assert headers["Content-Type"] == "application/json" assert "User-Agent" in headers - assert "omophub-python" in headers["User-Agent"] + assert "OMOPHub-SDK-Python" in headers["User-Agent"] client.close() @@ -350,7 +350,7 @@ async def test_default_headers(self) -> None: assert "Accept" in headers assert headers["Accept"] == "application/json" assert "User-Agent" in headers - assert "omophub-python" in headers["User-Agent"] + assert "OMOPHub-SDK-Python" in headers["User-Agent"] await client.close() From 3897dd4115ade9cffa14d79a65e6ab4d0bd9a1c0 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Tue, 9 Dec 2025 18:16:03 +0000 Subject: [PATCH 08/48] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ed374c..193d82c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.2.0] - 2025-12-09 +## [1.2.0] - 2025-12-09 ### Added From b162d13b69955be7f8129630e01f1fbbf890da4b Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 22 Dec 2025 12:05:56 +0000 Subject: [PATCH 09/48] Add website link to README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 37af929..ff229f2 100644 --- a/README.md +++ b/README.md @@ -252,3 +252,4 @@ MIT License - see [LICENSE](LICENSE) for details. - [GitHub Issues](https://github.com/omopHub/omophub-python/issues) - [Documentation](https://docs.omophub.com) +- [Website](https://omophub.com) From b4f560bbfd235ce435b398cafabc27341d7a6c16 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 27 Dec 2025 11:28:21 +0000 Subject: [PATCH 10/48] Readme updates --- README.md | 306 +++++++++++++++++++++++++++--------------------------- 1 file changed, 151 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index ff229f2..1a9b9f1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,31 @@ # OMOPHub Python SDK -[![Codecov test coverage](https://codecov.io/gh/omopHub/omophub-python/branch/main/graph/badge.svg)](https://app.codecov.io/gh/omopHub/omophub-python?branch=main) -[![PyPI version](https://badge.fury.io/py/omophub.svg)](https://badge.fury.io/py/omophub) +**Query millions standardized medical concepts via simple Python API** + +Access SNOMED CT, ICD-10, RxNorm, LOINC, and 90+ OHDSI ATHENA vocabularies without downloading, installing, or maintaining local databases. + +[![PyPI version](https://badge.fury.io/py/omophub.svg)](https://pypi.org/project/omophub/) [![Python Versions](https://img.shields.io/pypi/pyversions/omophub.svg)](https://pypi.org/project/omophub/) +[![Codecov](https://codecov.io/gh/omopHub/omophub-python/branch/main/graph/badge.svg)](https://app.codecov.io/gh/omopHub/omophub-python?branch=main) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -The official Python SDK for [OMOPHub](https://omophub.com) - a medical vocabulary API providing access to OHDSI ATHENA standardized vocabularies including SNOMED CT, ICD-10, RxNorm, LOINC, and 90+ other medical terminologies. +**[Documentation](https://docs.omophub.com/sdks/python/overview)** · +**[API Reference](https://docs.omophub.com/api-reference)** · +**[Examples](https://github.com/omopHub/omophub-python/tree/main/examples)** + +--- + +## Why OMOPHub? + +Working with OHDSI ATHENA vocabularies traditionally requires downloading multi-gigabyte files, setting up a PostgreSQL database, and writing complex SQL queries. **OMOPHub eliminates this friction.** + +| Traditional Approach | With OMOPHub | +|---------------------|--------------| +| Download 5GB+ ATHENA vocabulary files | `pip install omophub` | +| Set up and maintain database | One API call | +| Write complex SQL with multiple JOINs | Simple Python methods | +| Manually update vocabularies quarterly | Always current data | +| Local infrastructure required | Works anywhere Python runs | ## Installation @@ -16,188 +36,115 @@ pip install omophub ## Quick Start ```python -import omophub +from omophub import OMOPHub -# Initialize the client -client = omophub.OMOPHub(api_key="oh_xxxxxxxxx") +# Initialize client (uses OMOPHUB_API_KEY env variable, or pass api_key="...") +client = OMOPHub() # Get a concept by ID concept = client.concepts.get(201826) print(concept["concept_name"]) # "Type 2 diabetes mellitus" -# Search for concepts -results = client.search.basic("diabetes", vocabulary_ids=["SNOMED", "ICD10CM"]) -for concept in results["concepts"]: - print(f"{concept['concept_id']}: {concept['concept_name']}") +# Search for concepts across vocabularies +results = client.search.basic("metformin", vocabulary_ids=["RxNorm"], domain_ids=["Drug"]) +for c in results["concepts"]: + print(f"{c['concept_id']}: {c['concept_name']}") -# Get concept ancestors -ancestors = client.hierarchy.ancestors(201826, max_levels=3) +# Map ICD-10 code to SNOMED +mappings = client.mappings.get_by_code("ICD10CM", "E11.9", target_vocabularies=["SNOMED"]) -# Map concepts between vocabularies -mappings = client.mappings.get(201826, target_vocabularies=["ICD10CM"]) +# Navigate concept hierarchy +ancestors = client.hierarchy.ancestors(201826, max_levels=3) ``` -## Async Usage +## Async Support ```python -import omophub import asyncio +from omophub import AsyncOMOPHub async def main(): - async with omophub.AsyncOMOPHub(api_key="oh_xxx") as client: + async with AsyncOMOPHub() as client: concept = await client.concepts.get(201826) print(concept["concept_name"]) asyncio.run(main()) ``` -## Configuration +## Use Cases -### API Key +### ETL & Data Pipelines -Set your API key in one of three ways: +Validate and map clinical codes during OMOP CDM transformations: ```python -# 1. Pass directly to client -client = omophub.OMOPHub(api_key="oh_xxxxxxxxx") - -# 2. Set environment variable -# export OMOPHUB_API_KEY=oh_xxxxxxxxx -client = omophub.OMOPHub() - -# 3. Set module-level variable -import omophub -omophub.api_key = "oh_xxxxxxxxx" -client = omophub.OMOPHub() +# Validate that a source code exists and find its standard equivalent +def validate_and_map(source_vocab, source_code): + concept = client.concepts.get_by_code(source_vocab, source_code) + if concept["standard_concept"] != "S": + mappings = client.mappings.get(concept["concept_id"], + target_vocabularies=["SNOMED"]) + return mappings["mappings"][0]["target_concept_id"] + return concept["concept_id"] ``` -Get your API key from the [OMOPHub Dashboard](https://dashboard.omophub.com/api-keys). +### Data Quality Checks -### Additional Options +Verify codes exist and are valid standard concepts: ```python -client = omophub.OMOPHub( - api_key="oh_xxx", - base_url="https://api.omophub.com/v1", # API base URL - timeout=30.0, # Request timeout in seconds - max_retries=3, # Retry attempts for failed requests - vocab_version="2025.2", # Specific vocabulary version -) +# Check if all your condition codes are valid +condition_codes = ["E11.9", "I10", "J44.9"] # ICD-10 codes +for code in condition_codes: + try: + concept = client.concepts.get_by_code("ICD10CM", code) + print(f"OK {code}: {concept['concept_name']}") + except omophub.NotFoundError: + print(f"ERROR {code}: Invalid code!") ``` -## Resources +### Phenotype Development -### Concepts +Explore hierarchies to build comprehensive concept sets: ```python -# Get concept by ID -concept = client.concepts.get(201826) - -# Get concept by vocabulary code -concept = client.concepts.get_by_code("SNOMED", "73211009") - -# Batch get concepts -result = client.concepts.batch([201826, 4329847, 73211009]) - -# Get autocomplete suggestions -suggestions = client.concepts.suggest("diab", vocabulary="SNOMED", limit=10) - -# Get related concepts -related = client.concepts.related(201826, relatedness_types=["hierarchical", "semantic"]) - -# Get concept relationships -relationships = client.concepts.relationships(201826) +# Get all descendants of "Type 2 diabetes mellitus" for phenotype +descendants = client.hierarchy.descendants(201826, max_levels=5, standard_only=True) +concept_set = [d["concept_id"] for d in descendants["concepts"]] +print(f"Found {len(concept_set)} concepts for T2DM phenotype") ``` -### Search - -```python -# Basic search -results = client.search.basic( - "heart attack", - vocabulary_ids=["SNOMED"], - domain_ids=["Condition"], - page=1, - page_size=20, -) - -# Advanced search with facets -results = client.search.advanced( - "myocardial infarction", - vocabularies=["SNOMED", "ICD10CM"], - standard_concepts_only=True, -) - -# Semantic search -results = client.search.semantic("chest pain with shortness of breath") - -# Fuzzy search (typo-tolerant) -results = client.search.fuzzy("diabetis") # finds "diabetes" - -# Auto-pagination iterator -for concept in client.search.basic_iter("diabetes", page_size=100): - print(concept["concept_name"]) -``` - -### Hierarchy - -```python -# Get ancestors -ancestors = client.hierarchy.ancestors( - 201826, - max_levels=5, - relationship_types=["Is a"], -) - -# Get descendants -descendants = client.hierarchy.descendants( - 201826, - max_levels=3, - standard_only=True, -) -``` +### Clinical Applications -### Mappings +Build terminology lookups into healthcare applications: ```python -# Get mappings for a concept -mappings = client.mappings.get( - 201826, - target_vocabularies=["ICD10CM", "Read"], - include_mapping_quality=True, -) - -# Map concepts to target vocabulary -result = client.mappings.map( - source_concepts=[201826, 4329847], - target_vocabulary="ICD10CM", -) +# Autocomplete for clinical coding interface +suggestions = client.concepts.suggest("diab", vocabulary="SNOMED", limit=10) +# Returns: ["Diabetes mellitus", "Diabetic nephropathy", "Diabetic retinopathy", ...] ``` -### Vocabularies - -```python -# List all vocabularies -vocabularies = client.vocabularies.list(include_stats=True) - -# Get vocabulary details -snomed = client.vocabularies.get("SNOMED", include_domains=True) +## API Resources -# Get vocabulary statistics -stats = client.vocabularies.stats("SNOMED") -``` +| Resource | Description | Key Methods | +|----------|-------------|-------------| +| `concepts` | Concept lookup and batch operations | `get()`, `get_by_code()`, `batch()`, `suggest()` | +| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `fuzzy()` | +| `hierarchy` | Navigate concept relationships | `ancestors()`, `descendants()` | +| `mappings` | Cross-vocabulary mappings | `get()`, `map()` | +| `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` | +| `domains` | Domain information | `list()`, `get()`, `concepts()` | -### Domains +## Configuration ```python -# List all domains -domains = client.domains.list(include_statistics=True) - -# Get domain details -condition = client.domains.get("Condition") - -# Get concepts in a domain -concepts = client.domains.concepts("Drug", standard_only=True) +client = OMOPHub( + api_key="oh_xxx", # Or set OMOPHUB_API_KEY env var + base_url="https://api.omophub.com/v1", # API endpoint + timeout=30.0, # Request timeout (seconds) + max_retries=3, # Retry attempts + vocab_version="2025.2", # Specific vocabulary version +) ``` ## Error Handling @@ -206,50 +153,99 @@ concepts = client.domains.concepts("Drug", standard_only=True) import omophub try: - client = omophub.OMOPHub(api_key="oh_xxx") concept = client.concepts.get(999999999) except omophub.NotFoundError as e: print(f"Concept not found: {e.message}") except omophub.AuthenticationError as e: - print(f"Authentication failed: {e.message}") + print(f"Check your API key: {e.message}") except omophub.RateLimitError as e: print(f"Rate limited. Retry after {e.retry_after} seconds") -except omophub.ValidationError as e: - print(f"Invalid request: {e.message}") except omophub.APIError as e: print(f"API error {e.status_code}: {e.message}") -except omophub.OMOPHubError as e: - print(f"SDK error: {e.message}") ``` -## Type Hints +## Type Safety -The SDK is fully typed with TypedDict definitions for all API responses: +The SDK is fully typed with TypedDict definitions for IDE autocomplete: ```python from omophub import OMOPHub, Concept -client = OMOPHub(api_key="oh_xxx") +client = OMOPHub() concept: Concept = client.concepts.get(201826) # IDE autocomplete works for all fields -print(concept["concept_id"]) -print(concept["concept_name"]) -print(concept["vocabulary_id"]) +concept["concept_id"] # int +concept["concept_name"] # str +concept["vocabulary_id"] # str +concept["domain_id"] # str +concept["concept_class_id"] # str +``` + +## Integration Examples + +### With Pandas + +```python +import pandas as pd + +# Search and load into DataFrame +results = client.search.basic("hypertension", page_size=100) +df = pd.DataFrame(results["concepts"]) +print(df[["concept_id", "concept_name", "vocabulary_id"]].head()) +``` + +### In Jupyter Notebooks + +```python +# Iterate through all results with auto-pagination +for concept in client.search.basic_iter("diabetes", page_size=100): + process_concept(concept) ``` +## Compared to Alternatives + +| Feature | OMOPHub SDK | ATHENA Download | OHDSI WebAPI | +|---------|-------------|-----------------|--------------| +| Setup time | 1 minute | Hours | Hours | +| Infrastructure | None | Database required | Full OHDSI stack | +| Updates | Automatic | Manual download | Manual | +| Programmatic access | Native Python | SQL queries | REST API | + +**Best for:** Teams who need quick, programmatic access to OMOP vocabularies without infrastructure overhead. + ## Documentation - [Full Documentation](https://docs.omophub.com/sdks/python/overview) - [API Reference](https://docs.omophub.com/api-reference) - [Examples](https://github.com/omopHub/omophub-python/tree/main/examples) +- [Get API Key](https://dashboard.omophub.com/api-keys) -## License +## Contributing -MIT License - see [LICENSE](LICENSE) for details. +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +```bash +# Clone and install for development +git clone https://github.com/omopHub/omophub-python.git +cd omophub-python +pip install -e ".[dev]" + +# Run tests +pytest +``` ## Support - [GitHub Issues](https://github.com/omopHub/omophub-python/issues) -- [Documentation](https://docs.omophub.com) -- [Website](https://omophub.com) +- [GitHub Discussions](https://github.com/omopHub/omophub-python/discussions) +- Email: support@omophub.com +- Website: [omophub.com](https://omophub.com) + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +--- + +*Built for the OHDSI community* From b336c96fb0fcbfa915e4661a2c94fb34a2f61ec4 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 27 Dec 2025 23:19:38 +0000 Subject: [PATCH 11/48] Documentation --- CONTRIBUTING.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dba1590 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,160 @@ +# Contributing to OMOPHub Python SDK + +First off, thank you for considering contributing to OMOPHub! + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the [existing issues](https://github.com/OMOPHub/omophub-python/issues) to avoid duplicates. + +When creating a bug report, please include: + +- **Python version** (`python --version`) +- **SDK version** (`pip show omophub`) +- **Operating system** +- **Minimal code example** that reproduces the issue +- **Full error traceback** +- **Expected vs actual behavior** + +### Suggesting Features + +Feature requests are welcome! Please open an issue with: + +- Clear description of the feature +- Use case: why would this be useful? +- Possible implementation approach (optional) + +### Pull Requests + +1. **Fork the repository** and create your branch from `main` +2. **Install development dependencies:** + ```bash + git clone https://github.com/YOUR_USERNAME/omophub-python.git + cd omophub-python + pip install -e ".[dev]" + ``` +3. **Make your changes** with clear, descriptive commits +4. **Add tests** for new functionality +5. **Run the test suite:** + ```bash + pytest + ``` +6. **Ensure code style compliance:** + ```bash + ruff check . + ruff format . + mypy src/ + ``` +7. **Update documentation** if needed +8. **Submit a pull request** with a clear description + +## Development Setup + +### Prerequisites + +- Python 3.9+ +- pip + +### Installation + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/omophub-python.git +cd omophub-python + +# Create virtual environment +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install in development mode +pip install -e ".[dev]" +``` + +### Running Tests + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=omophub --cov-report=html + +# Run specific test file +pytest tests/test_concepts.py + +# Run tests matching a pattern +pytest -k "test_search" +``` + +### Code Style + +We use: +- **Ruff** for linting and formatting +- **mypy** for type checking + +```bash +# Check linting +ruff check . + +# Auto-format code +ruff format . + +# Type checking +mypy src/ +``` + +## Project Structure + +``` +omophub-python/ +├── src/omophub/ +│ ├── __init__.py # Public API exports +│ ├── client.py # OMOPHub client class +│ ├── resources/ # API resource classes +│ │ ├── concepts.py +│ │ ├── search.py +│ │ ├── hierarchy.py +│ │ └── ... +│ ├── types.py # TypedDict definitions +│ └── exceptions.py # Custom exceptions +├── tests/ +│ ├── test_concepts.py +│ ├── test_search.py +│ └── ... +├── examples/ +│ └── ... +└── pyproject.toml +``` + +## Commit Messages + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` New feature +- `fix:` Bug fix +- `docs:` Documentation changes +- `test:` Adding or updating tests +- `refactor:` Code refactoring +- `chore:` Maintenance tasks + +Examples: +``` +feat: add semantic search endpoint +fix: handle rate limit errors correctly +docs: update README with new examples +test: add tests for batch concept lookup +``` + +## Questions? + +- Open a [GitHub Discussion](https://github.com/OMOPHub/omophub-python/discussions) +- Email: support@omophub.com + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. + +--- + +Thank you for helping make OMOPHub better! From 52d6f5191952835694ea4761e50d28a041b4340d Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 29 Dec 2025 17:23:22 +0000 Subject: [PATCH 12/48] Corrections --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a9b9f1..7e485f0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Access SNOMED CT, ICD-10, RxNorm, LOINC, and 90+ OHDSI ATHENA vocabularies witho ## Why OMOPHub? -Working with OHDSI ATHENA vocabularies traditionally requires downloading multi-gigabyte files, setting up a PostgreSQL database, and writing complex SQL queries. **OMOPHub eliminates this friction.** +Working with OHDSI ATHENA vocabularies traditionally requires downloading multi-gigabyte files, setting up a database instance, and writing complex SQL queries. **OMOPHub eliminates this friction.** | Traditional Approach | With OMOPHub | |---------------------|--------------| From f37ba3a1645f127ad9226b88caf70bcf2e2bb31b Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Tue, 6 Jan 2026 09:57:44 +0000 Subject: [PATCH 13/48] v1.3.0 preparation --- CHANGELOG.md | 40 +- examples/navigate_hierarchy.py | 2 +- src/omophub/resources/concepts.py | 409 ++++++++++----- src/omophub/resources/domains.py | 94 +--- src/omophub/resources/hierarchy.py | 188 ++++--- src/omophub/resources/mappings.py | 154 ++---- src/omophub/resources/relationships.py | 142 ++---- src/omophub/resources/search.py | 94 ++-- src/omophub/resources/vocabularies.py | 191 +++---- src/omophub/types/vocabulary.py | 19 +- tests/integration/test_async.py | 2 +- tests/integration/test_domains.py | 29 +- tests/integration/test_hierarchy.py | 16 +- tests/integration/test_search.py | 4 +- tests/integration/test_vocabularies.py | 12 +- tests/unit/resources/test_domains.py | 88 ++-- tests/unit/resources/test_hierarchy.py | 53 +- tests/unit/resources/test_relationships.py | 63 +-- tests/unit/resources/test_search.py | 22 +- tests/unit/resources/test_vocabularies.py | 38 +- uv.lock | 563 +++++++++++++++++++++ 21 files changed, 1441 insertions(+), 782 deletions(-) create mode 100644 uv.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 193d82c..c8414d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2026-01-06 + +### Changes + +**Parameter Renames (for API consistency):** +- `search.autocomplete()`: `max_suggestions` → `page_size` +- `concepts.suggest()`: `vocabulary` → `vocabulary_ids`, `domain` → `domain_ids`, `limit` → `page_size` +- `concepts.related()`: `relatedness_types` → `relationship_types` +- `concepts.relationships()`: `relationship_type` → `relationship_ids` +- `relationships.get()`: `relationship_type` → `relationship_ids`, `target_vocabulary` → `vocabulary_ids` +- `hierarchy.ancestors()`: `vocabulary_id` → `vocabulary_ids`, `include_deprecated` → `include_invalid` +- `hierarchy.descendants()`: `vocabulary_id` → `vocabulary_ids`, `include_deprecated` → `include_invalid` + +**Simplified APIs (removed parameters):** +- `vocabularies.get()`: Removed `include_stats`, `include_domains` (use `stats()` method instead) +- `vocabularies.domains()`: Removed pagination parameters, now returns all domains +- `domains.list()`: Simplified to single `include_stats` parameter +- `domains.concepts()`: Removed `concept_class_ids`, added `include_invalid` +- `mappings.get()`: Simplified to `target_vocabulary`, `include_invalid`, `vocab_release` +- `relationships.types()`: Removed all filtering parameters + +**Default Changes:** +- `vocabularies.list()`: Default `page_size` changed from 100 to 20 +- `concepts.batch()`: Default `standard_only` changed from `False` to `True` + +### Added + +- `vocabularies.domain_stats(vocabulary_id, domain_id)` - Get statistics for a specific domain within a vocabulary +- `vocabularies.concept_classes()` - Get all concept classes +- `hierarchy.get(concept_id)` - Get complete hierarchy (ancestors and descendants) in one call +- `vocab_release` parameter to `concepts.get()`, `concepts.get_by_code()`, `mappings.get()`, `mappings.map()` +- `include_hierarchy` parameter to `concepts.get()` and `concepts.get_by_code()` +- Pagination support to `concepts.suggest()` +- `domain_ids`, `standard_only`, `include_reverse` parameters to `relationships.get()` + ## [1.2.0] - 2025-12-09 ### Added @@ -37,6 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full type hints and PEP 561 compliance - HTTP/2 support via httpx -[Unreleased]: https://github.com/omopHub/omophub-python/compare/v0.2.0...HEAD -[0.2.0]: https://github.com/omopHub/omophub-python/compare/v0.1.0...v0.2.0 +[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/omopHub/omophub-python/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/omopHub/omophub-python/compare/v0.1.0...v1.2.0 [0.1.0]: https://github.com/omopHub/omophub-python/releases/tag/v0.1.0 diff --git a/examples/navigate_hierarchy.py b/examples/navigate_hierarchy.py index fcdffa6..c47935c 100644 --- a/examples/navigate_hierarchy.py +++ b/examples/navigate_hierarchy.py @@ -40,7 +40,7 @@ def get_descendants() -> None: result = client.hierarchy.descendants( concept_id, max_levels=2, - standard_only=True, + include_invalid=False, ) concept = result.get("concept", {}) diff --git a/src/omophub/resources/concepts.py b/src/omophub/resources/concepts.py index 76e8a65..1809ba6 100644 --- a/src/omophub/resources/concepts.py +++ b/src/omophub/resources/concepts.py @@ -32,21 +32,19 @@ class SuggestParams(TypedDict, total=False): """Parameters for concept suggestions.""" query: str - vocabulary: str - domain: str - limit: int + page: int + page_size: int + vocabulary_ids: list[str] + domain_ids: list[str] + vocab_release: str class RelatedParams(TypedDict, total=False): """Parameters for related concepts.""" - relatedness_types: list[str] - vocabulary_ids: list[str] - domain_ids: list[str] - min_relatedness_score: float - max_results: int - include_scores: bool - standard_concepts_only: bool + relationship_types: list[str] + min_score: float + page_size: int class RelationshipsParams(TypedDict, total=False): @@ -71,13 +69,17 @@ def get( *, include_relationships: bool = False, include_synonyms: bool = False, + include_hierarchy: bool = False, + vocab_release: str | None = None, ) -> Concept: """Get a concept by ID. Args: concept_id: The OMOP concept ID - include_relationships: Include related concepts + include_relationships: Include related concepts (parents/children) include_synonyms: Include concept synonyms + include_hierarchy: Include hierarchy information + vocab_release: Specific vocabulary release (e.g., "2025.2") Returns: The concept data @@ -87,6 +89,10 @@ def get( params["include_relationships"] = "true" if include_synonyms: params["include_synonyms"] = "true" + if include_hierarchy: + params["include_hierarchy"] = "true" + if vocab_release: + params["vocab_release"] = vocab_release return self._request.get(f"/concepts/{concept_id}", params=params or None) @@ -97,14 +103,18 @@ def get_by_code( *, include_relationships: bool = False, include_synonyms: bool = False, + include_hierarchy: bool = False, + vocab_release: str | None = None, ) -> Concept: """Get a concept by vocabulary and code. Args: vocabulary_id: The vocabulary ID (e.g., "SNOMED", "ICD10CM") concept_code: The concept code within the vocabulary - include_relationships: Include related concepts + include_relationships: Include related concepts (parents/children) include_synonyms: Include concept synonyms + include_hierarchy: Include hierarchy information + vocab_release: Specific vocabulary release (e.g., "2025.2") Returns: The concept data with optional relationships and synonyms @@ -114,6 +124,10 @@ def get_by_code( params["include_relationships"] = "true" if include_synonyms: params["include_synonyms"] = "true" + if include_hierarchy: + params["include_hierarchy"] = "true" + if vocab_release: + params["vocab_release"] = vocab_release return self._request.get( f"/concepts/by-code/{vocabulary_id}/{concept_code}", @@ -128,17 +142,17 @@ def batch( include_synonyms: bool = False, include_mappings: bool = False, vocabulary_filter: list[str] | None = None, - standard_only: bool = False, + standard_only: bool = True, ) -> BatchConceptResult: """Get multiple concepts by IDs. Args: - concept_ids: List of concept IDs (max 1000) + concept_ids: List of concept IDs (max 100) include_relationships: Include related concepts include_synonyms: Include concept synonyms include_mappings: Include concept mappings vocabulary_filter: Filter results to specific vocabularies - standard_only: Only return standard concepts + standard_only: Only return standard concepts (default True) Returns: Batch result with concepts and any failures @@ -161,26 +175,32 @@ def suggest( self, query: str, *, - vocabulary: str | None = None, - domain: str | None = None, - limit: int = 10, - ) -> list[Suggestion]: + page: int = 1, + page_size: int = 10, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + vocab_release: str | None = None, + ) -> dict[str, Any]: """Get concept suggestions (autocomplete). Args: - query: Search query (min 2 characters) - vocabulary: Filter to specific vocabulary - domain: Filter to specific domain - limit: Maximum suggestions (default 10, max 50) + query: Search query (min 2 characters, max 100 characters) + page: Page number (default 1) + page_size: Number of suggestions per page (default 10, max 100) + vocabulary_ids: Filter to specific vocabularies (e.g., ["SNOMED", "ICD10CM"]) + domain_ids: Filter to specific domains (e.g., ["Condition", "Drug"]) + vocab_release: Specific vocabulary release (e.g., "2025.2") Returns: - List of suggestions + Paginated response with suggestions and pagination metadata """ - params: dict[str, Any] = {"query": query, "limit": limit} - if vocabulary: - params["vocabulary"] = vocabulary - if domain: - params["domain"] = domain + params: dict[str, Any] = {"query": query, "page": page, "page_size": page_size} + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if vocab_release: + params["vocab_release"] = vocab_release return self._request.get("/concepts/suggest", params=params) @@ -188,43 +208,30 @@ def related( self, concept_id: int, *, - relatedness_types: list[str] | None = None, - vocabulary_ids: list[str] | None = None, - domain_ids: list[str] | None = None, - min_relatedness_score: float | None = None, - max_results: int = 50, - include_scores: bool = True, - standard_concepts_only: bool = False, + relationship_types: list[str] | None = None, + min_score: float | None = None, + page_size: int = 20, + vocab_release: str | None = None, ) -> dict[str, Any]: """Get related concepts. Args: concept_id: The source concept ID - relatedness_types: Types of relatedness (hierarchical, semantic, etc.) - vocabulary_ids: Filter to specific vocabularies - domain_ids: Filter to specific domains - min_relatedness_score: Minimum relatedness score - max_results: Maximum results (default 50, max 200) - include_scores: Include score breakdown - standard_concepts_only: Only return standard concepts + relationship_types: Filter by relationship types (e.g., ["Is a", "Maps to"]) + min_score: Minimum relationship score (0.0-1.0) + page_size: Maximum number of results (default 20, max 100) + vocab_release: Specific vocabulary release (e.g., "2025.1") Returns: - Related concepts with scores and analysis + Related concepts with relationship scores """ - params: dict[str, Any] = { - "max_results": max_results, - "include_scores": "true" if include_scores else "false", - } - if relatedness_types: - params["relatedness_types"] = ",".join(relatedness_types) - if vocabulary_ids: - params["vocabulary_ids"] = ",".join(vocabulary_ids) - if domain_ids: - params["domain_ids"] = ",".join(domain_ids) - if min_relatedness_score is not None: - params["min_relatedness_score"] = min_relatedness_score - if standard_concepts_only: - params["standard_concepts_only"] = "true" + params: dict[str, Any] = {"page_size": page_size} + if relationship_types: + params["relationship_types"] = ",".join(relationship_types) + if min_score is not None: + params["min_score"] = min_score + if vocab_release: + params["vocab_release"] = vocab_release return self._request.get(f"/concepts/{concept_id}/related", params=params) @@ -232,35 +239,98 @@ def relationships( self, concept_id: int, *, - relationship_type: str | None = None, - target_vocabulary: str | None = None, + relationship_ids: str | list[str] | None = None, + vocabulary_ids: str | list[str] | None = None, + domain_ids: str | list[str] | None = None, include_invalid: bool = False, - page: int = 1, - page_size: int = 20, + standard_only: bool = False, + include_reverse: bool = False, + vocab_release: str | None = None, ) -> dict[str, Any]: """Get concept relationships. Args: concept_id: The concept ID - relationship_type: Filter by relationship type - target_vocabulary: Filter by target vocabulary - include_invalid: Include invalid relationships - page: Page number - page_size: Items per page + relationship_ids: Filter by relationship type IDs (string or list) + vocabulary_ids: Filter by target vocabulary IDs (string or list) + domain_ids: Filter by target domain IDs (string or list) + include_invalid: Include relationships to invalid concepts + standard_only: Only include relationships to standard concepts + include_reverse: Include reverse relationships + vocab_release: Specific vocabulary release version Returns: - Relationships with summary + Relationships data """ - params: dict[str, Any] = {"page": page, "page_size": page_size} - if relationship_type: - params["relationship_type"] = relationship_type - if target_vocabulary: - params["target_vocabulary"] = target_vocabulary + params: dict[str, Any] = {} + if relationship_ids: + params["relationship_ids"] = ( + ",".join(relationship_ids) + if isinstance(relationship_ids, list) + else relationship_ids + ) + if vocabulary_ids: + params["vocabulary_ids"] = ( + ",".join(vocabulary_ids) + if isinstance(vocabulary_ids, list) + else vocabulary_ids + ) + if domain_ids: + params["domain_ids"] = ( + ",".join(domain_ids) if isinstance(domain_ids, list) else domain_ids + ) if include_invalid: params["include_invalid"] = "true" + if standard_only: + params["standard_only"] = "true" + if include_reverse: + params["include_reverse"] = "true" + if vocab_release: + params["vocab_release"] = vocab_release return self._request.get(f"/concepts/{concept_id}/relationships", params=params) + def recommended( + self, + concept_ids: list[int], + *, + relationship_types: list[str] | None = None, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_only: bool = True, + include_invalid: bool = False, + page: int = 1, + page_size: int = 100, + ) -> dict[str, Any]: + """Get recommended concepts using OHDSI Phoebe algorithm. + + Args: + concept_ids: List of source concept IDs (1-100) + relationship_types: Filter by relationship types (max 20) + vocabulary_ids: Filter to specific vocabularies (max 50) + domain_ids: Filter to specific domains (max 50) + standard_only: Only return standard concepts (default True) + include_invalid: Include invalid/deprecated concepts (default False) + page: Page number (default 1) + page_size: Results per page (default 100, max 1000) + + Returns: + Recommendations grouped by source concept ID with pagination metadata + """ + body: dict[str, Any] = {"concept_ids": concept_ids} + if relationship_types: + body["relationship_types"] = relationship_types + if vocabulary_ids: + body["vocabulary_ids"] = vocabulary_ids + if domain_ids: + body["domain_ids"] = domain_ids + body["standard_only"] = standard_only + body["include_invalid"] = include_invalid + body["page"] = page + body["page_size"] = page_size + + return self._request.post("/concepts/recommended", json_data=body) + class AsyncConcepts: """Asynchronous concepts resource.""" @@ -274,13 +344,30 @@ async def get( *, include_relationships: bool = False, include_synonyms: bool = False, + include_hierarchy: bool = False, + vocab_release: str | None = None, ) -> Concept: - """Get a concept by ID.""" + """Get a concept by ID. + + Args: + concept_id: The OMOP concept ID + include_relationships: Include related concepts (parents/children) + include_synonyms: Include concept synonyms + include_hierarchy: Include hierarchy information + vocab_release: Specific vocabulary release (e.g., "2025.2") + + Returns: + The concept data + """ params: dict[str, Any] = {} if include_relationships: params["include_relationships"] = "true" if include_synonyms: params["include_synonyms"] = "true" + if include_hierarchy: + params["include_hierarchy"] = "true" + if vocab_release: + params["vocab_release"] = vocab_release return await self._request.get(f"/concepts/{concept_id}", params=params or None) @@ -291,14 +378,18 @@ async def get_by_code( *, include_relationships: bool = False, include_synonyms: bool = False, + include_hierarchy: bool = False, + vocab_release: str | None = None, ) -> Concept: """Get a concept by vocabulary and code. Args: vocabulary_id: The vocabulary ID (e.g., "SNOMED", "ICD10CM") concept_code: The concept code within the vocabulary - include_relationships: Include related concepts + include_relationships: Include related concepts (parents/children) include_synonyms: Include concept synonyms + include_hierarchy: Include hierarchy information + vocab_release: Specific vocabulary release (e.g., "2025.2") Returns: The concept data with optional relationships and synonyms @@ -308,6 +399,10 @@ async def get_by_code( params["include_relationships"] = "true" if include_synonyms: params["include_synonyms"] = "true" + if include_hierarchy: + params["include_hierarchy"] = "true" + if vocab_release: + params["vocab_release"] = vocab_release return await self._request.get( f"/concepts/by-code/{vocabulary_id}/{concept_code}", @@ -322,9 +417,9 @@ async def batch( include_synonyms: bool = False, include_mappings: bool = False, vocabulary_filter: list[str] | None = None, - standard_only: bool = False, + standard_only: bool = True, ) -> BatchConceptResult: - """Get multiple concepts by IDs.""" + """Get multiple concepts by IDs (max 100).""" body: dict[str, Any] = {"concept_ids": concept_ids} if include_relationships: body["include_relationships"] = True @@ -343,16 +438,32 @@ async def suggest( self, query: str, *, - vocabulary: str | None = None, - domain: str | None = None, - limit: int = 10, - ) -> list[Suggestion]: - """Get concept suggestions (autocomplete).""" - params: dict[str, Any] = {"query": query, "limit": limit} - if vocabulary: - params["vocabulary"] = vocabulary - if domain: - params["domain"] = domain + page: int = 1, + page_size: int = 10, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + vocab_release: str | None = None, + ) -> dict[str, Any]: + """Get concept suggestions (autocomplete). + + Args: + query: Search query (min 2 characters, max 100 characters) + page: Page number (default 1) + page_size: Number of suggestions per page (default 10, max 100) + vocabulary_ids: Filter to specific vocabularies (e.g., ["SNOMED", "ICD10CM"]) + domain_ids: Filter to specific domains (e.g., ["Condition", "Drug"]) + vocab_release: Specific vocabulary release (e.g., "2025.2") + + Returns: + Paginated response with suggestions and pagination metadata + """ + params: dict[str, Any] = {"query": query, "page": page, "page_size": page_size} + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if vocab_release: + params["vocab_release"] = vocab_release return await self._request.get("/concepts/suggest", params=params) @@ -360,29 +471,30 @@ async def related( self, concept_id: int, *, - relatedness_types: list[str] | None = None, - vocabulary_ids: list[str] | None = None, - domain_ids: list[str] | None = None, - min_relatedness_score: float | None = None, - max_results: int = 50, - include_scores: bool = True, - standard_concepts_only: bool = False, + relationship_types: list[str] | None = None, + min_score: float | None = None, + page_size: int = 20, + vocab_release: str | None = None, ) -> dict[str, Any]: - """Get related concepts.""" - params: dict[str, Any] = { - "max_results": max_results, - "include_scores": "true" if include_scores else "false", - } - if relatedness_types: - params["relatedness_types"] = ",".join(relatedness_types) - if vocabulary_ids: - params["vocabulary_ids"] = ",".join(vocabulary_ids) - if domain_ids: - params["domain_ids"] = ",".join(domain_ids) - if min_relatedness_score is not None: - params["min_relatedness_score"] = min_relatedness_score - if standard_concepts_only: - params["standard_concepts_only"] = "true" + """Get related concepts. + + Args: + concept_id: The source concept ID + relationship_types: Filter by relationship types (e.g., ["Is a", "Maps to"]) + min_score: Minimum relationship score (0.0-1.0) + page_size: Maximum number of results (default 20, max 100) + vocab_release: Specific vocabulary release (e.g., "2025.1") + + Returns: + Related concepts with relationship scores + """ + params: dict[str, Any] = {"page_size": page_size} + if relationship_types: + params["relationship_types"] = ",".join(relationship_types) + if min_score is not None: + params["min_score"] = min_score + if vocab_release: + params["vocab_release"] = vocab_release return await self._request.get(f"/concepts/{concept_id}/related", params=params) @@ -390,21 +502,82 @@ async def relationships( self, concept_id: int, *, - relationship_type: str | None = None, - target_vocabulary: str | None = None, + relationship_ids: str | list[str] | None = None, + vocabulary_ids: str | list[str] | None = None, + domain_ids: str | list[str] | None = None, include_invalid: bool = False, - page: int = 1, - page_size: int = 20, + standard_only: bool = False, + include_reverse: bool = False, + vocab_release: str | None = None, ) -> dict[str, Any]: """Get concept relationships.""" - params: dict[str, Any] = {"page": page, "page_size": page_size} - if relationship_type: - params["relationship_type"] = relationship_type - if target_vocabulary: - params["target_vocabulary"] = target_vocabulary + params: dict[str, Any] = {} + if relationship_ids: + params["relationship_ids"] = ( + ",".join(relationship_ids) + if isinstance(relationship_ids, list) + else relationship_ids + ) + if vocabulary_ids: + params["vocabulary_ids"] = ( + ",".join(vocabulary_ids) + if isinstance(vocabulary_ids, list) + else vocabulary_ids + ) + if domain_ids: + params["domain_ids"] = ( + ",".join(domain_ids) if isinstance(domain_ids, list) else domain_ids + ) if include_invalid: params["include_invalid"] = "true" + if standard_only: + params["standard_only"] = "true" + if include_reverse: + params["include_reverse"] = "true" + if vocab_release: + params["vocab_release"] = vocab_release return await self._request.get( f"/concepts/{concept_id}/relationships", params=params ) + + async def recommended( + self, + concept_ids: list[int], + *, + relationship_types: list[str] | None = None, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_only: bool = True, + include_invalid: bool = False, + page: int = 1, + page_size: int = 100, + ) -> dict[str, Any]: + """Get recommended concepts using OHDSI Phoebe algorithm. + + Args: + concept_ids: List of source concept IDs (1-100) + relationship_types: Filter by relationship types (max 20) + vocabulary_ids: Filter to specific vocabularies (max 50) + domain_ids: Filter to specific domains (max 50) + standard_only: Only return standard concepts (default True) + include_invalid: Include invalid/deprecated concepts (default False) + page: Page number (default 1) + page_size: Results per page (default 100, max 1000) + + Returns: + Recommendations grouped by source concept ID with pagination metadata + """ + body: dict[str, Any] = {"concept_ids": concept_ids} + if relationship_types: + body["relationship_types"] = relationship_types + if vocabulary_ids: + body["vocabulary_ids"] = vocabulary_ids + if domain_ids: + body["domain_ids"] = domain_ids + body["standard_only"] = standard_only + body["include_invalid"] = include_invalid + body["page"] = page + body["page_size"] = page_size + + return await self._request.post("/concepts/recommended", json_data=body) diff --git a/src/omophub/resources/domains.py b/src/omophub/resources/domains.py index e37d826..d0ed192 100644 --- a/src/omophub/resources/domains.py +++ b/src/omophub/resources/domains.py @@ -19,46 +19,19 @@ def __init__(self, request: Request[Any]) -> None: def list( self, *, - vocabulary_ids: builtins.list[str] | None = None, - include_concept_counts: bool = True, - include_statistics: bool = False, - include_examples: bool = False, - standard_only: bool = False, - active_only: bool = True, - sort_by: str = "domain_id", - sort_order: str = "asc", + include_stats: bool = False, ) -> dict[str, Any]: """List all domains. Args: - vocabulary_ids: Filter by vocabularies - include_concept_counts: Include concept counts - include_statistics: Include detailed statistics - include_examples: Include example concepts - standard_only: Only standard concepts - active_only: Only active domains - sort_by: Sort field - sort_order: Sort order + include_stats: Include concept counts and vocabulary coverage Returns: - Domain list with summary + Domain list """ - params: dict[str, Any] = { - "sort_by": sort_by, - "sort_order": sort_order, - } - if vocabulary_ids: - params["vocabulary_ids"] = ",".join(vocabulary_ids) - if include_concept_counts: - params["include_concept_counts"] = "true" - if include_statistics: - params["include_statistics"] = "true" - if include_examples: - params["include_examples"] = "true" - if standard_only: - params["standard_only"] = "true" - if not active_only: - params["active_only"] = "false" + params: dict[str, Any] = {} + if include_stats: + params["include_stats"] = "true" return self._request.get("/domains", params=params) @@ -67,8 +40,8 @@ def concepts( domain_id: str, *, vocabulary_ids: builtins.list[str] | None = None, - concept_class_ids: builtins.list[str] | None = None, standard_only: bool = False, + include_invalid: bool = False, page: int = 1, page_size: int = 50, ) -> dict[str, Any]: @@ -77,8 +50,8 @@ def concepts( Args: domain_id: The domain ID vocabulary_ids: Filter by vocabularies - concept_class_ids: Filter by concept classes standard_only: Only standard concepts + include_invalid: Include invalid/deprecated concepts page: Page number page_size: Results per page @@ -88,10 +61,10 @@ def concepts( params: dict[str, Any] = {"page": page, "page_size": page_size} if vocabulary_ids: params["vocabulary_ids"] = ",".join(vocabulary_ids) - if concept_class_ids: - params["concept_class_ids"] = ",".join(concept_class_ids) if standard_only: params["standard_only"] = "true" + if include_invalid: + params["include_invalid"] = "true" return self._request.get(f"/domains/{domain_id}/concepts", params=params) @@ -105,46 +78,19 @@ def __init__(self, request: AsyncRequest[Any]) -> None: async def list( self, *, - vocabulary_ids: builtins.list[str] | None = None, - include_concept_counts: bool = True, - include_statistics: bool = False, - include_examples: bool = False, - standard_only: bool = False, - active_only: bool = True, - sort_by: str = "domain_id", - sort_order: str = "asc", + include_stats: bool = False, ) -> dict[str, Any]: """List all domains. Args: - vocabulary_ids: Filter by vocabularies - include_concept_counts: Include concept counts - include_statistics: Include detailed statistics - include_examples: Include example concepts - standard_only: Only standard concepts - active_only: Only active domains - sort_by: Sort field - sort_order: Sort order + include_stats: Include concept counts and vocabulary coverage Returns: - Domain list with summary + Domain list """ - params: dict[str, Any] = { - "sort_by": sort_by, - "sort_order": sort_order, - } - if vocabulary_ids: - params["vocabulary_ids"] = ",".join(vocabulary_ids) - if include_concept_counts: - params["include_concept_counts"] = "true" - if include_statistics: - params["include_statistics"] = "true" - if include_examples: - params["include_examples"] = "true" - if standard_only: - params["standard_only"] = "true" - if not active_only: - params["active_only"] = "false" + params: dict[str, Any] = {} + if include_stats: + params["include_stats"] = "true" return await self._request.get("/domains", params=params) @@ -153,8 +99,8 @@ async def concepts( domain_id: str, *, vocabulary_ids: builtins.list[str] | None = None, - concept_class_ids: builtins.list[str] | None = None, standard_only: bool = False, + include_invalid: bool = False, page: int = 1, page_size: int = 50, ) -> dict[str, Any]: @@ -163,8 +109,8 @@ async def concepts( Args: domain_id: The domain ID vocabulary_ids: Filter by vocabularies - concept_class_ids: Filter by concept classes standard_only: Only standard concepts + include_invalid: Include invalid/deprecated concepts page: Page number page_size: Results per page @@ -174,9 +120,9 @@ async def concepts( params: dict[str, Any] = {"page": page, "page_size": page_size} if vocabulary_ids: params["vocabulary_ids"] = ",".join(vocabulary_ids) - if concept_class_ids: - params["concept_class_ids"] = ",".join(concept_class_ids) if standard_only: params["standard_only"] = "true" + if include_invalid: + params["include_invalid"] = "true" return await self._request.get(f"/domains/{domain_id}/concepts", params=params) diff --git a/src/omophub/resources/hierarchy.py b/src/omophub/resources/hierarchy.py index 3d2eaf3..5b7e793 100644 --- a/src/omophub/resources/hierarchy.py +++ b/src/omophub/resources/hierarchy.py @@ -14,17 +14,61 @@ class Hierarchy: def __init__(self, request: Request[Any]) -> None: self._request = request + def get( + self, + concept_id: int, + *, + format: str = "flat", + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + max_levels: int = 10, + max_results: int | None = None, + relationship_types: list[str] | None = None, + include_invalid: bool = False, + ) -> dict[str, Any]: + """Get complete concept hierarchy (ancestors and descendants). + + Args: + concept_id: The concept ID + format: Response format - "flat" (default) or "graph" for visualization + vocabulary_ids: Filter to specific vocabularies (e.g., ["SNOMED", "ICD10CM"]) + domain_ids: Filter to specific domains (e.g., ["Condition", "Drug"]) + max_levels: Maximum hierarchy levels to traverse in both directions (default 10) + max_results: Maximum results per direction for performance optimization + relationship_types: Relationship types to follow (default: "Is a") + include_invalid: Include deprecated/invalid concepts (default: False) + + Returns: + For flat format: ancestors, descendants arrays with level/total counts + For graph format: nodes and edges arrays for visualization + """ + params: dict[str, Any] = { + "format": format, + "max_levels": min(max_levels, 20), + } + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if max_results is not None: + params["max_results"] = max_results + if relationship_types: + params["relationship_types"] = ",".join(relationship_types) + if include_invalid: + params["include_invalid"] = "true" + + return self._request.get(f"/concepts/{concept_id}/hierarchy", params=params) + def ancestors( self, concept_id: int, *, - vocabulary_id: str | None = None, + vocabulary_ids: list[str] | None = None, max_levels: int | None = None, relationship_types: list[str] | None = None, include_paths: bool = False, include_distance: bool = True, - standard_only: bool = False, - include_deprecated: bool = False, + include_invalid: bool = False, page: int = 1, page_size: int = 100, ) -> dict[str, Any]: @@ -32,22 +76,21 @@ def ancestors( Args: concept_id: The concept ID - vocabulary_id: Filter to specific vocabulary + vocabulary_ids: Filter to specific vocabularies (e.g., ["SNOMED", "ICD10CM"]) max_levels: Maximum hierarchy levels to traverse relationship_types: Relationship types to follow (default: "Is a") - include_paths: Include path information - include_distance: Include distance from source - standard_only: Only return standard concepts - include_deprecated: Include deprecated concepts + include_paths: Include path_length field for each ancestor + include_distance: Include hierarchy_level field for each ancestor + include_invalid: Include deprecated/invalid concepts (default: False) page: Page number page_size: Results per page Returns: - Ancestors with hierarchy summary + Ancestors with hierarchy_summary and pagination metadata """ params: dict[str, Any] = {"page": page, "page_size": page_size} - if vocabulary_id: - params["vocabulary_id"] = vocabulary_id + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) if max_levels is not None: params["max_levels"] = max_levels if relationship_types: @@ -56,10 +99,8 @@ def ancestors( params["include_paths"] = "true" if include_distance: params["include_distance"] = "true" - if standard_only: - params["standard_only"] = "true" - if include_deprecated: - params["include_deprecated"] = "true" + if include_invalid: + params["include_invalid"] = "true" return self._request.get(f"/concepts/{concept_id}/ancestors", params=params) @@ -67,15 +108,13 @@ def descendants( self, concept_id: int, *, - vocabulary_id: str | None = None, + vocabulary_ids: list[str] | None = None, max_levels: int = 10, relationship_types: list[str] | None = None, include_distance: bool = True, - standard_only: bool = False, - include_deprecated: bool = False, + include_paths: bool = False, + include_invalid: bool = False, domain_ids: list[str] | None = None, - concept_class_ids: list[str] | None = None, - include_synonyms: bool = False, page: int = 1, page_size: int = 100, ) -> dict[str, Any]: @@ -83,42 +122,36 @@ def descendants( Args: concept_id: The concept ID - vocabulary_id: Filter to specific vocabulary - max_levels: Maximum hierarchy levels (default 10, max 10) - relationship_types: Relationship types to follow - include_distance: Include distance from source - standard_only: Only return standard concepts - include_deprecated: Include deprecated concepts - domain_ids: Filter by domains - concept_class_ids: Filter by concept classes - include_synonyms: Include synonyms + vocabulary_ids: Filter to specific vocabularies (e.g., ["SNOMED", "ICD10CM"]) + max_levels: Maximum hierarchy levels (default 10, max 20) + relationship_types: Relationship types to follow (default: "Is a") + include_distance: Include hierarchy_level field for each descendant + include_paths: Include path_length field for each descendant + include_invalid: Include deprecated/invalid concepts (default: False) + domain_ids: Filter by domains (e.g., ["Condition", "Drug"]) page: Page number page_size: Results per page Returns: - Descendants with hierarchy summary + Descendants with hierarchy_summary and pagination metadata """ params: dict[str, Any] = { - "max_levels": min(max_levels, 10), + "max_levels": min(max_levels, 20), "page": page, "page_size": page_size, } - if vocabulary_id: - params["vocabulary_id"] = vocabulary_id + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) if relationship_types: params["relationship_types"] = ",".join(relationship_types) if include_distance: params["include_distance"] = "true" - if standard_only: - params["standard_only"] = "true" - if include_deprecated: - params["include_deprecated"] = "true" + if include_paths: + params["include_paths"] = "true" + if include_invalid: + params["include_invalid"] = "true" if domain_ids: params["domain_ids"] = ",".join(domain_ids) - if concept_class_ids: - params["concept_class_ids"] = ",".join(concept_class_ids) - if include_synonyms: - params["include_synonyms"] = "true" return self._request.get(f"/concepts/{concept_id}/descendants", params=params) @@ -129,24 +162,55 @@ class AsyncHierarchy: def __init__(self, request: AsyncRequest[Any]) -> None: self._request = request + async def get( + self, + concept_id: int, + *, + format: str = "flat", + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + max_levels: int = 10, + max_results: int | None = None, + relationship_types: list[str] | None = None, + include_invalid: bool = False, + ) -> dict[str, Any]: + """Get complete concept hierarchy (ancestors and descendants).""" + params: dict[str, Any] = { + "format": format, + "max_levels": min(max_levels, 20), + } + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if max_results is not None: + params["max_results"] = max_results + if relationship_types: + params["relationship_types"] = ",".join(relationship_types) + if include_invalid: + params["include_invalid"] = "true" + + return await self._request.get( + f"/concepts/{concept_id}/hierarchy", params=params + ) + async def ancestors( self, concept_id: int, *, - vocabulary_id: str | None = None, + vocabulary_ids: list[str] | None = None, max_levels: int | None = None, relationship_types: list[str] | None = None, include_paths: bool = False, include_distance: bool = True, - standard_only: bool = False, - include_deprecated: bool = False, + include_invalid: bool = False, page: int = 1, page_size: int = 100, ) -> dict[str, Any]: """Get concept ancestors.""" params: dict[str, Any] = {"page": page, "page_size": page_size} - if vocabulary_id: - params["vocabulary_id"] = vocabulary_id + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) if max_levels is not None: params["max_levels"] = max_levels if relationship_types: @@ -155,10 +219,8 @@ async def ancestors( params["include_paths"] = "true" if include_distance: params["include_distance"] = "true" - if standard_only: - params["standard_only"] = "true" - if include_deprecated: - params["include_deprecated"] = "true" + if include_invalid: + params["include_invalid"] = "true" return await self._request.get( f"/concepts/{concept_id}/ancestors", params=params @@ -168,40 +230,34 @@ async def descendants( self, concept_id: int, *, - vocabulary_id: str | None = None, + vocabulary_ids: list[str] | None = None, max_levels: int = 10, relationship_types: list[str] | None = None, include_distance: bool = True, - standard_only: bool = False, - include_deprecated: bool = False, + include_paths: bool = False, + include_invalid: bool = False, domain_ids: list[str] | None = None, - concept_class_ids: list[str] | None = None, - include_synonyms: bool = False, page: int = 1, page_size: int = 100, ) -> dict[str, Any]: """Get concept descendants.""" params: dict[str, Any] = { - "max_levels": min(max_levels, 10), + "max_levels": min(max_levels, 20), "page": page, "page_size": page_size, } - if vocabulary_id: - params["vocabulary_id"] = vocabulary_id + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) if relationship_types: params["relationship_types"] = ",".join(relationship_types) if include_distance: params["include_distance"] = "true" - if standard_only: - params["standard_only"] = "true" - if include_deprecated: - params["include_deprecated"] = "true" + if include_paths: + params["include_paths"] = "true" + if include_invalid: + params["include_invalid"] = "true" if domain_ids: params["domain_ids"] = ",".join(domain_ids) - if concept_class_ids: - params["concept_class_ids"] = ",".join(concept_class_ids) - if include_synonyms: - params["include_synonyms"] = "true" return await self._request.get( f"/concepts/{concept_id}/descendants", params=params diff --git a/src/omophub/resources/mappings.py b/src/omophub/resources/mappings.py index b0ee0e2..5dbbe6f 100644 --- a/src/omophub/resources/mappings.py +++ b/src/omophub/resources/mappings.py @@ -18,68 +18,30 @@ def get( self, concept_id: int, *, - target_vocabularies: list[str] | None = None, - mapping_types: list[str] | None = None, - direction: str = "both", - include_indirect: bool = False, - standard_only: bool = False, - include_mapping_quality: bool = False, - include_synonyms: bool = False, - include_context: bool = False, - active_only: bool = True, - sort_by: str | None = None, - sort_order: str | None = None, - page: int = 1, - page_size: int = 50, + target_vocabulary: str | None = None, + include_invalid: bool = False, + vocab_release: str | None = None, ) -> dict[str, Any]: """Get mappings for a concept. Args: concept_id: The concept ID - target_vocabularies: Filter by target vocabularies - mapping_types: Filter by mapping types - direction: Mapping direction ("outgoing", "incoming", "both") - include_indirect: Include indirect mappings - standard_only: Only standard concept mappings - include_mapping_quality: Include quality metrics - include_synonyms: Include synonyms - include_context: Include mapping context - active_only: Only active mappings - sort_by: Sort field - sort_order: Sort order - page: Page number - page_size: Results per page + target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM") + include_invalid: Include invalid/deprecated mappings + vocab_release: Specific vocabulary release version (e.g., "2025.1") Returns: - Mappings with summary + Mappings for the concept """ - params: dict[str, Any] = { - "direction": direction, - "page": page, - "page_size": page_size, - } - if target_vocabularies: - params["target_vocabularies"] = ",".join(target_vocabularies) - if mapping_types: - params["mapping_types"] = ",".join(mapping_types) - if include_indirect: - params["include_indirect"] = "true" - if standard_only: - params["standard_only"] = "true" - if include_mapping_quality: - params["include_mapping_quality"] = "true" - if include_synonyms: - params["include_synonyms"] = "true" - if include_context: - params["include_context"] = "true" - if not active_only: - params["active_only"] = "false" - if sort_by: - params["sort_by"] = sort_by - if sort_order: - params["sort_order"] = sort_order - - return self._request.get(f"/concepts/{concept_id}/mappings", params=params) + params: dict[str, Any] = {} + if target_vocabulary: + params["target_vocabulary"] = target_vocabulary + if include_invalid: + params["include_invalid"] = "true" + if vocab_release: + params["vocab_release"] = vocab_release + + return self._request.get(f"/concepts/{concept_id}/mappings", params=params or None) def map( self, @@ -88,6 +50,7 @@ def map( *, mapping_type: str | None = None, include_invalid: bool = False, + vocab_release: str | None = None, ) -> dict[str, Any]: """Map concepts to a target vocabulary. @@ -96,6 +59,7 @@ def map( target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED") mapping_type: Mapping type (direct, equivalent, broader, narrower) include_invalid: Include invalid mappings + vocab_release: Specific vocabulary release version (e.g., "2025.1") Returns: Mapping results with summary @@ -109,7 +73,11 @@ def map( if include_invalid: body["include_invalid"] = True - return self._request.post("/concepts/map", json_data=body) + params: dict[str, Any] = {} + if vocab_release: + params["vocab_release"] = vocab_release + + return self._request.post("/concepts/map", json_data=body, params=params or None) class AsyncMappings: @@ -122,69 +90,31 @@ async def get( self, concept_id: int, *, - target_vocabularies: list[str] | None = None, - mapping_types: list[str] | None = None, - direction: str = "both", - include_indirect: bool = False, - standard_only: bool = False, - include_mapping_quality: bool = False, - include_synonyms: bool = False, - include_context: bool = False, - active_only: bool = True, - sort_by: str | None = None, - sort_order: str | None = None, - page: int = 1, - page_size: int = 50, + target_vocabulary: str | None = None, + include_invalid: bool = False, + vocab_release: str | None = None, ) -> dict[str, Any]: """Get mappings for a concept. Args: concept_id: The concept ID - target_vocabularies: Filter by target vocabularies - mapping_types: Filter by mapping types - direction: Mapping direction ("outgoing", "incoming", "both") - include_indirect: Include indirect mappings - standard_only: Only standard concept mappings - include_mapping_quality: Include quality metrics - include_synonyms: Include synonyms - include_context: Include mapping context - active_only: Only active mappings - sort_by: Sort field - sort_order: Sort order - page: Page number - page_size: Results per page + target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM") + include_invalid: Include invalid/deprecated mappings + vocab_release: Specific vocabulary release version (e.g., "2025.1") Returns: - Mappings with summary + Mappings for the concept """ - params: dict[str, Any] = { - "direction": direction, - "page": page, - "page_size": page_size, - } - if target_vocabularies: - params["target_vocabularies"] = ",".join(target_vocabularies) - if mapping_types: - params["mapping_types"] = ",".join(mapping_types) - if include_indirect: - params["include_indirect"] = "true" - if standard_only: - params["standard_only"] = "true" - if include_mapping_quality: - params["include_mapping_quality"] = "true" - if include_synonyms: - params["include_synonyms"] = "true" - if include_context: - params["include_context"] = "true" - if not active_only: - params["active_only"] = "false" - if sort_by: - params["sort_by"] = sort_by - if sort_order: - params["sort_order"] = sort_order + params: dict[str, Any] = {} + if target_vocabulary: + params["target_vocabulary"] = target_vocabulary + if include_invalid: + params["include_invalid"] = "true" + if vocab_release: + params["vocab_release"] = vocab_release return await self._request.get( - f"/concepts/{concept_id}/mappings", params=params + f"/concepts/{concept_id}/mappings", params=params or None ) async def map( @@ -194,6 +124,7 @@ async def map( *, mapping_type: str | None = None, include_invalid: bool = False, + vocab_release: str | None = None, ) -> dict[str, Any]: """Map concepts to a target vocabulary. @@ -202,6 +133,7 @@ async def map( target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED") mapping_type: Mapping type (direct, equivalent, broader, narrower) include_invalid: Include invalid mappings + vocab_release: Specific vocabulary release version (e.g., "2025.1") Returns: Mapping results with summary @@ -215,4 +147,8 @@ async def map( if include_invalid: body["include_invalid"] = True - return await self._request.post("/concepts/map", json_data=body) + params: dict[str, Any] = {} + if vocab_release: + params["vocab_release"] = vocab_release + + return await self._request.post("/concepts/map", json_data=body, params=params or None) diff --git a/src/omophub/resources/relationships.py b/src/omophub/resources/relationships.py index eea18b9..7da8929 100644 --- a/src/omophub/resources/relationships.py +++ b/src/omophub/resources/relationships.py @@ -18,80 +18,63 @@ def get( self, concept_id: int, *, - relationship_type: str | None = None, - target_vocabulary: str | None = None, + relationship_ids: list[str] | None = None, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_only: bool = False, include_invalid: bool = False, + include_reverse: bool = False, page: int = 1, - page_size: int = 50, + page_size: int = 100, ) -> dict[str, Any]: """Get relationships for a concept. Args: concept_id: The concept ID - relationship_type: Filter by relationship type - target_vocabulary: Filter by target vocabulary + relationship_ids: Filter by relationship IDs (e.g., ["Is a", "Maps to"]) + vocabulary_ids: Filter by vocabulary IDs + domain_ids: Filter by domain IDs + standard_only: Only include relationships to standard concepts include_invalid: Include invalid relationships + include_reverse: Include reverse relationships page: Page number - page_size: Results per page + page_size: Results per page (max 1000) Returns: - Relationships with summary + Relationships with pagination metadata """ params: dict[str, Any] = {"page": page, "page_size": page_size} - if relationship_type: - params["relationship_type"] = relationship_type - if target_vocabulary: - params["target_vocabulary"] = target_vocabulary + if relationship_ids: + params["relationship_ids"] = ",".join(relationship_ids) + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if standard_only: + params["standard_only"] = "true" if include_invalid: params["include_invalid"] = "true" + if include_reverse: + params["include_reverse"] = "true" return self._request.get(f"/concepts/{concept_id}/relationships", params=params) def types( self, *, - vocabulary_ids: list[str] | None = None, - include_reverse: bool = False, - include_usage_stats: bool = False, - include_examples: bool = False, - category: str | None = None, - is_defining: bool | None = None, - standard_only: bool = False, page: int = 1, page_size: int = 100, ) -> dict[str, Any]: - """Get available relationship types. + """Get available relationship types from the OMOP CDM. Args: - vocabulary_ids: Filter by vocabularies - include_reverse: Include reverse relationships - include_usage_stats: Include usage statistics - include_examples: Include example concepts - category: Filter by category - is_defining: Filter by defining status - standard_only: Only standard relationships - page: Page number - page_size: Results per page + page: Page number (1-based) + page_size: Results per page (max 500) Returns: - Relationship types with metadata + Relationship types with pagination metadata """ params: dict[str, Any] = {"page": page, "page_size": page_size} - if vocabulary_ids: - params["vocabulary_ids"] = ",".join(vocabulary_ids) - if include_reverse: - params["include_reverse"] = "true" - if include_usage_stats: - params["include_usage_stats"] = "true" - if include_examples: - params["include_examples"] = "true" - if category: - params["category"] = category - if is_defining is not None: - params["is_defining"] = "true" if is_defining else "false" - if standard_only: - params["standard_only"] = "true" - return self._request.get("/relationships/types", params=params) @@ -105,32 +88,44 @@ async def get( self, concept_id: int, *, - relationship_type: str | None = None, - target_vocabulary: str | None = None, + relationship_ids: list[str] | None = None, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_only: bool = False, include_invalid: bool = False, + include_reverse: bool = False, page: int = 1, - page_size: int = 50, + page_size: int = 100, ) -> dict[str, Any]: """Get relationships for a concept. Args: concept_id: The concept ID - relationship_type: Filter by relationship type - target_vocabulary: Filter by target vocabulary + relationship_ids: Filter by relationship IDs (e.g., ["Is a", "Maps to"]) + vocabulary_ids: Filter by vocabulary IDs + domain_ids: Filter by domain IDs + standard_only: Only include relationships to standard concepts include_invalid: Include invalid relationships + include_reverse: Include reverse relationships page: Page number - page_size: Results per page + page_size: Results per page (max 1000) Returns: - Relationships with summary + Relationships with pagination metadata """ params: dict[str, Any] = {"page": page, "page_size": page_size} - if relationship_type: - params["relationship_type"] = relationship_type - if target_vocabulary: - params["target_vocabulary"] = target_vocabulary + if relationship_ids: + params["relationship_ids"] = ",".join(relationship_ids) + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if standard_only: + params["standard_only"] = "true" if include_invalid: params["include_invalid"] = "true" + if include_reverse: + params["include_reverse"] = "true" return await self._request.get( f"/concepts/{concept_id}/relationships", params=params @@ -139,46 +134,17 @@ async def get( async def types( self, *, - vocabulary_ids: list[str] | None = None, - include_reverse: bool = False, - include_usage_stats: bool = False, - include_examples: bool = False, - category: str | None = None, - is_defining: bool | None = None, - standard_only: bool = False, page: int = 1, page_size: int = 100, ) -> dict[str, Any]: - """Get available relationship types. + """Get available relationship types from the OMOP CDM. Args: - vocabulary_ids: Filter by vocabularies - include_reverse: Include reverse relationships - include_usage_stats: Include usage statistics - include_examples: Include example concepts - category: Filter by category - is_defining: Filter by defining status - standard_only: Only standard relationships - page: Page number - page_size: Results per page + page: Page number (1-based) + page_size: Results per page (max 500) Returns: - Relationship types with metadata + Relationship types with pagination metadata """ params: dict[str, Any] = {"page": page, "page_size": page_size} - if vocabulary_ids: - params["vocabulary_ids"] = ",".join(vocabulary_ids) - if include_reverse: - params["include_reverse"] = "true" - if include_usage_stats: - params["include_usage_stats"] = "true" - if include_examples: - params["include_examples"] = "true" - if category: - params["category"] = category - if is_defining is not None: - params["is_defining"] = "true" if is_defining else "false" - if standard_only: - params["standard_only"] = "true" - return await self._request.get("/relationships/types", params=params) diff --git a/src/omophub/resources/search.py b/src/omophub/resources/search.py index 5873856..7c85bc8 100644 --- a/src/omophub/resources/search.py +++ b/src/omophub/resources/search.py @@ -37,15 +37,15 @@ class AdvancedSearchParams(TypedDict, total=False): """Parameters for advanced search.""" query: str - vocabularies: list[str] - domains: list[str] - concept_classes: list[str] + vocabulary_ids: list[str] + domain_ids: list[str] + concept_class_ids: list[str] standard_concepts_only: bool include_invalid: bool relationship_filters: list[dict[str, Any]] date_range: dict[str, str] - limit: int - offset: int + page: int + page_size: int class Search: @@ -168,50 +168,50 @@ def advanced( self, query: str, *, - vocabularies: list[str] | None = None, - domains: list[str] | None = None, - concept_classes: list[str] | None = None, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + concept_class_ids: list[str] | None = None, standard_concepts_only: bool = False, include_invalid: bool = False, relationship_filters: list[dict[str, Any]] | None = None, - limit: int = 20, - offset: int = 0, + page: int = 1, + page_size: int = 20, ) -> SearchResult: """Advanced concept search with facets. Args: query: Search query string - vocabularies: Filter by vocabularies - domains: Filter by domains - concept_classes: Filter by concept classes + vocabulary_ids: Filter by vocabulary IDs + domain_ids: Filter by domain IDs + concept_class_ids: Filter by concept class IDs standard_concepts_only: Only return standard concepts include_invalid: Include invalid concepts relationship_filters: Relationship-based filters - limit: Maximum results - offset: Result offset + page: Page number (1-based) + page_size: Results per page Returns: Search results with facets and metadata """ body: dict[str, Any] = {"query": query} - if vocabularies: - body["vocabularies"] = vocabularies - if domains: - body["domains"] = domains - if concept_classes: - body["concept_classes"] = concept_classes + if vocabulary_ids: + body["vocabulary_ids"] = vocabulary_ids + if domain_ids: + body["domain_ids"] = domain_ids + if concept_class_ids: + body["concept_class_ids"] = concept_class_ids if standard_concepts_only: body["standard_concepts_only"] = True if include_invalid: body["include_invalid"] = True if relationship_filters: body["relationship_filters"] = relationship_filters - if limit != 20: - body["limit"] = limit - if offset > 0: - body["offset"] = offset + if page != 1: + body["page"] = page + if page_size != 20: + body["page_size"] = page_size - return self._request.post("/concepts/search/advanced", json_data=body) + return self._request.post("/search/advanced", json_data=body) def autocomplete( self, @@ -219,7 +219,7 @@ def autocomplete( *, vocabulary_ids: list[str] | None = None, domains: list[str] | None = None, - max_suggestions: int = 10, + page_size: int = 10, ) -> list[Suggestion]: """Get autocomplete suggestions. @@ -227,12 +227,12 @@ def autocomplete( query: Partial query string vocabulary_ids: Filter by vocabulary IDs domains: Filter by domains - max_suggestions: Maximum suggestions + page_size: Maximum suggestions to return Returns: Autocomplete suggestions """ - params: dict[str, Any] = {"query": query, "max_suggestions": max_suggestions} + params: dict[str, Any] = {"query": query, "page_size": page_size} if vocabulary_ids: params["vocabulary_ids"] = ",".join(vocabulary_ids) if domains: @@ -297,35 +297,35 @@ async def advanced( self, query: str, *, - vocabularies: list[str] | None = None, - domains: list[str] | None = None, - concept_classes: list[str] | None = None, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + concept_class_ids: list[str] | None = None, standard_concepts_only: bool = False, include_invalid: bool = False, relationship_filters: list[dict[str, Any]] | None = None, - limit: int = 20, - offset: int = 0, + page: int = 1, + page_size: int = 20, ) -> SearchResult: """Advanced concept search with facets.""" body: dict[str, Any] = {"query": query} - if vocabularies: - body["vocabularies"] = vocabularies - if domains: - body["domains"] = domains - if concept_classes: - body["concept_classes"] = concept_classes + if vocabulary_ids: + body["vocabulary_ids"] = vocabulary_ids + if domain_ids: + body["domain_ids"] = domain_ids + if concept_class_ids: + body["concept_class_ids"] = concept_class_ids if standard_concepts_only: body["standard_concepts_only"] = True if include_invalid: body["include_invalid"] = True if relationship_filters: body["relationship_filters"] = relationship_filters - if limit != 20: - body["limit"] = limit - if offset > 0: - body["offset"] = offset + if page != 1: + body["page"] = page + if page_size != 20: + body["page_size"] = page_size - return await self._request.post("/concepts/search/advanced", json_data=body) + return await self._request.post("/search/advanced", json_data=body) async def autocomplete( self, @@ -333,10 +333,10 @@ async def autocomplete( *, vocabulary_ids: list[str] | None = None, domains: list[str] | None = None, - max_suggestions: int = 10, + page_size: int = 10, ) -> list[Suggestion]: """Get autocomplete suggestions.""" - params: dict[str, Any] = {"query": query, "max_suggestions": max_suggestions} + params: dict[str, Any] = {"query": query, "page_size": page_size} if vocabulary_ids: params["vocabulary_ids"] = ",".join(vocabulary_ids) if domains: diff --git a/src/omophub/resources/vocabularies.py b/src/omophub/resources/vocabularies.py index e37dc0b..09ff6ac 100644 --- a/src/omophub/resources/vocabularies.py +++ b/src/omophub/resources/vocabularies.py @@ -25,7 +25,7 @@ def list( sort_by: str = "name", sort_order: str = "asc", page: int = 1, - page_size: int = 100, + page_size: int = 20, ) -> dict[str, Any]: """List all vocabularies. @@ -53,32 +53,17 @@ def list( return self._request.get("/vocabularies", params=params) - def get( - self, - vocabulary_id: str, - *, - include_stats: bool = False, - include_domains: bool = False, - ) -> Vocabulary: + def get(self, vocabulary_id: str) -> Vocabulary: """Get vocabulary details. Args: vocabulary_id: The vocabulary ID - include_stats: Include statistics - include_domains: Include domain breakdown Returns: - Vocabulary details + Vocabulary details including vocabulary_id, vocabulary_name, + vocabulary_reference, vocabulary_version, vocabulary_concept_id """ - params: dict[str, Any] = {} - if include_stats: - params["include_stats"] = "true" - if include_domains: - params["include_domains"] = "true" - - return self._request.get( - f"/vocabularies/{vocabulary_id}", params=params or None - ) + return self._request.get(f"/vocabularies/{vocabulary_id}") def stats(self, vocabulary_id: str) -> VocabularyStats: """Get vocabulary statistics. @@ -91,58 +76,83 @@ def stats(self, vocabulary_id: str) -> VocabularyStats: """ return self._request.get(f"/vocabularies/{vocabulary_id}/stats") - def domains( - self, - *, - vocabulary_ids: builtins.list[str] | None = None, - page: int = 1, - page_size: int = 50, - ) -> dict[str, Any]: - """Get vocabulary domains. + def domain_stats(self, vocabulary_id: str, domain_id: str) -> dict[str, Any]: + """Get statistics for a specific domain within a vocabulary. Args: - vocabulary_ids: Filter by vocabulary IDs (optional) - page: Page number - page_size: Results per page + vocabulary_id: The vocabulary ID (e.g., "SNOMED", "ICD10CM") + domain_id: The domain ID (e.g., "Condition", "Drug", "Procedure") Returns: - Domain statistics for vocabularies + Domain statistics including concept counts and class breakdown """ - params: dict[str, Any] = {"page": page, "page_size": page_size} - if vocabulary_ids: - params["vocabulary_ids"] = ",".join(vocabulary_ids) - return self._request.get("/vocabularies/domains", params=params) + return self._request.get( + f"/vocabularies/{vocabulary_id}/stats/domains/{domain_id}" + ) + + def domains(self) -> dict[str, Any]: + """Get all standard OHDSI domains. + + Returns: + List of all available domains with domain_id, domain_name, and description + """ + return self._request.get("/vocabularies/domains") + + def concept_classes(self) -> dict[str, Any]: + """Get all concept classes. + + Returns: + List of all available concept classes with concept_class_id, + concept_class_name, and concept_class_concept_id + """ + return self._request.get("/vocabularies/concept-classes") def concepts( self, vocabulary_id: str, *, - domain_id: str | None = None, - concept_class_id: str | None = None, - standard_only: bool = False, + search: str | None = None, + standard_concept: str = "all", + include_invalid: bool = False, + include_relationships: bool = False, + include_synonyms: bool = False, + sort_by: str = "name", + sort_order: str = "asc", page: int = 1, - page_size: int = 50, + page_size: int = 20, ) -> dict[str, Any]: """Get concepts in a vocabulary. Args: vocabulary_id: The vocabulary ID - domain_id: Filter by domain - concept_class_id: Filter by concept class - standard_only: Only standard concepts + search: Search term to filter concepts by name or code + standard_concept: Filter by standard concept status ('S', 'C', 'all') + include_invalid: Include invalid or deprecated concepts + include_relationships: Include concept relationships + include_synonyms: Include concept synonyms + sort_by: Sort field ('name', 'concept_id', 'concept_code') + sort_order: Sort order ('asc' or 'desc') page: Page number - page_size: Results per page + page_size: Results per page (max 1000) Returns: Paginated concepts """ - params: dict[str, Any] = {"page": page, "page_size": page_size} - if domain_id: - params["domain_id"] = domain_id - if concept_class_id: - params["concept_class_id"] = concept_class_id - if standard_only: - params["standard_only"] = "true" + params: dict[str, Any] = { + "page": page, + "page_size": page_size, + "standard_concept": standard_concept, + "sort_by": sort_by, + "sort_order": sort_order, + } + if search: + params["search"] = search + if include_invalid: + params["include_invalid"] = "true" + if include_relationships: + params["include_relationships"] = "true" + if include_synonyms: + params["include_synonyms"] = "true" return self._request.get( f"/vocabularies/{vocabulary_id}/concepts", params=params @@ -163,7 +173,7 @@ async def list( sort_by: str = "name", sort_order: str = "asc", page: int = 1, - page_size: int = 100, + page_size: int = 20, ) -> dict[str, Any]: """List all vocabularies.""" params: dict[str, Any] = { @@ -179,59 +189,58 @@ async def list( return await self._request.get("/vocabularies", params=params) - async def get( - self, - vocabulary_id: str, - *, - include_stats: bool = False, - include_domains: bool = False, - ) -> Vocabulary: + async def get(self, vocabulary_id: str) -> Vocabulary: """Get vocabulary details.""" - params: dict[str, Any] = {} - if include_stats: - params["include_stats"] = "true" - if include_domains: - params["include_domains"] = "true" - - return await self._request.get( - f"/vocabularies/{vocabulary_id}", params=params or None - ) + return await self._request.get(f"/vocabularies/{vocabulary_id}") async def stats(self, vocabulary_id: str) -> VocabularyStats: """Get vocabulary statistics.""" return await self._request.get(f"/vocabularies/{vocabulary_id}/stats") - async def domains( - self, - *, - vocabulary_ids: builtins.list[str] | None = None, - page: int = 1, - page_size: int = 50, - ) -> dict[str, Any]: - """Get vocabulary domains.""" - params: dict[str, Any] = {"page": page, "page_size": page_size} - if vocabulary_ids: - params["vocabulary_ids"] = ",".join(vocabulary_ids) - return await self._request.get("/vocabularies/domains", params=params) + async def domain_stats(self, vocabulary_id: str, domain_id: str) -> dict[str, Any]: + """Get statistics for a specific domain within a vocabulary.""" + return await self._request.get( + f"/vocabularies/{vocabulary_id}/stats/domains/{domain_id}" + ) + + async def domains(self) -> dict[str, Any]: + """Get all standard OHDSI domains.""" + return await self._request.get("/vocabularies/domains") + + async def concept_classes(self) -> dict[str, Any]: + """Get all concept classes.""" + return await self._request.get("/vocabularies/concept-classes") async def concepts( self, vocabulary_id: str, *, - domain_id: str | None = None, - concept_class_id: str | None = None, - standard_only: bool = False, + search: str | None = None, + standard_concept: str = "all", + include_invalid: bool = False, + include_relationships: bool = False, + include_synonyms: bool = False, + sort_by: str = "name", + sort_order: str = "asc", page: int = 1, - page_size: int = 50, + page_size: int = 20, ) -> dict[str, Any]: """Get concepts in a vocabulary.""" - params: dict[str, Any] = {"page": page, "page_size": page_size} - if domain_id: - params["domain_id"] = domain_id - if concept_class_id: - params["concept_class_id"] = concept_class_id - if standard_only: - params["standard_only"] = "true" + params: dict[str, Any] = { + "page": page, + "page_size": page_size, + "standard_concept": standard_concept, + "sort_by": sort_by, + "sort_order": sort_order, + } + if search: + params["search"] = search + if include_invalid: + params["include_invalid"] = "true" + if include_relationships: + params["include_relationships"] = "true" + if include_synonyms: + params["include_synonyms"] = "true" return await self._request.get( f"/vocabularies/{vocabulary_id}/concepts", params=params diff --git a/src/omophub/types/vocabulary.py b/src/omophub/types/vocabulary.py index 2686c5f..235d707 100644 --- a/src/omophub/types/vocabulary.py +++ b/src/omophub/types/vocabulary.py @@ -24,15 +24,28 @@ class VocabularyDomain(TypedDict): classification_count: NotRequired[int] +class DomainDistribution(TypedDict): + """Domain distribution within vocabulary statistics.""" + + domain_id: str + domain_name: str + concept_count: int + + class VocabularyStats(TypedDict): - """Vocabulary statistics.""" + """Vocabulary statistics from /vocabularies/{vocabulary_id}/stats endpoint.""" + vocabulary_id: str + vocabulary_name: str total_concepts: int standard_concepts: NotRequired[int] classification_concepts: NotRequired[int] invalid_concepts: NotRequired[int] - relationships_count: NotRequired[int] - synonyms_count: NotRequired[int] + active_concepts: NotRequired[int] + valid_start_date: NotRequired[str] + valid_end_date: NotRequired[str] + last_updated: NotRequired[str] + domain_distribution: NotRequired[list[DomainDistribution]] class Vocabulary(TypedDict): diff --git a/tests/integration/test_async.py b/tests/integration/test_async.py index 6da8e05..e2a6a3c 100644 --- a/tests/integration/test_async.py +++ b/tests/integration/test_async.py @@ -109,7 +109,7 @@ async def test_async_autocomplete( """Async autocomplete.""" result = await async_integration_client.search.autocomplete( "aspi", - max_suggestions=5, + page_size=5, ) suggestions = extract_data(result, "suggestions") diff --git a/tests/integration/test_domains.py b/tests/integration/test_domains.py index e2c7d10..5cc276a 100644 --- a/tests/integration/test_domains.py +++ b/tests/integration/test_domains.py @@ -27,30 +27,17 @@ def test_list_domains(self, integration_client: OMOPHub) -> None: assert "Drug" in domain_ids assert "Procedure" in domain_ids - def test_list_domains_with_options(self, integration_client: OMOPHub) -> None: - """List domains with statistics and examples.""" - result = integration_client.domains.list( - include_concept_counts=True, - include_statistics=True, - ) - - domains = result.get("domains", []) - assert len(domains) > 0 - # Verify domains are returned with expected structure - assert all("domain_id" in d for d in domains) - - def test_list_domains_with_vocabulary_filter( - self, integration_client: OMOPHub - ) -> None: - """List domains filtered by vocabulary.""" - result = integration_client.domains.list( - vocabulary_ids=["SNOMED"], - include_concept_counts=True, - ) + def test_list_domains_with_stats(self, integration_client: OMOPHub) -> None: + """List domains with statistics.""" + result = integration_client.domains.list(include_stats=True) domains = result.get("domains", []) - assert isinstance(domains, list) assert len(domains) > 0 + # Verify domains are returned with expected structure including stats + for domain in domains: + assert "domain_id" in domain + assert "concept_count" in domain + assert "standard_concept_count" in domain def test_get_domain_concepts(self, integration_client: OMOPHub) -> None: """Get concepts in Condition domain.""" diff --git a/tests/integration/test_hierarchy.py b/tests/integration/test_hierarchy.py index acc015a..ceb449b 100644 --- a/tests/integration/test_hierarchy.py +++ b/tests/integration/test_hierarchy.py @@ -37,7 +37,7 @@ def test_get_ancestors_with_options(self, integration_client: OMOPHub) -> None: """Get ancestors with various options.""" result = integration_client.hierarchy.ancestors( DIABETES_CONCEPT_ID, - vocabulary_id="SNOMED", + vocabulary_ids=["SNOMED"], max_levels=5, include_distance=True, page_size=50, @@ -46,6 +46,11 @@ def test_get_ancestors_with_options(self, integration_client: OMOPHub) -> None: ancestors = result.get("ancestors", result) assert isinstance(ancestors, list) + # Verify hierarchy_summary structure if present + if "hierarchy_summary" in result: + summary = result["hierarchy_summary"] + assert "max_hierarchy_depth" in summary or "total_ancestors" in summary + def test_get_descendants(self, integration_client: OMOPHub) -> None: """Get descendants of a parent concept.""" # 201820 is Diabetes mellitus (parent of Type 2) @@ -61,12 +66,17 @@ def test_get_descendants_with_filters(self, integration_client: OMOPHub) -> None """Get descendants with domain and vocabulary filters.""" result = integration_client.hierarchy.descendants( 201820, - vocabulary_id="SNOMED", + vocabulary_ids=["SNOMED"], max_levels=3, include_distance=True, - standard_only=True, + include_invalid=False, page_size=100, ) descendants = result.get("descendants", result) assert isinstance(descendants, list) + + # Verify hierarchy_summary structure if present + if "hierarchy_summary" in result: + summary = result["hierarchy_summary"] + assert "max_hierarchy_depth" in summary or "total_descendants" in summary diff --git a/tests/integration/test_search.py b/tests/integration/test_search.py index 4ff9643..90329bf 100644 --- a/tests/integration/test_search.py +++ b/tests/integration/test_search.py @@ -53,7 +53,7 @@ def test_autocomplete(self, integration_client: OMOPHub) -> None: """Test autocomplete suggestions.""" result = integration_client.search.autocomplete( "diab", - max_suggestions=10, + page_size=10, ) suggestions = extract_data(result, "suggestions") @@ -76,7 +76,7 @@ def test_autocomplete_with_filters(self, integration_client: OMOPHub) -> None: "hyper", vocabulary_ids=["SNOMED"], domains=["Condition"], - max_suggestions=5, + page_size=5, ) suggestions = extract_data(result, "suggestions") diff --git a/tests/integration/test_vocabularies.py b/tests/integration/test_vocabularies.py index f9915ea..690bc7f 100644 --- a/tests/integration/test_vocabularies.py +++ b/tests/integration/test_vocabularies.py @@ -72,18 +72,22 @@ def test_get_vocabulary_stats(self, integration_client: OMOPHub) -> None: ) def test_get_vocabulary_domains(self, integration_client: OMOPHub) -> None: - """Get vocabulary domains.""" - result = integration_client.vocabularies.domains(vocabulary_ids=["SNOMED"]) + """Get all OHDSI domains.""" + result = integration_client.vocabularies.domains() domains = extract_data(result, "domains") assert isinstance(domains, list) + assert len(domains) > 0 + # Verify domain structure + assert all("domain_id" in d for d in domains) + assert all("domain_name" in d for d in domains) def test_get_vocabulary_concepts(self, integration_client: OMOPHub) -> None: """Get concepts in SNOMED vocabulary.""" result = integration_client.vocabularies.concepts( "SNOMED", - domain_id="Condition", - standard_only=True, + search="diabetes", + standard_concept="S", page_size=10, ) diff --git a/tests/unit/resources/test_domains.py b/tests/unit/resources/test_domains.py index 35035b5..827f809 100644 --- a/tests/unit/resources/test_domains.py +++ b/tests/unit/resources/test_domains.py @@ -27,7 +27,6 @@ def test_list_domains(self, sync_client: OMOPHub, base_url: str) -> None: {"domain_id": "Drug", "domain_name": "Drug"}, {"domain_id": "Procedure", "domain_name": "Procedure"}, ], - "summary": {"total_domains": 3}, }, } respx.get(f"{base_url}/domains").mock( @@ -38,36 +37,51 @@ def test_list_domains(self, sync_client: OMOPHub, base_url: str) -> None: assert "domains" in result @respx.mock - def test_list_domains_with_options( + def test_list_domains_with_stats( self, sync_client: OMOPHub, base_url: str ) -> None: - """Test listing domains with all filter options.""" + """Test listing domains with include_stats option.""" route = respx.get(f"{base_url}/domains").mock( return_value=Response( - 200, json={"success": True, "data": {"domains": []}} + 200, + json={ + "success": True, + "data": { + "domains": [ + { + "domain_id": "Condition", + "domain_name": "Condition", + "concept_count": 845672, + "standard_concept_count": 423891, + "vocabulary_coverage": ["SNOMED", "ICD10CM"], + } + ] + }, + }, ) ) - sync_client.domains.list( - vocabulary_ids=["SNOMED", "ICD10CM"], - include_concept_counts=True, - include_statistics=True, - include_examples=True, - standard_only=True, - active_only=False, - sort_by="concept_count", - sort_order="desc", + sync_client.domains.list(include_stats=True) + + url_str = str(route.calls[0].request.url) + assert "include_stats=true" in url_str + + @respx.mock + def test_list_domains_without_stats( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test listing domains without stats (default).""" + route = respx.get(f"{base_url}/domains").mock( + return_value=Response( + 200, json={"success": True, "data": {"domains": []}} + ) ) + sync_client.domains.list() + url_str = str(route.calls[0].request.url) - assert "vocabulary_ids=SNOMED%2CICD10CM" in url_str - assert "include_concept_counts=true" in url_str - assert "include_statistics=true" in url_str - assert "include_examples=true" in url_str - assert "standard_only=true" in url_str - assert "active_only=false" in url_str - assert "sort_by=concept_count" in url_str - assert "sort_order=desc" in url_str + # Should not include include_stats when False (default) + assert "include_stats" not in url_str @respx.mock def test_get_domain_concepts(self, sync_client: OMOPHub, base_url: str) -> None: @@ -89,8 +103,8 @@ def test_get_domain_concepts(self, sync_client: OMOPHub, base_url: str) -> None: result = sync_client.domains.concepts( "Condition", vocabulary_ids=["SNOMED"], - concept_class_ids=["Clinical Finding"], standard_only=True, + include_invalid=True, page=1, page_size=100, ) @@ -98,8 +112,8 @@ def test_get_domain_concepts(self, sync_client: OMOPHub, base_url: str) -> None: assert "concepts" in result url_str = str(route.calls[0].request.url) assert "vocabulary_ids=SNOMED" in url_str - assert "concept_class_ids=Clinical+Finding" in url_str assert "standard_only=true" in url_str + assert "include_invalid=true" in url_str assert "page=1" in url_str assert "page_size=100" in url_str @@ -124,36 +138,20 @@ async def test_async_list_domains( @pytest.mark.asyncio @respx.mock - async def test_async_list_domains_with_options( + async def test_async_list_domains_with_stats( self, async_client: omophub.AsyncOMOPHub, base_url: str ) -> None: - """Test async listing domains with all options.""" + """Test async listing domains with include_stats.""" route = respx.get(f"{base_url}/domains").mock( return_value=Response( 200, json={"success": True, "data": {"domains": []}} ) ) - await async_client.domains.list( - vocabulary_ids=["SNOMED"], - include_concept_counts=True, - include_statistics=True, - include_examples=True, - standard_only=True, - active_only=False, - sort_by="domain_id", - sort_order="asc", - ) + await async_client.domains.list(include_stats=True) url_str = str(route.calls[0].request.url) - assert "vocabulary_ids=SNOMED" in url_str - assert "include_concept_counts=true" in url_str - assert "include_statistics=true" in url_str - assert "include_examples=true" in url_str - assert "active_only=false" in url_str - assert "standard_only=true" in url_str - assert "sort_by=domain_id" in url_str - assert "sort_order=asc" in url_str + assert "include_stats=true" in url_str @pytest.mark.asyncio @respx.mock @@ -170,15 +168,15 @@ async def test_async_get_domain_concepts( await async_client.domains.concepts( "Condition", vocabulary_ids=["SNOMED", "ICD10CM"], - concept_class_ids=["Clinical Finding", "Disease"], standard_only=True, + include_invalid=True, page=2, page_size=25, ) url_str = str(route.calls[0].request.url) assert "vocabulary_ids=SNOMED%2CICD10CM" in url_str - assert "concept_class_ids=Clinical+Finding%2CDisease" in url_str assert "standard_only=true" in url_str + assert "include_invalid=true" in url_str assert "page=2" in url_str assert "page_size=25" in url_str diff --git a/tests/unit/resources/test_hierarchy.py b/tests/unit/resources/test_hierarchy.py index bd2db57..2028818 100644 --- a/tests/unit/resources/test_hierarchy.py +++ b/tests/unit/resources/test_hierarchy.py @@ -47,25 +47,23 @@ def test_get_ancestors_with_options(self, sync_client: OMOPHub, base_url: str) - sync_client.hierarchy.ancestors( 201826, - vocabulary_id="SNOMED", + vocabulary_ids=["SNOMED"], max_levels=5, relationship_types=["Is a", "Subsumes"], include_paths=True, include_distance=True, - standard_only=True, - include_deprecated=True, + include_invalid=True, page=2, page_size=50, ) url_str = str(route.calls[0].request.url) - assert "vocabulary_id=SNOMED" in url_str + assert "vocabulary_ids=SNOMED" in url_str assert "max_levels=5" in url_str assert "relationship_types=Is+a%2CSubsumes" in url_str assert "include_paths=true" in url_str assert "include_distance=true" in url_str - assert "standard_only=true" in url_str - assert "include_deprecated=true" in url_str + assert "include_invalid=true" in url_str assert "page=2" in url_str assert "page_size=50" in url_str @@ -101,28 +99,25 @@ def test_get_descendants_with_options( sync_client.hierarchy.descendants( 201820, - vocabulary_id="SNOMED", + vocabulary_ids=["SNOMED"], max_levels=3, relationship_types=["Is a"], include_distance=True, - standard_only=True, - include_deprecated=True, + include_paths=True, + include_invalid=True, domain_ids=["Condition"], - concept_class_ids=["Clinical Finding"], - include_synonyms=True, page=1, page_size=100, ) url_str = str(route.calls[0].request.url) - assert "vocabulary_id=SNOMED" in url_str + assert "vocabulary_ids=SNOMED" in url_str assert "max_levels=3" in url_str assert "relationship_types=Is+a" in url_str assert "include_distance=true" in url_str - assert "standard_only=true" in url_str + assert "include_paths=true" in url_str + assert "include_invalid=true" in url_str assert "domain_ids=Condition" in url_str - assert "concept_class_ids=Clinical+Finding" in url_str - assert "include_synonyms=true" in url_str assert "page=1" in url_str assert "page_size=100" in url_str @@ -130,18 +125,18 @@ def test_get_descendants_with_options( def test_get_descendants_max_levels_capped( self, sync_client: OMOPHub, base_url: str ) -> None: - """Test that max_levels is capped at 10.""" + """Test that max_levels is capped at 20.""" route = respx.get(f"{base_url}/concepts/201820/descendants").mock( return_value=Response( 200, json={"success": True, "data": {"descendants": []}} ) ) - # Request max_levels=50, should be capped to 10 + # Request max_levels=50, should be capped to 20 sync_client.hierarchy.descendants(201820, max_levels=50) url_str = str(route.calls[0].request.url) - assert "max_levels=10" in url_str + assert "max_levels=20" in url_str @@ -177,19 +172,19 @@ async def test_async_get_ancestors_with_options( await async_client.hierarchy.ancestors( 201826, - vocabulary_id="SNOMED", + vocabulary_ids=["SNOMED"], max_levels=3, relationship_types=["Is a"], include_paths=True, - standard_only=True, + include_invalid=True, ) url_str = str(route.calls[0].request.url) - assert "vocabulary_id=SNOMED" in url_str + assert "vocabulary_ids=SNOMED" in url_str assert "max_levels=3" in url_str assert "include_paths=true" in url_str assert "relationship_types=Is+a" in url_str - assert "standard_only=true" in url_str + assert "include_invalid=true" in url_str @pytest.mark.asyncio @respx.mock @@ -220,19 +215,17 @@ async def test_async_get_descendants_with_filters( await async_client.hierarchy.descendants( 201820, - vocabulary_id="SNOMED", + vocabulary_ids=["SNOMED"], max_levels=5, domain_ids=["Condition"], - concept_class_ids=["Clinical Finding"], - standard_only=True, - include_synonyms=True, + include_invalid=True, + include_paths=True, ) url_str = str(route.calls[0].request.url) - assert "vocabulary_id=SNOMED" in url_str + assert "vocabulary_ids=SNOMED" in url_str assert "domain_ids=Condition" in url_str - assert "standard_only=true" in url_str + assert "include_invalid=true" in url_str assert "max_levels=5" in url_str - assert "concept_class_ids=Clinical+Finding" in url_str or "concept_class_ids=Clinical%20Finding" in url_str - assert "include_synonyms=true" in url_str + assert "include_paths=true" in url_str diff --git a/tests/unit/resources/test_relationships.py b/tests/unit/resources/test_relationships.py index 10bceb5..64ed218 100644 --- a/tests/unit/resources/test_relationships.py +++ b/tests/unit/resources/test_relationships.py @@ -88,55 +88,22 @@ def test_get_relationship_types(self, sync_client: OMOPHub, base_url: str) -> No assert "relationship_types" in result @respx.mock - def test_get_relationship_types_with_filters( + def test_get_relationship_types_with_pagination( self, sync_client: OMOPHub, base_url: str ) -> None: - """Test getting relationship types with all filter options.""" + """Test getting relationship types with pagination options.""" route = respx.get(f"{base_url}/relationships/types").mock( return_value=Response( 200, json={"success": True, "data": {"relationship_types": []}} ) ) - sync_client.relationships.types( - vocabulary_ids=["SNOMED", "ICD10CM"], - include_reverse=True, - include_usage_stats=True, - include_examples=True, - category="hierarchy", - is_defining=True, - standard_only=True, - page=1, - page_size=50, - ) + sync_client.relationships.types(page=2, page_size=50) url_str = str(route.calls[0].request.url) - assert "vocabulary_ids=SNOMED%2CICD10CM" in url_str - assert "include_reverse=true" in url_str - assert "include_usage_stats=true" in url_str - assert "include_examples=true" in url_str - assert "category=hierarchy" in url_str - assert "is_defining=true" in url_str - assert "standard_only=true" in url_str - assert "page=1" in url_str + assert "page=2" in url_str assert "page_size=50" in url_str - @respx.mock - def test_get_relationship_types_is_defining_false( - self, sync_client: OMOPHub, base_url: str - ) -> None: - """Test is_defining=False is properly encoded.""" - route = respx.get(f"{base_url}/relationships/types").mock( - return_value=Response( - 200, json={"success": True, "data": {"relationship_types": []}} - ) - ) - - sync_client.relationships.types(is_defining=False) - - url_str = str(route.calls[0].request.url) - assert "is_defining=false" in url_str - class TestAsyncRelationshipsResource: """Tests for the asynchronous AsyncRelationships resource.""" @@ -201,30 +168,18 @@ async def test_async_get_relationship_types( @pytest.mark.asyncio @respx.mock - async def test_async_get_relationship_types_with_filters( + async def test_async_get_relationship_types_with_pagination( self, async_client: omophub.AsyncOMOPHub, base_url: str ) -> None: - """Test async relationship types with all filters.""" + """Test async relationship types with pagination options.""" route = respx.get(f"{base_url}/relationships/types").mock( return_value=Response( 200, json={"success": True, "data": {"relationship_types": []}} ) ) - await async_client.relationships.types( - vocabulary_ids=["SNOMED"], - include_reverse=True, - include_usage_stats=True, - include_examples=True, - category="mapping", - is_defining=False, - standard_only=True, - page=1, - page_size=100, - ) + await async_client.relationships.types(page=3, page_size=25) url_str = str(route.calls[0].request.url) - assert "vocabulary_ids=SNOMED" in url_str - assert "include_reverse=true" in url_str - assert "category=mapping" in url_str - assert "is_defining=false" in url_str + assert "page=3" in url_str + assert "page_size=25" in url_str diff --git a/tests/unit/resources/test_search.py b/tests/unit/resources/test_search.py index bad0d91..0716903 100644 --- a/tests/unit/resources/test_search.py +++ b/tests/unit/resources/test_search.py @@ -131,7 +131,7 @@ def mock_response(request): @respx.mock def test_advanced_search(self, sync_client: OMOPHub, base_url: str) -> None: """Test advanced search with POST body.""" - route = respx.post(f"{base_url}/concepts/search/advanced").mock( + route = respx.post(f"{base_url}/search/advanced").mock( return_value=Response( 200, json={ @@ -143,14 +143,14 @@ def test_advanced_search(self, sync_client: OMOPHub, base_url: str) -> None: sync_client.search.advanced( "myocardial infarction", - vocabularies=["SNOMED"], - domains=["Condition"], - concept_classes=["Clinical Finding"], + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + concept_class_ids=["Clinical Finding"], standard_concepts_only=True, include_invalid=True, relationship_filters=[{"type": "Is a", "concept_id": 123}], - limit=50, - offset=10, + page=2, + page_size=50, ) # Verify POST body was sent @@ -174,7 +174,7 @@ def test_autocomplete(self, sync_client: OMOPHub, base_url: str) -> None: "diab", vocabulary_ids=["SNOMED"], domains=["Condition"], - max_suggestions=5, + page_size=5, ) assert len(result) == 2 @@ -182,7 +182,7 @@ def test_autocomplete(self, sync_client: OMOPHub, base_url: str) -> None: assert "query=diab" in url_str assert "vocabulary_ids=SNOMED" in url_str assert "domains=Condition" in url_str - assert "max_suggestions=5" in url_str + assert "page_size=5" in url_str class TestAsyncSearchResource: @@ -247,7 +247,7 @@ async def test_async_advanced_search( self, async_client: omophub.AsyncOMOPHub, base_url: str ) -> None: """Test async advanced search.""" - respx.post(f"{base_url}/concepts/search/advanced").mock( + respx.post(f"{base_url}/search/advanced").mock( return_value=Response( 200, json={"success": True, "data": {"concepts": []}} ) @@ -255,8 +255,8 @@ async def test_async_advanced_search( result = await async_client.search.advanced( "heart attack", - vocabularies=["SNOMED", "ICD10CM"], - domains=["Condition"], + vocabulary_ids=["SNOMED", "ICD10CM"], + domain_ids=["Condition"], standard_concepts_only=True, ) diff --git a/tests/unit/resources/test_vocabularies.py b/tests/unit/resources/test_vocabularies.py index 7e3fd35..8e38c2e 100644 --- a/tests/unit/resources/test_vocabularies.py +++ b/tests/unit/resources/test_vocabularies.py @@ -174,17 +174,25 @@ def test_get_vocabulary_concepts(self, sync_client: OMOPHub, base_url: str) -> N sync_client.vocabularies.concepts( "SNOMED", - domain_id="Condition", - concept_class_id="Clinical Finding", - standard_only=True, + search="diabetes", + standard_concept="S", + include_invalid=True, + include_relationships=True, + include_synonyms=True, + sort_by="concept_id", + sort_order="desc", page=1, page_size=100, ) url_str = str(route.calls[0].request.url) - assert "domain_id=Condition" in url_str - assert "concept_class_id=Clinical+Finding" in url_str - assert "standard_only=true" in url_str + assert "search=diabetes" in url_str + assert "standard_concept=S" in url_str + assert "include_invalid=true" in url_str + assert "include_relationships=true" in url_str + assert "include_synonyms=true" in url_str + assert "sort_by=concept_id" in url_str + assert "sort_order=desc" in url_str class TestAsyncVocabulariesResource: @@ -321,14 +329,20 @@ async def test_async_get_vocabulary_concepts( await async_client.vocabularies.concepts( "SNOMED", - domain_id="Drug", - concept_class_id="Ingredient", - standard_only=True, + search="aspirin", + standard_concept="S", + include_invalid=True, + include_relationships=True, + include_synonyms=True, + sort_by="name", + sort_order="asc", page=1, page_size=50, ) url_str = str(route.calls[0].request.url) - assert "domain_id=Drug" in url_str - assert "concept_class_id=Ingredient" in url_str - assert "standard_only=true" in url_str + assert "search=aspirin" in url_str + assert "standard_concept=S" in url_str + assert "include_invalid=true" in url_str + assert "include_relationships=true" in url_str + assert "include_synonyms=true" in url_str diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..48e0f61 --- /dev/null +++ b/uv.lock @@ -0,0 +1,563 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, + { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, + { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, + { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.7.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/29/47f29026ca17f35cf299290292d5f8331f5077364974b7675a353179afa2/librt-0.7.7.tar.gz", hash = "sha256:81d957b069fed1890953c3b9c3895c7689960f233eea9a1d9607f71ce7f00b2c", size = 145910, upload-time = "2026-01-01T23:52:22.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/84/2cfb1f3b9b60bab52e16a220c931223fc8e963d0d7bb9132bef012aafc3f/librt-0.7.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4836c5645f40fbdc275e5670819bde5ab5f2e882290d304e3c6ddab1576a6d0", size = 54709, upload-time = "2026-01-01T23:50:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/19/a1/3127b277e9d3784a8040a54e8396d9ae5c64d6684dc6db4b4089b0eedcfb/librt-0.7.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae8aec43117a645a31e5f60e9e3a0797492e747823b9bda6972d521b436b4e8", size = 56658, upload-time = "2026-01-01T23:50:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/3a/e9/b91b093a5c42eb218120445f3fef82e0b977fa2225f4d6fc133d25cdf86a/librt-0.7.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:aea05f701ccd2a76b34f0daf47ca5068176ff553510b614770c90d76ac88df06", size = 161026, upload-time = "2026-01-01T23:50:50.853Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cb/1ded77d5976a79d7057af4a010d577ce4f473ff280984e68f4974a3281e5/librt-0.7.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b16ccaeff0ed4355dfb76fe1ea7a5d6d03b5ad27f295f77ee0557bc20a72495", size = 169529, upload-time = "2026-01-01T23:50:52.24Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/6ca5bdaa701e15f05000ac1a4c5d1475c422d3484bd3d1ca9e8c2f5be167/librt-0.7.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48c7e150c095d5e3cea7452347ba26094be905d6099d24f9319a8b475fcd3e0", size = 183271, upload-time = "2026-01-01T23:50:55.287Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2d/55c0e38073997b4bbb5ddff25b6d1bbba8c2f76f50afe5bb9c844b702f34/librt-0.7.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4dcee2f921a8632636d1c37f1bbdb8841d15666d119aa61e5399c5268e7ce02e", size = 179039, upload-time = "2026-01-01T23:50:56.807Z" }, + { url = "https://files.pythonhosted.org/packages/33/4e/3662a41ae8bb81b226f3968426293517b271d34d4e9fd4b59fc511f1ae40/librt-0.7.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14ef0f4ac3728ffd85bfc58e2f2f48fb4ef4fa871876f13a73a7381d10a9f77c", size = 173505, upload-time = "2026-01-01T23:50:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5d/cf768deb8bdcbac5f8c21fcb32dd483d038d88c529fd351bbe50590b945d/librt-0.7.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e4ab69fa37f8090f2d971a5d2bc606c7401170dbdae083c393d6cbf439cb45b8", size = 193570, upload-time = "2026-01-01T23:50:59.546Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/ee70effd13f1d651976d83a2812391f6203971740705e3c0900db75d4bce/librt-0.7.7-cp310-cp310-win32.whl", hash = "sha256:4bf3cc46d553693382d2abf5f5bd493d71bb0f50a7c0beab18aa13a5545c8900", size = 42600, upload-time = "2026-01-01T23:51:00.694Z" }, + { url = "https://files.pythonhosted.org/packages/f0/eb/dc098730f281cba76c279b71783f5de2edcba3b880c1ab84a093ef826062/librt-0.7.7-cp310-cp310-win_amd64.whl", hash = "sha256:f0c8fe5aeadd8a0e5b0598f8a6ee3533135ca50fd3f20f130f9d72baf5c6ac58", size = 48977, upload-time = "2026-01-01T23:51:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/f0/56/30b5c342518005546df78841cb0820ae85a17e7d07d521c10ef367306d0d/librt-0.7.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a487b71fbf8a9edb72a8c7a456dda0184642d99cd007bc819c0b7ab93676a8ee", size = 54709, upload-time = "2026-01-01T23:51:02.774Z" }, + { url = "https://files.pythonhosted.org/packages/72/78/9f120e3920b22504d4f3835e28b55acc2cc47c9586d2e1b6ba04c3c1bf01/librt-0.7.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f4d4efb218264ecf0f8516196c9e2d1a0679d9fb3bb15df1155a35220062eba8", size = 56663, upload-time = "2026-01-01T23:51:03.838Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/7d7a1ee7dfc1151836028eba25629afcf45b56bbc721293e41aa2e9b8934/librt-0.7.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b8bb331aad734b059c4b450cd0a225652f16889e286b2345af5e2c3c625c3d85", size = 161705, upload-time = "2026-01-01T23:51:04.917Z" }, + { url = "https://files.pythonhosted.org/packages/45/a5/952bc840ac8917fbcefd6bc5f51ad02b89721729814f3e2bfcc1337a76d6/librt-0.7.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:467dbd7443bda08338fc8ad701ed38cef48194017554f4c798b0a237904b3f99", size = 171029, upload-time = "2026-01-01T23:51:06.09Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bf/c017ff7da82dc9192cf40d5e802a48a25d00e7639b6465cfdcee5893a22c/librt-0.7.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50d1d1ee813d2d1a3baf2873634ba506b263032418d16287c92ec1cc9c1a00cb", size = 184704, upload-time = "2026-01-01T23:51:07.549Z" }, + { url = "https://files.pythonhosted.org/packages/77/ec/72f3dd39d2cdfd6402ab10836dc9cbf854d145226062a185b419c4f1624a/librt-0.7.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e5070cf3ec92d98f57574da0224f8c73faf1ddd6d8afa0b8c9f6e86997bc74", size = 180719, upload-time = "2026-01-01T23:51:09.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/86/06e7a1a81b246f3313bf515dd9613a1c81583e6fd7843a9f4d625c4e926d/librt-0.7.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bdb9f3d865b2dafe7f9ad7f30ef563c80d0ddd2fdc8cc9b8e4f242f475e34d75", size = 174537, upload-time = "2026-01-01T23:51:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/83/08/f9fb2edc9c7a76e95b2924ce81d545673f5b034e8c5dd92159d1c7dae0c6/librt-0.7.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8185c8497d45164e256376f9da5aed2bb26ff636c798c9dabe313b90e9f25b28", size = 195238, upload-time = "2026-01-01T23:51:11.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/56/ea2d2489d3ea1f47b301120e03a099e22de7b32c93df9a211e6ff4f9bf38/librt-0.7.7-cp311-cp311-win32.whl", hash = "sha256:44d63ce643f34a903f09ff7ca355aae019a3730c7afd6a3c037d569beeb5d151", size = 42939, upload-time = "2026-01-01T23:51:13.192Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/c288f417e42ba2a037f1c0753219e277b33090ed4f72f292fb6fe175db4c/librt-0.7.7-cp311-cp311-win_amd64.whl", hash = "sha256:7d13cc340b3b82134f8038a2bfe7137093693dcad8ba5773da18f95ad6b77a8a", size = 49240, upload-time = "2026-01-01T23:51:14.264Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/738eb33a6c1516fdb2dfd2a35db6e5300f7616679b573585be0409bc6890/librt-0.7.7-cp311-cp311-win_arm64.whl", hash = "sha256:983de36b5a83fe9222f4f7dcd071f9b1ac6f3f17c0af0238dadfb8229588f890", size = 42613, upload-time = "2026-01-01T23:51:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/1cd9d752070011641e8aee046c851912d5f196ecd726fffa7aed2070f3e0/librt-0.7.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a85a1fc4ed11ea0eb0a632459ce004a2d14afc085a50ae3463cd3dfe1ce43fc", size = 55687, upload-time = "2026-01-01T23:51:16.291Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/d5a1d4221c4fe7e76ae1459d24d6037783cb83c7645164c07d7daf1576ec/librt-0.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87654e29a35938baead1c4559858f346f4a2a7588574a14d784f300ffba0efd", size = 57136, upload-time = "2026-01-01T23:51:17.363Z" }, + { url = "https://files.pythonhosted.org/packages/23/6f/0c86b5cb5e7ef63208c8cc22534df10ecc5278efc0d47fb8815577f3ca2f/librt-0.7.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c9faaebb1c6212c20afd8043cd6ed9de0a47d77f91a6b5b48f4e46ed470703fe", size = 165320, upload-time = "2026-01-01T23:51:18.455Z" }, + { url = "https://files.pythonhosted.org/packages/16/37/df4652690c29f645ffe405b58285a4109e9fe855c5bb56e817e3e75840b3/librt-0.7.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1908c3e5a5ef86b23391448b47759298f87f997c3bd153a770828f58c2bb4630", size = 174216, upload-time = "2026-01-01T23:51:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d6/d3afe071910a43133ec9c0f3e4ce99ee6df0d4e44e4bddf4b9e1c6ed41cc/librt-0.7.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbc4900e95a98fc0729523be9d93a8fedebb026f32ed9ffc08acd82e3e181503", size = 189005, upload-time = "2026-01-01T23:51:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/74060a870fe2d9fd9f47824eba6717ce7ce03124a0d1e85498e0e7efc1b2/librt-0.7.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7ea4e1fbd253e5c68ea0fe63d08577f9d288a73f17d82f652ebc61fa48d878d", size = 183961, upload-time = "2026-01-01T23:51:22.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/918a86c66304af66a3c1d46d54df1b2d0b8894babc42a14fb6f25511497f/librt-0.7.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef7699b7a5a244b1119f85c5bbc13f152cd38240cbb2baa19b769433bae98e50", size = 177610, upload-time = "2026-01-01T23:51:23.874Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d7/b5e58dc2d570f162e99201b8c0151acf40a03a39c32ab824dd4febf12736/librt-0.7.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:955c62571de0b181d9e9e0a0303c8bc90d47670a5eff54cf71bf5da61d1899cf", size = 199272, upload-time = "2026-01-01T23:51:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" }, + { url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" }, + { url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" }, + { url = "https://files.pythonhosted.org/packages/69/9c/228a5c1224bd23809a635490a162e9cbdc68d99f0eeb4a696f07886b8206/librt-0.7.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:23daa1ab0512bafdd677eb1bfc9611d8ffbe2e328895671e64cb34166bc1b8c8", size = 55188, upload-time = "2026-01-01T23:51:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c2/0e7c6067e2b32a156308205e5728f4ed6478c501947e9142f525afbc6bd2/librt-0.7.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:558a9e5a6f3cc1e20b3168fb1dc802d0d8fa40731f6e9932dcc52bbcfbd37111", size = 56895, upload-time = "2026-01-01T23:51:44.534Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/de50ff70c80855eb79d1d74035ef06f664dd073fb7fb9d9fb4429651b8eb/librt-0.7.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2567cb48dc03e5b246927ab35cbb343376e24501260a9b5e30b8e255dca0d1d2", size = 163724, upload-time = "2026-01-01T23:51:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/6e/19/f8e4bf537899bdef9e0bb9f0e4b18912c2d0f858ad02091b6019864c9a6d/librt-0.7.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6066c638cdf85ff92fc6f932d2d73c93a0e03492cdfa8778e6d58c489a3d7259", size = 172470, upload-time = "2026-01-01T23:51:46.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/4c/dcc575b69d99076768e8dd6141d9aecd4234cba7f0e09217937f52edb6ed/librt-0.7.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a609849aca463074c17de9cda173c276eb8fee9e441053529e7b9e249dc8b8ee", size = 186806, upload-time = "2026-01-01T23:51:48.009Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f8/4094a2b7816c88de81239a83ede6e87f1138477d7ee956c30f136009eb29/librt-0.7.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add4e0a000858fe9bb39ed55f31085506a5c38363e6eb4a1e5943a10c2bfc3d1", size = 181809, upload-time = "2026-01-01T23:51:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/821b7c0ab1b5a6cd9aee7ace8309c91545a2607185101827f79122219a7e/librt-0.7.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a3bfe73a32bd0bdb9a87d586b05a23c0a1729205d79df66dee65bb2e40d671ba", size = 175597, upload-time = "2026-01-01T23:51:50.636Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/27f6bfbcc764805864c04211c6ed636fe1d58f57a7b68d1f4ae5ed74e0e0/librt-0.7.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ecce0544d3db91a40f8b57ae26928c02130a997b540f908cefd4d279d6c5848", size = 196506, upload-time = "2026-01-01T23:51:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/46/ba/c9b9c6fc931dd7ea856c573174ccaf48714905b1a7499904db2552e3bbaf/librt-0.7.7-cp314-cp314-win32.whl", hash = "sha256:8f7a74cf3a80f0c3b0ec75b0c650b2f0a894a2cec57ef75f6f72c1e82cdac61d", size = 39747, upload-time = "2026-01-01T23:51:53.683Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/cd1269337c4cde3ee70176ee611ab0058aa42fc8ce5c9dce55f48facfcd8/librt-0.7.7-cp314-cp314-win_amd64.whl", hash = "sha256:3d1fe2e8df3268dd6734dba33ededae72ad5c3a859b9577bc00b715759c5aaab", size = 45971, upload-time = "2026-01-01T23:51:54.697Z" }, + { url = "https://files.pythonhosted.org/packages/79/fd/e0844794423f5583108c5991313c15e2b400995f44f6ec6871f8aaf8243c/librt-0.7.7-cp314-cp314-win_arm64.whl", hash = "sha256:2987cf827011907d3dfd109f1be0d61e173d68b1270107bb0e89f2fca7f2ed6b", size = 39075, upload-time = "2026-01-01T23:51:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/02/211fd8f7c381e7b2a11d0fdfcd410f409e89967be2e705983f7c6342209a/librt-0.7.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e92c8de62b40bfce91d5e12c6e8b15434da268979b1af1a6589463549d491e6", size = 57368, upload-time = "2026-01-01T23:51:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/aca257affae73ece26041ae76032153266d110453173f67d7603058e708c/librt-0.7.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f683dcd49e2494a7535e30f779aa1ad6e3732a019d80abe1309ea91ccd3230e3", size = 59238, upload-time = "2026-01-01T23:51:58.066Z" }, + { url = "https://files.pythonhosted.org/packages/96/47/7383a507d8e0c11c78ca34c9d36eab9000db5989d446a2f05dc40e76c64f/librt-0.7.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b15e5d17812d4d629ff576699954f74e2cc24a02a4fc401882dd94f81daba45", size = 183870, upload-time = "2026-01-01T23:51:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/50f3d8eec8efdaf79443963624175c92cec0ba84827a66b7fcfa78598e51/librt-0.7.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c084841b879c4d9b9fa34e5d5263994f21aea7fd9c6add29194dbb41a6210536", size = 194608, upload-time = "2026-01-01T23:52:00.419Z" }, + { url = "https://files.pythonhosted.org/packages/23/d9/1b6520793aadb59d891e3b98ee057a75de7f737e4a8b4b37fdbecb10d60f/librt-0.7.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c8fb9966f84737115513fecbaf257f9553d067a7dd45a69c2c7e5339e6a8dc", size = 206776, upload-time = "2026-01-01T23:52:01.705Z" }, + { url = "https://files.pythonhosted.org/packages/ff/db/331edc3bba929d2756fa335bfcf736f36eff4efcb4f2600b545a35c2ae58/librt-0.7.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5fb1ecb2c35362eab2dbd354fd1efa5a8440d3e73a68be11921042a0edc0ff", size = 203206, upload-time = "2026-01-01T23:52:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/6af79ec77204e85f6f2294fc171a30a91bb0e35d78493532ed680f5d98be/librt-0.7.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d1454899909d63cc9199a89fcc4f81bdd9004aef577d4ffc022e600c412d57f3", size = 196697, upload-time = "2026-01-01T23:52:04.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/de55ecce4b2796d6d243295c221082ca3a944dc2fb3a52dcc8660ce7727d/librt-0.7.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7ef28f2e7a016b29792fe0a2dd04dec75725b32a1264e390c366103f834a9c3a", size = 217193, upload-time = "2026-01-01T23:52:06.159Z" }, + { url = "https://files.pythonhosted.org/packages/41/61/33063e271949787a2f8dd33c5260357e3d512a114fc82ca7890b65a76e2d/librt-0.7.7-cp314-cp314t-win32.whl", hash = "sha256:5e419e0db70991b6ba037b70c1d5bbe92b20ddf82f31ad01d77a347ed9781398", size = 40277, upload-time = "2026-01-01T23:52:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/1abd972349f83a696ea73159ac964e63e2d14086fdd9bc7ca878c25fced4/librt-0.7.7-cp314-cp314t-win_amd64.whl", hash = "sha256:d6b7d93657332c817b8d674ef6bf1ab7796b4f7ce05e420fd45bd258a72ac804", size = 46765, upload-time = "2026-01-01T23:52:08.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[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 = "omophub" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "python-dotenv" }, + { name = "respx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "python-dotenv", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "typing-extensions", specifier = ">=4.5.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] From e8cb1d6f0d60356566c3d3a7c84e7db30eea8aef Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Tue, 6 Jan 2026 10:20:28 +0000 Subject: [PATCH 14/48] Release preparation --- README.md | 10 +-- src/omophub/resources/concepts.py | 1 - src/omophub/resources/vocabularies.py | 2 - tests/unit/resources/test_concepts.py | 83 +++++++++------------- tests/unit/resources/test_mappings.py | 50 +++---------- tests/unit/resources/test_relationships.py | 16 ++--- tests/unit/resources/test_vocabularies.py | 65 +++++------------ 7 files changed, 74 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index 7e485f0..1606723 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ for c in results["concepts"]: print(f"{c['concept_id']}: {c['concept_name']}") # Map ICD-10 code to SNOMED -mappings = client.mappings.get_by_code("ICD10CM", "E11.9", target_vocabularies=["SNOMED"]) +mappings = client.mappings.get_by_code("ICD10CM", "E11.9", target_vocabulary="SNOMED") # Navigate concept hierarchy ancestors = client.hierarchy.ancestors(201826, max_levels=3) @@ -82,8 +82,8 @@ Validate and map clinical codes during OMOP CDM transformations: def validate_and_map(source_vocab, source_code): concept = client.concepts.get_by_code(source_vocab, source_code) if concept["standard_concept"] != "S": - mappings = client.mappings.get(concept["concept_id"], - target_vocabularies=["SNOMED"]) + mappings = client.mappings.get(concept["concept_id"], + target_vocabulary="SNOMED") return mappings["mappings"][0]["target_concept_id"] return concept["concept_id"] ``` @@ -109,7 +109,7 @@ Explore hierarchies to build comprehensive concept sets: ```python # Get all descendants of "Type 2 diabetes mellitus" for phenotype -descendants = client.hierarchy.descendants(201826, max_levels=5, standard_only=True) +descendants = client.hierarchy.descendants(201826, max_levels=5) concept_set = [d["concept_id"] for d in descendants["concepts"]] print(f"Found {len(concept_set)} concepts for T2DM phenotype") ``` @@ -120,7 +120,7 @@ Build terminology lookups into healthcare applications: ```python # Autocomplete for clinical coding interface -suggestions = client.concepts.suggest("diab", vocabulary="SNOMED", limit=10) +suggestions = client.concepts.suggest("diab", vocabulary_ids=["SNOMED"], page_size=10) # Returns: ["Diabetes mellitus", "Diabetic nephropathy", "Diabetic retinopathy", ...] ``` diff --git a/src/omophub/resources/concepts.py b/src/omophub/resources/concepts.py index 1809ba6..def06dc 100644 --- a/src/omophub/resources/concepts.py +++ b/src/omophub/resources/concepts.py @@ -7,7 +7,6 @@ if TYPE_CHECKING: from .._request import AsyncRequest, Request from ..types.concept import BatchConceptResult, Concept - from ..types.search import Suggestion class GetConceptParams(TypedDict, total=False): diff --git a/src/omophub/resources/vocabularies.py b/src/omophub/resources/vocabularies.py index 09ff6ac..f298cdf 100644 --- a/src/omophub/resources/vocabularies.py +++ b/src/omophub/resources/vocabularies.py @@ -5,8 +5,6 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - import builtins - from .._request import AsyncRequest, Request from ..types.vocabulary import Vocabulary, VocabularyStats diff --git a/tests/unit/resources/test_concepts.py b/tests/unit/resources/test_concepts.py index 2168a1e..0695e00 100644 --- a/tests/unit/resources/test_concepts.py +++ b/tests/unit/resources/test_concepts.py @@ -137,15 +137,15 @@ def test_suggest_concepts_with_filters( sync_client.concepts.suggest( "diabetes", - vocabulary="SNOMED", - domain="Condition", - limit=20, + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + page_size=20, ) url_str = str(route.calls[0].request.url) - assert "vocabulary=SNOMED" in url_str - assert "domain=Condition" in url_str - assert "limit=20" in url_str + assert "vocabulary_ids=SNOMED" in url_str + assert "domain_ids=Condition" in url_str + assert "page_size=20" in url_str @respx.mock def test_get_related_concepts(self, sync_client: OMOPHub, base_url: str) -> None: @@ -179,23 +179,15 @@ def test_get_related_with_options( sync_client.concepts.related( 201826, - relatedness_types=["hierarchical", "semantic"], - vocabulary_ids=["SNOMED"], - domain_ids=["Condition"], - min_relatedness_score=0.5, - max_results=100, - include_scores=False, - standard_concepts_only=True, + relationship_types=["Is a", "Maps to"], + min_score=0.5, + page_size=100, ) url_str = str(route.calls[0].request.url) - assert "relatedness_types=hierarchical%2Csemantic" in url_str - assert "vocabulary_ids=SNOMED" in url_str - assert "domain_ids=Condition" in url_str - assert "min_relatedness_score=0.5" in url_str - assert "max_results=100" in url_str - assert "include_scores=false" in url_str - assert "standard_concepts_only=true" in url_str + assert "relationship_types=Is+a%2CMaps+to" in url_str + assert "min_score=0.5" in url_str + assert "page_size=100" in url_str @respx.mock def test_get_concept_relationships( @@ -230,20 +222,16 @@ def test_get_concept_relationships_with_options( sync_client.concepts.relationships( 201826, - relationship_type="Is a", - target_vocabulary="SNOMED", + relationship_ids="Is a", + vocabulary_ids="SNOMED", include_invalid=True, - page=2, - page_size=50, ) url_str = str(route.calls[0].request.url) # All params use snake_case to match API standards - assert "relationship_type=Is+a" in url_str - assert "target_vocabulary=SNOMED" in url_str + assert "relationship_ids=Is+a" in url_str + assert "vocabulary_ids=SNOMED" in url_str assert "include_invalid=true" in url_str - assert "page=2" in url_str - assert "page_size=50" in url_str class TestAsyncConceptsResource: @@ -369,15 +357,15 @@ async def test_async_suggest_with_filters( await async_client.concepts.suggest( "aspirin", - vocabulary="SNOMED", - domain="Drug", - limit=5, + vocabulary_ids=["SNOMED"], + domain_ids=["Drug"], + page_size=5, ) url_str = str(route.calls[0].request.url) - assert "vocabulary=SNOMED" in url_str - assert "domain=Drug" in url_str - assert "limit=5" in url_str + assert "vocabulary_ids=SNOMED" in url_str + assert "domain_ids=Drug" in url_str + assert "page_size=5" in url_str @pytest.mark.asyncio @respx.mock @@ -408,20 +396,15 @@ async def test_async_get_related_with_options( await async_client.concepts.related( 201826, - relatedness_types=["semantic"], - vocabulary_ids=["SNOMED"], - domain_ids=["Condition"], - min_relatedness_score=0.7, - max_results=25, - include_scores=True, - standard_concepts_only=True, + relationship_types=["Is a"], + min_score=0.7, + page_size=25, ) url_str = str(route.calls[0].request.url) - assert "relatedness_types=semantic" in url_str - assert "vocabulary_ids=SNOMED" in url_str - assert "min_relatedness_score=0.7" in url_str - assert "standard_concepts_only=true" in url_str + assert "relationship_types=Is+a" in url_str + assert "min_score=0.7" in url_str + assert "page_size=25" in url_str @pytest.mark.asyncio @respx.mock @@ -452,14 +435,12 @@ async def test_async_get_relationships_with_options( await async_client.concepts.relationships( 201826, - relationship_type="Maps to", - target_vocabulary="ICD10CM", + relationship_ids="Maps to", + vocabulary_ids="ICD10CM", include_invalid=True, - page=1, - page_size=100, ) url_str = str(route.calls[0].request.url) - assert "relationship_type=Maps+to" in url_str - assert "target_vocabulary=ICD10CM" in url_str + assert "relationship_ids=Maps+to" in url_str + assert "vocabulary_ids=ICD10CM" in url_str assert "include_invalid=true" in url_str diff --git a/tests/unit/resources/test_mappings.py b/tests/unit/resources/test_mappings.py index 65b175c..45b34d4 100644 --- a/tests/unit/resources/test_mappings.py +++ b/tests/unit/resources/test_mappings.py @@ -44,7 +44,7 @@ def test_get_mappings(self, sync_client: OMOPHub, base_url: str) -> None: def test_get_mappings_with_filters( self, sync_client: OMOPHub, base_url: str ) -> None: - """Test getting mappings with all filter options.""" + """Test getting mappings with filter options.""" route = respx.get(f"{base_url}/concepts/201826/mappings").mock( return_value=Response( 200, json={"success": True, "data": {"mappings": []}} @@ -53,33 +53,13 @@ def test_get_mappings_with_filters( sync_client.mappings.get( 201826, - target_vocabularies=["ICD10CM", "ICD9CM"], - mapping_types=["MAPS TO", "IS A"], - direction="outgoing", - include_indirect=True, - standard_only=True, - include_mapping_quality=True, - include_synonyms=True, - include_context=True, - active_only=False, - sort_by="mapping_type", - sort_order="asc", - page=1, - page_size=100, + target_vocabulary="ICD10CM", + include_invalid=True, ) url_str = str(route.calls[0].request.url) - assert "target_vocabularies=ICD10CM%2CICD9CM" in url_str - assert "mapping_types=MAPS+TO%2CIS+A" in url_str - assert "direction=outgoing" in url_str - assert "include_indirect=true" in url_str - assert "standard_only=true" in url_str - assert "include_mapping_quality=true" in url_str - assert "include_synonyms=true" in url_str - assert "include_context=true" in url_str - assert "active_only=false" in url_str - assert "sort_by=mapping_type" in url_str - assert "sort_order=asc" in url_str + assert "target_vocabulary=ICD10CM" in url_str + assert "include_invalid=true" in url_str @respx.mock def test_map_concepts(self, sync_client: OMOPHub, base_url: str) -> None: @@ -158,7 +138,7 @@ async def test_async_get_mappings( async def test_async_get_mappings_with_filters( self, async_client: omophub.AsyncOMOPHub, base_url: str ) -> None: - """Test async mappings with all filters.""" + """Test async mappings with filters.""" route = respx.get(f"{base_url}/concepts/201826/mappings").mock( return_value=Response( 200, json={"success": True, "data": {"mappings": []}} @@ -167,23 +147,13 @@ async def test_async_get_mappings_with_filters( await async_client.mappings.get( 201826, - target_vocabularies=["ICD10CM"], - mapping_types=["MAPS TO"], - direction="both", - include_indirect=True, - standard_only=True, - include_mapping_quality=True, - include_synonyms=True, - include_context=True, - active_only=False, - sort_by="target_vocabulary", - sort_order="desc", + target_vocabulary="ICD10CM", + include_invalid=True, ) url_str = str(route.calls[0].request.url) - assert "target_vocabularies=ICD10CM" in url_str - assert "include_indirect=true" in url_str - assert "active_only=false" in url_str + assert "target_vocabulary=ICD10CM" in url_str + assert "include_invalid=true" in url_str @pytest.mark.asyncio @respx.mock diff --git a/tests/unit/resources/test_relationships.py b/tests/unit/resources/test_relationships.py index 64ed218..e44677b 100644 --- a/tests/unit/resources/test_relationships.py +++ b/tests/unit/resources/test_relationships.py @@ -53,16 +53,16 @@ def test_get_relationships_with_filters( sync_client.relationships.get( 201826, - relationship_type="Is a", - target_vocabulary="SNOMED", + relationship_ids=["Is a"], + vocabulary_ids=["SNOMED"], include_invalid=True, page=2, page_size=100, ) url_str = str(route.calls[0].request.url) - assert "relationship_type=Is+a" in url_str - assert "target_vocabulary=SNOMED" in url_str + assert "relationship_ids=Is+a" in url_str + assert "vocabulary_ids=SNOMED" in url_str assert "include_invalid=true" in url_str assert "page=2" in url_str assert "page_size=100" in url_str @@ -137,16 +137,16 @@ async def test_async_get_relationships_with_filters( await async_client.relationships.get( 201826, - relationship_type="Maps to", - target_vocabulary="ICD10CM", + relationship_ids=["Maps to"], + vocabulary_ids=["ICD10CM"], include_invalid=True, page=1, page_size=200, ) url_str = str(route.calls[0].request.url) - assert "relationship_type=Maps+to" in url_str - assert "target_vocabulary=ICD10CM" in url_str + assert "relationship_ids=Maps+to" in url_str + assert "vocabulary_ids=ICD10CM" in url_str assert "include_invalid=true" in url_str assert "page=1" in url_str assert "page_size=200" in url_str diff --git a/tests/unit/resources/test_vocabularies.py b/tests/unit/resources/test_vocabularies.py index 8e38c2e..bace5b6 100644 --- a/tests/unit/resources/test_vocabularies.py +++ b/tests/unit/resources/test_vocabularies.py @@ -91,25 +91,18 @@ def test_get_vocabulary(self, sync_client: OMOPHub, base_url: str) -> None: assert result["vocabulary_id"] == "SNOMED" @respx.mock - def test_get_vocabulary_with_options( + def test_get_vocabulary_basic( self, sync_client: OMOPHub, base_url: str ) -> None: - """Test getting vocabulary with include options.""" - route = respx.get(f"{base_url}/vocabularies/SNOMED").mock( + """Test getting vocabulary (no additional options - use stats() for statistics).""" + respx.get(f"{base_url}/vocabularies/SNOMED").mock( return_value=Response( 200, json={"success": True, "data": {"vocabulary_id": "SNOMED"}} ) ) - sync_client.vocabularies.get( - "SNOMED", - include_stats=True, - include_domains=True, - ) - - url_str = str(route.calls[0].request.url) - assert "include_stats=true" in url_str - assert "include_domains=true" in url_str + result = sync_client.vocabularies.get("SNOMED") + assert result["vocabulary_id"] == "SNOMED" @respx.mock def test_get_vocabulary_stats(self, sync_client: OMOPHub, base_url: str) -> None: @@ -132,29 +125,22 @@ def test_get_vocabulary_stats(self, sync_client: OMOPHub, base_url: str) -> None @respx.mock def test_get_vocabulary_domains(self, sync_client: OMOPHub, base_url: str) -> None: - """Test getting vocabulary domains.""" + """Test getting all standard OHDSI domains.""" domains_response = { "success": True, "data": { "domains": [ - {"domain_id": "Condition", "concept_count": 150000}, - {"domain_id": "Drug", "concept_count": 100000}, + {"domain_id": "Condition", "domain_name": "Condition"}, + {"domain_id": "Drug", "domain_name": "Drug"}, ], }, } - route = respx.get(f"{base_url}/vocabularies/domains").mock( + respx.get(f"{base_url}/vocabularies/domains").mock( return_value=Response(200, json=domains_response) ) - result = sync_client.vocabularies.domains( - vocabulary_ids=["SNOMED"], page=1, page_size=25 - ) - + result = sync_client.vocabularies.domains() assert "domains" in result - url_str = str(route.calls[0].request.url) - assert "vocabulary_ids=SNOMED" in url_str - assert "page=1" in url_str - assert "page_size=25" in url_str @respx.mock def test_get_vocabulary_concepts(self, sync_client: OMOPHub, base_url: str) -> None: @@ -255,25 +241,18 @@ async def test_async_get_vocabulary( @pytest.mark.asyncio @respx.mock - async def test_async_get_vocabulary_with_options( + async def test_async_get_vocabulary_basic( self, async_client: omophub.AsyncOMOPHub, base_url: str ) -> None: - """Test async get vocabulary with options.""" - route = respx.get(f"{base_url}/vocabularies/SNOMED").mock( + """Test async get vocabulary (no additional options - use stats() for statistics).""" + respx.get(f"{base_url}/vocabularies/SNOMED").mock( return_value=Response( 200, json={"success": True, "data": {"vocabulary_id": "SNOMED"}} ) ) - await async_client.vocabularies.get( - "SNOMED", - include_stats=True, - include_domains=True, - ) - - url_str = str(route.calls[0].request.url) - assert "include_stats=true" in url_str - assert "include_domains=true" in url_str + result = await async_client.vocabularies.get("SNOMED") + assert result["vocabulary_id"] == "SNOMED" @pytest.mark.asyncio @respx.mock @@ -299,21 +278,15 @@ async def test_async_get_vocabulary_stats( async def test_async_get_vocabulary_domains( self, async_client: omophub.AsyncOMOPHub, base_url: str ) -> None: - """Test async getting vocabulary domains.""" - route = respx.get(f"{base_url}/vocabularies/domains").mock( + """Test async getting all standard OHDSI domains.""" + respx.get(f"{base_url}/vocabularies/domains").mock( return_value=Response( 200, json={"success": True, "data": {"domains": []}} ) ) - await async_client.vocabularies.domains( - vocabulary_ids=["SNOMED"], page=2, page_size=30 - ) - - url_str = str(route.calls[0].request.url) - assert "vocabulary_ids=SNOMED" in url_str - assert "page=2" in url_str - assert "page_size=30" in url_str + result = await async_client.vocabularies.domains() + assert "domains" in result @pytest.mark.asyncio @respx.mock From e0711ab25e5a2f15fca7880bd8a38589a23bf82c Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Tue, 6 Jan 2026 10:35:37 +0000 Subject: [PATCH 15/48] Integration tests --- src/omophub/resources/mappings.py | 12 ++++++++--- tests/integration/test_concepts.py | 11 +++++----- tests/integration/test_mappings.py | 9 +++----- tests/integration/test_relationships.py | 20 ++++++----------- tests/integration/test_vocabularies.py | 10 +++------ tests/unit/resources/test_concepts.py | 8 ++----- tests/unit/resources/test_domains.py | 20 +++++------------ tests/unit/resources/test_hierarchy.py | 18 +++++++++++----- tests/unit/resources/test_mappings.py | 26 ++++++----------------- tests/unit/resources/test_search.py | 20 ++++++++--------- tests/unit/resources/test_vocabularies.py | 12 +++-------- tests/unit/test_client.py | 4 +++- tests/unit/test_http.py | 13 ++++++------ tests/unit/test_pagination.py | 4 +++- tests/unit/test_request.py | 16 ++++---------- 15 files changed, 80 insertions(+), 123 deletions(-) diff --git a/src/omophub/resources/mappings.py b/src/omophub/resources/mappings.py index 5dbbe6f..f768a5e 100644 --- a/src/omophub/resources/mappings.py +++ b/src/omophub/resources/mappings.py @@ -41,7 +41,9 @@ def get( if vocab_release: params["vocab_release"] = vocab_release - return self._request.get(f"/concepts/{concept_id}/mappings", params=params or None) + return self._request.get( + f"/concepts/{concept_id}/mappings", params=params or None + ) def map( self, @@ -77,7 +79,9 @@ def map( if vocab_release: params["vocab_release"] = vocab_release - return self._request.post("/concepts/map", json_data=body, params=params or None) + return self._request.post( + "/concepts/map", json_data=body, params=params or None + ) class AsyncMappings: @@ -151,4 +155,6 @@ async def map( if vocab_release: params["vocab_release"] = vocab_release - return await self._request.post("/concepts/map", json_data=body, params=params or None) + return await self._request.post( + "/concepts/map", json_data=body, params=params or None + ) diff --git a/tests/integration/test_concepts.py b/tests/integration/test_concepts.py index 6804c70..03fc76a 100644 --- a/tests/integration/test_concepts.py +++ b/tests/integration/test_concepts.py @@ -61,7 +61,7 @@ def test_batch_concepts(self, integration_client: OMOPHub) -> None: def test_suggest_concepts(self, integration_client: OMOPHub) -> None: """Get concept suggestions.""" - result = integration_client.concepts.suggest("diabetes", limit=5) + result = integration_client.concepts.suggest("diabetes", page_size=5) # Result is a list of concept objects with concept_name field suggestions = extract_data(result, "suggestions") @@ -81,9 +81,9 @@ def test_suggest_concepts_with_filters(self, integration_client: OMOPHub) -> Non """Get concept suggestions with vocabulary filter.""" result = integration_client.concepts.suggest( "aspirin", - vocabulary="RxNorm", - domain="Drug", - limit=10, + vocabulary_ids=["RxNorm"], + domain_ids=["Drug"], + page_size=10, ) # Should get at least one result or empty list @@ -94,7 +94,7 @@ def test_get_related_concepts(self, integration_client: OMOPHub) -> None: """Get related concepts.""" result = integration_client.concepts.related( DIABETES_CONCEPT_ID, - max_results=10, + page_size=10, ) # Should have related_concepts key @@ -105,7 +105,6 @@ def test_get_concept_relationships(self, integration_client: OMOPHub) -> None: """Get concept relationships.""" result = integration_client.concepts.relationships( DIABETES_CONCEPT_ID, - page_size=20, ) # Should have relationships diff --git a/tests/integration/test_mappings.py b/tests/integration/test_mappings.py index 22b0ee1..96e8141 100644 --- a/tests/integration/test_mappings.py +++ b/tests/integration/test_mappings.py @@ -27,20 +27,17 @@ def test_get_mappings_to_icd10(self, integration_client: OMOPHub) -> None: """Get ICD-10 mappings for SNOMED concept.""" result = integration_client.mappings.get( DIABETES_CONCEPT_ID, - target_vocabularies=["ICD10CM"], - direction="outgoing", + target_vocabulary="ICD10CM", ) mappings = extract_data(result, "mappings") assert isinstance(mappings, list) def test_get_mappings_with_options(self, integration_client: OMOPHub) -> None: - """Get mappings with quality and context options.""" + """Get mappings with include_invalid option.""" result = integration_client.mappings.get( DIABETES_CONCEPT_ID, - include_mapping_quality=True, - include_context=True, - page_size=50, + include_invalid=True, ) mappings = extract_data(result, "mappings") diff --git a/tests/integration/test_relationships.py b/tests/integration/test_relationships.py index c89d1dc..cd50d65 100644 --- a/tests/integration/test_relationships.py +++ b/tests/integration/test_relationships.py @@ -29,7 +29,7 @@ def test_get_relationships_with_type_filter( """Get relationships filtered by type.""" result = integration_client.relationships.get( DIABETES_CONCEPT_ID, - relationship_type="Is a", + relationship_ids=["Is a"], page_size=50, ) @@ -42,7 +42,7 @@ def test_get_relationships_with_vocabulary_filter( """Get relationships filtered by target vocabulary.""" result = integration_client.relationships.get( DIABETES_CONCEPT_ID, - target_vocabulary="SNOMED", + vocabulary_ids=["SNOMED"], page_size=100, ) @@ -60,25 +60,17 @@ def test_get_relationship_types(self, integration_client: OMOPHub) -> None: def test_get_relationship_types_with_filters( self, integration_client: OMOPHub ) -> None: - """Get relationship types with filters.""" + """Get relationship types with pagination.""" result = integration_client.relationships.types( - vocabulary_ids=["SNOMED"], - include_reverse=True, - include_usage_stats=True, page_size=50, ) types = result.get("relationship_types", result) assert isinstance(types, list) - def test_get_relationship_types_by_category( - self, integration_client: OMOPHub - ) -> None: - """Get relationship types by category.""" - result = integration_client.relationships.types( - category="hierarchy", - standard_only=True, - ) + def test_get_relationship_types_basic(self, integration_client: OMOPHub) -> None: + """Get relationship types with default settings.""" + result = integration_client.relationships.types() types = result.get("relationship_types", result) assert isinstance(types, list) diff --git a/tests/integration/test_vocabularies.py b/tests/integration/test_vocabularies.py index 690bc7f..ed43077 100644 --- a/tests/integration/test_vocabularies.py +++ b/tests/integration/test_vocabularies.py @@ -48,13 +48,9 @@ def test_get_vocabulary(self, integration_client: OMOPHub) -> None: assert vocab["vocabulary_id"] == "SNOMED" assert "vocabulary_name" in vocab - def test_get_vocabulary_with_options(self, integration_client: OMOPHub) -> None: - """Get vocabulary with stats and domains.""" - vocab = integration_client.vocabularies.get( - "SNOMED", - include_stats=True, - include_domains=True, - ) + def test_get_vocabulary_basic(self, integration_client: OMOPHub) -> None: + """Get vocabulary (use stats() method for statistics).""" + vocab = integration_client.vocabularies.get("SNOMED") assert vocab["vocabulary_id"] == "SNOMED" assert "vocabulary_name" in vocab diff --git a/tests/unit/resources/test_concepts.py b/tests/unit/resources/test_concepts.py index 0695e00..685c72e 100644 --- a/tests/unit/resources/test_concepts.py +++ b/tests/unit/resources/test_concepts.py @@ -87,9 +87,7 @@ def test_batch_concepts_with_options( ) -> None: """Test batch concepts with all options.""" route = respx.post(f"{base_url}/concepts/batch").mock( - return_value=Response( - 200, json={"success": True, "data": {"concepts": []}} - ) + return_value=Response(200, json={"success": True, "data": {"concepts": []}}) ) sync_client.concepts.batch( @@ -312,9 +310,7 @@ async def test_async_batch_concepts_with_options( ) -> None: """Test async batch with options.""" route = respx.post(f"{base_url}/concepts/batch").mock( - return_value=Response( - 200, json={"success": True, "data": {"concepts": []}} - ) + return_value=Response(200, json={"success": True, "data": {"concepts": []}}) ) await async_client.concepts.batch( diff --git a/tests/unit/resources/test_domains.py b/tests/unit/resources/test_domains.py index 827f809..38492cc 100644 --- a/tests/unit/resources/test_domains.py +++ b/tests/unit/resources/test_domains.py @@ -37,9 +37,7 @@ def test_list_domains(self, sync_client: OMOPHub, base_url: str) -> None: assert "domains" in result @respx.mock - def test_list_domains_with_stats( - self, sync_client: OMOPHub, base_url: str - ) -> None: + def test_list_domains_with_stats(self, sync_client: OMOPHub, base_url: str) -> None: """Test listing domains with include_stats option.""" route = respx.get(f"{base_url}/domains").mock( return_value=Response( @@ -72,9 +70,7 @@ def test_list_domains_without_stats( ) -> None: """Test listing domains without stats (default).""" route = respx.get(f"{base_url}/domains").mock( - return_value=Response( - 200, json={"success": True, "data": {"domains": []}} - ) + return_value=Response(200, json={"success": True, "data": {"domains": []}}) ) sync_client.domains.list() @@ -128,9 +124,7 @@ async def test_async_list_domains( ) -> None: """Test async listing domains.""" respx.get(f"{base_url}/domains").mock( - return_value=Response( - 200, json={"success": True, "data": {"domains": []}} - ) + return_value=Response(200, json={"success": True, "data": {"domains": []}}) ) result = await async_client.domains.list() @@ -143,9 +137,7 @@ async def test_async_list_domains_with_stats( ) -> None: """Test async listing domains with include_stats.""" route = respx.get(f"{base_url}/domains").mock( - return_value=Response( - 200, json={"success": True, "data": {"domains": []}} - ) + return_value=Response(200, json={"success": True, "data": {"domains": []}}) ) await async_client.domains.list(include_stats=True) @@ -160,9 +152,7 @@ async def test_async_get_domain_concepts( ) -> None: """Test async getting domain concepts.""" route = respx.get(f"{base_url}/domains/Condition/concepts").mock( - return_value=Response( - 200, json={"success": True, "data": {"concepts": []}} - ) + return_value=Response(200, json={"success": True, "data": {"concepts": []}}) ) await async_client.domains.concepts( diff --git a/tests/unit/resources/test_hierarchy.py b/tests/unit/resources/test_hierarchy.py index 2028818..41e4a00 100644 --- a/tests/unit/resources/test_hierarchy.py +++ b/tests/unit/resources/test_hierarchy.py @@ -23,8 +23,16 @@ def test_get_ancestors(self, sync_client: OMOPHub, base_url: str) -> None: "success": True, "data": { "ancestors": [ - {"concept_id": 201820, "concept_name": "Diabetes mellitus", "level": 1}, - {"concept_id": 4000, "concept_name": "Endocrine disorder", "level": 2}, + { + "concept_id": 201820, + "concept_name": "Diabetes mellitus", + "level": 1, + }, + { + "concept_id": 4000, + "concept_name": "Endocrine disorder", + "level": 2, + }, ], "summary": {"total_ancestors": 2, "max_level": 2}, }, @@ -37,7 +45,9 @@ def test_get_ancestors(self, sync_client: OMOPHub, base_url: str) -> None: assert "ancestors" in result @respx.mock - def test_get_ancestors_with_options(self, sync_client: OMOPHub, base_url: str) -> None: + def test_get_ancestors_with_options( + self, sync_client: OMOPHub, base_url: str + ) -> None: """Test getting ancestors with all options.""" route = respx.get(f"{base_url}/concepts/201826/ancestors").mock( return_value=Response( @@ -139,7 +149,6 @@ def test_get_descendants_max_levels_capped( assert "max_levels=20" in url_str - class TestAsyncHierarchyResource: """Tests for the asynchronous AsyncHierarchy resource.""" @@ -228,4 +237,3 @@ async def test_async_get_descendants_with_filters( assert "include_invalid=true" in url_str assert "max_levels=5" in url_str assert "include_paths=true" in url_str - diff --git a/tests/unit/resources/test_mappings.py b/tests/unit/resources/test_mappings.py index 45b34d4..6848ad4 100644 --- a/tests/unit/resources/test_mappings.py +++ b/tests/unit/resources/test_mappings.py @@ -46,9 +46,7 @@ def test_get_mappings_with_filters( ) -> None: """Test getting mappings with filter options.""" route = respx.get(f"{base_url}/concepts/201826/mappings").mock( - return_value=Response( - 200, json={"success": True, "data": {"mappings": []}} - ) + return_value=Response(200, json={"success": True, "data": {"mappings": []}}) ) sync_client.mappings.get( @@ -95,9 +93,7 @@ def test_map_concepts_with_options( ) -> None: """Test mapping concepts with additional options.""" route = respx.post(f"{base_url}/concepts/map").mock( - return_value=Response( - 200, json={"success": True, "data": {"mappings": []}} - ) + return_value=Response(200, json={"success": True, "data": {"mappings": []}}) ) sync_client.mappings.map( @@ -114,7 +110,6 @@ def test_map_concepts_with_options( assert route.calls[0].request.content - class TestAsyncMappingsResource: """Tests for the asynchronous AsyncMappings resource.""" @@ -125,9 +120,7 @@ async def test_async_get_mappings( ) -> None: """Test async getting mappings.""" respx.get(f"{base_url}/concepts/201826/mappings").mock( - return_value=Response( - 200, json={"success": True, "data": {"mappings": []}} - ) + return_value=Response(200, json={"success": True, "data": {"mappings": []}}) ) result = await async_client.mappings.get(201826) @@ -140,9 +133,7 @@ async def test_async_get_mappings_with_filters( ) -> None: """Test async mappings with filters.""" route = respx.get(f"{base_url}/concepts/201826/mappings").mock( - return_value=Response( - 200, json={"success": True, "data": {"mappings": []}} - ) + return_value=Response(200, json={"success": True, "data": {"mappings": []}}) ) await async_client.mappings.get( @@ -162,9 +153,7 @@ async def test_async_map_concepts( ) -> None: """Test async mapping concepts.""" respx.post(f"{base_url}/concepts/map").mock( - return_value=Response( - 200, json={"success": True, "data": {"mappings": []}} - ) + return_value=Response(200, json={"success": True, "data": {"mappings": []}}) ) result = await async_client.mappings.map( @@ -181,9 +170,7 @@ async def test_async_map_concepts_with_options( ) -> None: """Test async mapping with options.""" route = respx.post(f"{base_url}/concepts/map").mock( - return_value=Response( - 200, json={"success": True, "data": {"mappings": []}} - ) + return_value=Response(200, json={"success": True, "data": {"mappings": []}}) ) await async_client.mappings.map( @@ -194,4 +181,3 @@ async def test_async_map_concepts_with_options( ) assert route.calls[0].request.content - diff --git a/tests/unit/resources/test_search.py b/tests/unit/resources/test_search.py index 0716903..fafafc0 100644 --- a/tests/unit/resources/test_search.py +++ b/tests/unit/resources/test_search.py @@ -36,12 +36,12 @@ def test_basic_search(self, sync_client: OMOPHub, base_url: str) -> None: assert "concepts" in result @respx.mock - def test_basic_search_with_filters(self, sync_client: OMOPHub, base_url: str) -> None: + def test_basic_search_with_filters( + self, sync_client: OMOPHub, base_url: str + ) -> None: """Test basic search with vocabulary and domain filters.""" route = respx.get(f"{base_url}/search/concepts").mock( - return_value=Response( - 200, json={"success": True, "data": {"concepts": []}} - ) + return_value=Response(200, json={"success": True, "data": {"concepts": []}}) ) sync_client.search.basic( @@ -95,7 +95,9 @@ def test_basic_iter_single_page(self, sync_client: OMOPHub, base_url: str) -> No assert len(concepts) == 2 @respx.mock - def test_basic_iter_multiple_pages(self, sync_client: OMOPHub, base_url: str) -> None: + def test_basic_iter_multiple_pages( + self, sync_client: OMOPHub, base_url: str + ) -> None: """Test basic_iter auto-pagination across multiple pages.""" page1_response = { "success": True, @@ -214,9 +216,7 @@ async def test_async_basic_search_with_filters( ) -> None: """Test async basic search with all filters.""" route = respx.get(f"{base_url}/search/concepts").mock( - return_value=Response( - 200, json={"success": True, "data": {"concepts": []}} - ) + return_value=Response(200, json={"success": True, "data": {"concepts": []}}) ) await async_client.search.basic( @@ -248,9 +248,7 @@ async def test_async_advanced_search( ) -> None: """Test async advanced search.""" respx.post(f"{base_url}/search/advanced").mock( - return_value=Response( - 200, json={"success": True, "data": {"concepts": []}} - ) + return_value=Response(200, json={"success": True, "data": {"concepts": []}}) ) result = await async_client.search.advanced( diff --git a/tests/unit/resources/test_vocabularies.py b/tests/unit/resources/test_vocabularies.py index bace5b6..6016651 100644 --- a/tests/unit/resources/test_vocabularies.py +++ b/tests/unit/resources/test_vocabularies.py @@ -91,9 +91,7 @@ def test_get_vocabulary(self, sync_client: OMOPHub, base_url: str) -> None: assert result["vocabulary_id"] == "SNOMED" @respx.mock - def test_get_vocabulary_basic( - self, sync_client: OMOPHub, base_url: str - ) -> None: + def test_get_vocabulary_basic(self, sync_client: OMOPHub, base_url: str) -> None: """Test getting vocabulary (no additional options - use stats() for statistics).""" respx.get(f"{base_url}/vocabularies/SNOMED").mock( return_value=Response( @@ -280,9 +278,7 @@ async def test_async_get_vocabulary_domains( ) -> None: """Test async getting all standard OHDSI domains.""" respx.get(f"{base_url}/vocabularies/domains").mock( - return_value=Response( - 200, json={"success": True, "data": {"domains": []}} - ) + return_value=Response(200, json={"success": True, "data": {"domains": []}}) ) result = await async_client.vocabularies.domains() @@ -295,9 +291,7 @@ async def test_async_get_vocabulary_concepts( ) -> None: """Test async getting vocabulary concepts.""" route = respx.get(f"{base_url}/vocabularies/SNOMED/concepts").mock( - return_value=Response( - 200, json={"success": True, "data": {"concepts": []}} - ) + return_value=Response(200, json={"success": True, "data": {"concepts": []}}) ) await async_client.vocabularies.concepts( diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index c378206..55b7b7c 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -81,7 +81,9 @@ async def test_async_get_concept( concept = await async_client.concepts.get(201826) assert concept["concept_id"] == 201826 - def test_async_client_has_resources(self, async_client: omophub.AsyncOMOPHub) -> None: + def test_async_client_has_resources( + self, async_client: omophub.AsyncOMOPHub + ) -> None: """Test that async client has all expected resources.""" assert hasattr(async_client, "concepts") assert hasattr(async_client, "search") diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 781db40..23ef7f4 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -29,7 +29,7 @@ def test_request_success(self) -> None: return_value=Response(200, json={"success": True}) ) - content, status_code, headers = client.request( + content, status_code, _ = client.request( "GET", "https://api.example.com/test" ) @@ -167,7 +167,7 @@ def side_effect(request: httpx.Request) -> Response: respx.get("https://api.example.com/test").mock(side_effect=side_effect) with patch("time.sleep"): # Skip actual sleep - content, status_code, _ = client.request( + _, status_code, _ = client.request( "GET", "https://api.example.com/test" ) @@ -185,9 +185,8 @@ def test_max_retries_exceeded(self) -> None: side_effect=httpx.ConnectError("Connection refused") ) - with patch("time.sleep"): # Skip actual sleep - with pytest.raises(ConnectionError): - client.request("GET", "https://api.example.com/test") + with patch("time.sleep"), pytest.raises(ConnectionError): + client.request("GET", "https://api.example.com/test") client.close() @@ -244,7 +243,7 @@ async def test_request_success(self) -> None: return_value=Response(200, json={"success": True}) ) - content, status_code, headers = await client.request( + content, status_code, _ = await client.request( "GET", "https://api.example.com/test" ) @@ -325,7 +324,7 @@ def side_effect(request: httpx.Request) -> Response: respx.get("https://api.example.com/test").mock(side_effect=side_effect) with patch("asyncio.sleep"): # Skip actual sleep - content, status_code, _ = await client.request( + _, status_code, _ = await client.request( "GET", "https://api.example.com/test" ) diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 99b9306..e213972 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -56,7 +56,9 @@ def test_build_query_string_caps_page_size(self) -> None: def test_build_paginated_path_without_existing_query(self) -> None: """Test building path without existing query string.""" - result = PaginationHelper.build_paginated_path("/concepts", page=1, page_size=20) + result = PaginationHelper.build_paginated_path( + "/concepts", page=1, page_size=20 + ) assert result.startswith("/concepts?") assert "page=1" in result diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py index e0fbbdb..35709b3 100644 --- a/tests/unit/test_request.py +++ b/tests/unit/test_request.py @@ -176,9 +176,7 @@ def test_error_parsing_401(self, request_handler: Request) -> None: assert exc_info.value.status_code == 401 - def test_error_parsing_429_with_retry_after( - self, request_handler: Request - ) -> None: + def test_error_parsing_429_with_retry_after(self, request_handler: Request) -> None: """Test 429 error parsing with Retry-After header.""" with respx.mock: respx.get("https://api.example.com/v1/test").mock( @@ -228,9 +226,7 @@ def test_json_decode_error(self, request_handler: Request) -> None: assert "Invalid JSON" in str(exc_info.value) - def test_json_decode_error_on_error_status( - self, request_handler: Request - ) -> None: + def test_json_decode_error_on_error_status(self, request_handler: Request) -> None: """Test handling of invalid JSON on error status code.""" with respx.mock: respx.get("https://api.example.com/v1/test").mock( @@ -383,9 +379,7 @@ async def test_async_error_parsing(self, request_handler: AsyncRequest) -> None: assert exc_info.value.request_id == "req_xyz789" @pytest.mark.asyncio - async def test_async_json_decode_error( - self, request_handler: AsyncRequest - ) -> None: + async def test_async_json_decode_error(self, request_handler: AsyncRequest) -> None: """Test async handling of invalid JSON.""" with respx.mock: respx.get("https://api.example.com/v1/test").mock( @@ -398,9 +392,7 @@ async def test_async_json_decode_error( assert "Invalid JSON" in str(exc_info.value) @pytest.mark.asyncio - async def test_async_rate_limit_error( - self, request_handler: AsyncRequest - ) -> None: + async def test_async_rate_limit_error(self, request_handler: AsyncRequest) -> None: """Test async 429 rate limit error with retry-after.""" with respx.mock: respx.get("https://api.example.com/v1/test").mock( From 680059cfeb6207f910bf4795d7b9b9b519023530 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Tue, 6 Jan 2026 12:04:22 +0000 Subject: [PATCH 16/48] Improved tests --- tests/unit/resources/test_concepts.py | 323 +++++++++++++++++++++++++ tests/unit/resources/test_hierarchy.py | 163 +++++++++++++ 2 files changed, 486 insertions(+) diff --git a/tests/unit/resources/test_concepts.py b/tests/unit/resources/test_concepts.py index 685c72e..4023e55 100644 --- a/tests/unit/resources/test_concepts.py +++ b/tests/unit/resources/test_concepts.py @@ -231,6 +231,163 @@ def test_get_concept_relationships_with_options( assert "vocabulary_ids=SNOMED" in url_str assert "include_invalid=true" in url_str + @respx.mock + def test_get_concept_relationships_with_all_options( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test relationships with all filter options including standard_only and include_reverse.""" + route = respx.get(f"{base_url}/concepts/201826/relationships").mock( + return_value=Response( + 200, json={"success": True, "data": {"relationships": []}} + ) + ) + + sync_client.concepts.relationships( + 201826, + relationship_ids=["Is a", "Maps to"], + vocabulary_ids=["SNOMED", "ICD10CM"], + domain_ids=["Condition", "Drug"], + include_invalid=True, + standard_only=True, + include_reverse=True, + vocab_release="2025.1", + ) + + url_str = str(route.calls[0].request.url) + assert "relationship_ids=Is+a%2CMaps+to" in url_str + assert "vocabulary_ids=SNOMED%2CICD10CM" in url_str + assert "domain_ids=Condition%2CDrug" in url_str + assert "include_invalid=true" in url_str + assert "standard_only=true" in url_str + assert "include_reverse=true" in url_str + assert "vocab_release=2025.1" in url_str + + @respx.mock + def test_recommended_concepts(self, sync_client: OMOPHub, base_url: str) -> None: + """Test getting recommended concepts.""" + recommended_response = { + "success": True, + "data": { + "recommendations": [ + {"concept_id": 201820, "score": 0.95}, + ], + "meta": {"total": 1}, + }, + } + respx.post(f"{base_url}/concepts/recommended").mock( + return_value=Response(200, json=recommended_response) + ) + + result = sync_client.concepts.recommended([201826]) + assert "recommendations" in result + + @respx.mock + def test_recommended_concepts_with_options( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test recommended concepts with all options.""" + route = respx.post(f"{base_url}/concepts/recommended").mock( + return_value=Response( + 200, json={"success": True, "data": {"recommendations": []}} + ) + ) + + sync_client.concepts.recommended( + [201826, 1112807], + relationship_types=["Is a", "Maps to"], + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + standard_only=False, + include_invalid=True, + page=2, + page_size=50, + ) + + # Verify POST body was sent + assert route.calls[0].request.content + + @respx.mock + def test_get_concept_with_hierarchy( + self, sync_client: OMOPHub, mock_api_response: dict, base_url: str + ) -> None: + """Test getting a concept with hierarchy option.""" + route = respx.get(f"{base_url}/concepts/201826").mock( + return_value=Response(200, json=mock_api_response) + ) + + sync_client.concepts.get(201826, include_hierarchy=True) + + url_str = str(route.calls[0].request.url) + assert "include_hierarchy=true" in url_str + + @respx.mock + def test_get_concept_with_vocab_release( + self, sync_client: OMOPHub, mock_api_response: dict, base_url: str + ) -> None: + """Test getting a concept with vocab_release option.""" + route = respx.get(f"{base_url}/concepts/201826").mock( + return_value=Response(200, json=mock_api_response) + ) + + sync_client.concepts.get(201826, vocab_release="2025.1") + + url_str = str(route.calls[0].request.url) + assert "vocab_release=2025.1" in url_str + + @respx.mock + def test_get_by_code_with_all_options( + self, sync_client: OMOPHub, mock_api_response: dict, base_url: str + ) -> None: + """Test getting concept by code with all options.""" + route = respx.get(f"{base_url}/concepts/by-code/SNOMED/44054006").mock( + return_value=Response(200, json=mock_api_response) + ) + + sync_client.concepts.get_by_code( + "SNOMED", + "44054006", + include_relationships=True, + include_synonyms=True, + include_hierarchy=True, + vocab_release="2025.1", + ) + + url_str = str(route.calls[0].request.url) + assert "include_relationships=true" in url_str + assert "include_synonyms=true" in url_str + assert "include_hierarchy=true" in url_str + assert "vocab_release=2025.1" in url_str + + @respx.mock + def test_suggest_concepts_with_vocab_release( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test suggest with vocab_release option.""" + route = respx.get(f"{base_url}/concepts/suggest").mock( + return_value=Response(200, json={"success": True, "data": []}) + ) + + sync_client.concepts.suggest("diabetes", vocab_release="2025.1") + + url_str = str(route.calls[0].request.url) + assert "vocab_release=2025.1" in url_str + + @respx.mock + def test_related_concepts_with_vocab_release( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test related concepts with vocab_release option.""" + route = respx.get(f"{base_url}/concepts/201826/related").mock( + return_value=Response( + 200, json={"success": True, "data": {"related_concepts": []}} + ) + ) + + sync_client.concepts.related(201826, vocab_release="2025.1") + + url_str = str(route.calls[0].request.url) + assert "vocab_release=2025.1" in url_str + class TestAsyncConceptsResource: """Tests for the asynchronous concepts resource.""" @@ -440,3 +597,169 @@ async def test_async_get_relationships_with_options( assert "relationship_ids=Maps+to" in url_str assert "vocabulary_ids=ICD10CM" in url_str assert "include_invalid=true" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_get_relationships_with_all_options( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async relationships with all options.""" + route = respx.get(f"{base_url}/concepts/201826/relationships").mock( + return_value=Response( + 200, json={"success": True, "data": {"relationships": []}} + ) + ) + + await async_client.concepts.relationships( + 201826, + relationship_ids=["Is a"], + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + include_invalid=True, + standard_only=True, + include_reverse=True, + vocab_release="2025.1", + ) + + url_str = str(route.calls[0].request.url) + assert "domain_ids=Condition" in url_str + assert "standard_only=true" in url_str + assert "include_reverse=true" in url_str + assert "vocab_release=2025.1" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_recommended_concepts( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async getting recommended concepts.""" + recommended_response = { + "success": True, + "data": { + "recommendations": [{"concept_id": 201820}], + }, + } + respx.post(f"{base_url}/concepts/recommended").mock( + return_value=Response(200, json=recommended_response) + ) + + result = await async_client.concepts.recommended([201826]) + assert "recommendations" in result + + @pytest.mark.asyncio + @respx.mock + async def test_async_recommended_concepts_with_options( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async recommended with all options.""" + route = respx.post(f"{base_url}/concepts/recommended").mock( + return_value=Response( + 200, json={"success": True, "data": {"recommendations": []}} + ) + ) + + await async_client.concepts.recommended( + [201826, 1112807], + relationship_types=["Is a"], + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + standard_only=False, + include_invalid=True, + page=1, + page_size=100, + ) + + assert route.calls[0].request.content + + @pytest.mark.asyncio + @respx.mock + async def test_async_get_concept_with_hierarchy( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async get concept with hierarchy option.""" + route = respx.get(f"{base_url}/concepts/201826").mock( + return_value=Response( + 200, json={"success": True, "data": {"concept_id": 201826}} + ) + ) + + await async_client.concepts.get(201826, include_hierarchy=True) + + url_str = str(route.calls[0].request.url) + assert "include_hierarchy=true" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_get_concept_with_vocab_release( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async get concept with vocab_release option.""" + route = respx.get(f"{base_url}/concepts/201826").mock( + return_value=Response( + 200, json={"success": True, "data": {"concept_id": 201826}} + ) + ) + + await async_client.concepts.get(201826, vocab_release="2025.1") + + url_str = str(route.calls[0].request.url) + assert "vocab_release=2025.1" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_get_by_code_with_all_options( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async get by code with all options.""" + route = respx.get(f"{base_url}/concepts/by-code/SNOMED/44054006").mock( + return_value=Response( + 200, json={"success": True, "data": {"concept_id": 201826}} + ) + ) + + await async_client.concepts.get_by_code( + "SNOMED", + "44054006", + include_relationships=True, + include_synonyms=True, + include_hierarchy=True, + vocab_release="2025.1", + ) + + url_str = str(route.calls[0].request.url) + assert "include_relationships=true" in url_str + assert "include_synonyms=true" in url_str + assert "include_hierarchy=true" in url_str + assert "vocab_release=2025.1" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_suggest_with_vocab_release( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async suggest with vocab_release option.""" + route = respx.get(f"{base_url}/concepts/suggest").mock( + return_value=Response(200, json={"success": True, "data": []}) + ) + + await async_client.concepts.suggest("diabetes", vocab_release="2025.1") + + url_str = str(route.calls[0].request.url) + assert "vocab_release=2025.1" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_related_with_vocab_release( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async related with vocab_release option.""" + route = respx.get(f"{base_url}/concepts/201826/related").mock( + return_value=Response( + 200, json={"success": True, "data": {"related_concepts": []}} + ) + ) + + await async_client.concepts.related(201826, vocab_release="2025.1") + + url_str = str(route.calls[0].request.url) + assert "vocab_release=2025.1" in url_str diff --git a/tests/unit/resources/test_hierarchy.py b/tests/unit/resources/test_hierarchy.py index 41e4a00..dde4cd0 100644 --- a/tests/unit/resources/test_hierarchy.py +++ b/tests/unit/resources/test_hierarchy.py @@ -16,6 +16,97 @@ class TestHierarchyResource: """Tests for the synchronous Hierarchy resource.""" + @respx.mock + def test_get_hierarchy(self, sync_client: OMOPHub, base_url: str) -> None: + """Test getting complete hierarchy for a concept.""" + hierarchy_response = { + "success": True, + "data": { + "ancestors": [{"concept_id": 201820, "level": 1}], + "descendants": [{"concept_id": 201830, "level": 1}], + "summary": {"total_ancestors": 1, "total_descendants": 1}, + }, + } + respx.get(f"{base_url}/concepts/201826/hierarchy").mock( + return_value=Response(200, json=hierarchy_response) + ) + + result = sync_client.hierarchy.get(201826) + assert "ancestors" in result + assert "descendants" in result + + @respx.mock + def test_get_hierarchy_with_options( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test getting hierarchy with all options.""" + route = respx.get(f"{base_url}/concepts/201826/hierarchy").mock( + return_value=Response( + 200, + json={"success": True, "data": {"ancestors": [], "descendants": []}}, + ) + ) + + sync_client.hierarchy.get( + 201826, + format="flat", + vocabulary_ids=["SNOMED", "ICD10CM"], + domain_ids=["Condition"], + max_levels=15, + max_results=100, + relationship_types=["Is a", "Subsumes"], + include_invalid=True, + ) + + url_str = str(route.calls[0].request.url) + assert "format=flat" in url_str + assert "vocabulary_ids=SNOMED%2CICD10CM" in url_str + assert "domain_ids=Condition" in url_str + assert "max_levels=15" in url_str + assert "max_results=100" in url_str + assert "relationship_types=Is+a%2CSubsumes" in url_str + assert "include_invalid=true" in url_str + + @respx.mock + def test_get_hierarchy_max_levels_capped( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test that max_levels is capped at 20.""" + route = respx.get(f"{base_url}/concepts/201826/hierarchy").mock( + return_value=Response( + 200, + json={"success": True, "data": {"ancestors": [], "descendants": []}}, + ) + ) + + # Request max_levels=50, should be capped to 20 + sync_client.hierarchy.get(201826, max_levels=50) + + url_str = str(route.calls[0].request.url) + assert "max_levels=20" in url_str + + @respx.mock + def test_get_hierarchy_graph_format( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test getting hierarchy in graph format.""" + graph_response = { + "success": True, + "data": { + "nodes": [{"id": 201826, "label": "Type 2 diabetes"}], + "edges": [{"source": 201826, "target": 201820}], + }, + } + route = respx.get(f"{base_url}/concepts/201826/hierarchy").mock( + return_value=Response(200, json=graph_response) + ) + + result = sync_client.hierarchy.get(201826, format="graph") + + url_str = str(route.calls[0].request.url) + assert "format=graph" in url_str + assert "nodes" in result + @respx.mock def test_get_ancestors(self, sync_client: OMOPHub, base_url: str) -> None: """Test getting concept ancestors.""" @@ -152,6 +243,78 @@ def test_get_descendants_max_levels_capped( class TestAsyncHierarchyResource: """Tests for the asynchronous AsyncHierarchy resource.""" + @pytest.mark.asyncio + @respx.mock + async def test_async_get_hierarchy( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async getting complete hierarchy.""" + hierarchy_response = { + "success": True, + "data": { + "ancestors": [{"concept_id": 201820, "level": 1}], + "descendants": [{"concept_id": 201830, "level": 1}], + }, + } + respx.get(f"{base_url}/concepts/201826/hierarchy").mock( + return_value=Response(200, json=hierarchy_response) + ) + + result = await async_client.hierarchy.get(201826) + assert "ancestors" in result + assert "descendants" in result + + @pytest.mark.asyncio + @respx.mock + async def test_async_get_hierarchy_with_options( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async hierarchy with all options.""" + route = respx.get(f"{base_url}/concepts/201826/hierarchy").mock( + return_value=Response( + 200, + json={"success": True, "data": {"ancestors": [], "descendants": []}}, + ) + ) + + await async_client.hierarchy.get( + 201826, + format="graph", + vocabulary_ids=["SNOMED"], + domain_ids=["Condition", "Drug"], + max_levels=10, + max_results=50, + relationship_types=["Is a"], + include_invalid=True, + ) + + url_str = str(route.calls[0].request.url) + assert "format=graph" in url_str + assert "vocabulary_ids=SNOMED" in url_str + assert "domain_ids=Condition%2CDrug" in url_str + assert "max_levels=10" in url_str + assert "max_results=50" in url_str + assert "relationship_types=Is+a" in url_str + assert "include_invalid=true" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_get_hierarchy_max_levels_capped( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async hierarchy max_levels capped at 20.""" + route = respx.get(f"{base_url}/concepts/201826/hierarchy").mock( + return_value=Response( + 200, + json={"success": True, "data": {"ancestors": [], "descendants": []}}, + ) + ) + + await async_client.hierarchy.get(201826, max_levels=100) + + url_str = str(route.calls[0].request.url) + assert "max_levels=20" in url_str + @pytest.mark.asyncio @respx.mock async def test_async_get_ancestors( From ebdba7c2cf59253e3e2d0d373796eb1904407bca Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Tue, 6 Jan 2026 14:18:53 +0000 Subject: [PATCH 17/48] Examples update --- examples/map_between_vocabularies.py | 16 ++++------------ examples/navigate_hierarchy.py | 2 +- examples/search_concepts.py | 2 +- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/examples/map_between_vocabularies.py b/examples/map_between_vocabularies.py index f6d8b21..bcd0e73 100644 --- a/examples/map_between_vocabularies.py +++ b/examples/map_between_vocabularies.py @@ -16,8 +16,7 @@ def get_mappings() -> None: result = client.mappings.get( concept_id, - target_vocabularies=["ICD10CM", "Read", "ICD9CM"], - include_mapping_quality=True, + target_vocabulary="ICD10CM", ) source = result.get("source_concept", {}) @@ -32,12 +31,8 @@ def get_mappings() -> None: target_vocab = m.get("target_vocabulary_id", "?") target_code = m.get("target_concept_code", "?") target_name = m.get("target_concept_name", "?") - # Access confidence via quality when available - quality = m.get("quality", {}) - confidence = quality.get("confidence_score", "N/A") if quality else "N/A" print(f"\n [{target_vocab}] {target_code}") print(f" Name: {target_name}") - print(f" Confidence: {confidence}") except omophub.OMOPHubError as e: print(f"API error: {e.message}") finally: @@ -89,14 +84,11 @@ def lookup_by_code() -> None: print(f" Vocabulary: {concept.get('vocabulary_id', 'Unknown')}") print(f" Standard: {concept.get('standard_concept', 'N/A')}") - # If it's not a standard concept, find mappings to standard concepts + # If it's not a standard concept, find mappings if concept.get("standard_concept") != "S": - mappings = client.mappings.get( - concept.get("concept_id", 0), - standard_only=True, - ) + mappings = client.mappings.get(concept.get("concept_id", 0)) - print("\n Standard mappings:") + print("\n Mappings to other vocabularies:") for m in mappings.get("mappings", [])[:5]: print(f" → {m.get('target_concept_name', 'Unknown')}") except omophub.OMOPHubError as e: diff --git a/examples/navigate_hierarchy.py b/examples/navigate_hierarchy.py index c47935c..e239047 100644 --- a/examples/navigate_hierarchy.py +++ b/examples/navigate_hierarchy.py @@ -59,7 +59,7 @@ def explore_relationships() -> None: # Aspirin concept_id = 1112807 - result = client.concepts.relationships(concept_id, page_size=20) + result = client.concepts.relationships(concept_id) relationships = result.get("relationships", result) summary = result.get("relationship_summary", {}) diff --git a/examples/search_concepts.py b/examples/search_concepts.py index 30a87a5..419b0a7 100644 --- a/examples/search_concepts.py +++ b/examples/search_concepts.py @@ -56,7 +56,7 @@ def autocomplete_example() -> None: print("\n=== Autocomplete ===") # Get suggestions as user types - suggestions = client.concepts.suggest("hypert", limit=5) + suggestions = client.concepts.suggest("hypert", page_size=5) print("Suggestions for 'hypert':") for s in suggestions[:5]: From 6784613ed4e3b82bc2770bbca11f04b38cad8f5c Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Thu, 8 Jan 2026 12:30:19 +0000 Subject: [PATCH 18/48] Downloads badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1606723..711aa90 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Access SNOMED CT, ICD-10, RxNorm, LOINC, and 90+ OHDSI ATHENA vocabularies witho [![Python Versions](https://img.shields.io/pypi/pyversions/omophub.svg)](https://pypi.org/project/omophub/) [![Codecov](https://codecov.io/gh/omopHub/omophub-python/branch/main/graph/badge.svg)](https://app.codecov.io/gh/omopHub/omophub-python?branch=main) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +![Downloads](https://img.shields.io/pypi/dm/omophub) **[Documentation](https://docs.omophub.com/sdks/python/overview)** · **[API Reference](https://docs.omophub.com/api-reference)** · From 1c3770f34870f73e56cbe17ef90e14733454fe8b Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Mon, 19 Jan 2026 15:43:42 +0000 Subject: [PATCH 19/48] Sponsorship --- .github/FUNDING.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c15574b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: [omophub] + From 7bdf440eec4103acc44ae94f52b5c634f7b7148b Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 24 Jan 2026 21:24:08 +0000 Subject: [PATCH 20/48] Add integration tests for standard concept filtering and multiple filters with pagination --- tests/integration/test_search.py | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/integration/test_search.py b/tests/integration/test_search.py index 90329bf..ef60212 100644 --- a/tests/integration/test_search.py +++ b/tests/integration/test_search.py @@ -49,6 +49,54 @@ def test_search_with_domain_filter(self, integration_client: OMOPHub) -> None: for concept in concepts: assert concept.get("domain_id") == "Drug" + def test_search_with_standard_concept_filter( + self, integration_client: OMOPHub + ) -> None: + """Search for standard concepts only.""" + results = integration_client.search.basic( + "diabetes", + standard_concept="S", + page_size=20, + ) + + concepts = extract_data(results, "concepts") + assert len(concepts) > 0 + # All results should be standard concepts + for concept in concepts: + assert concept.get("standard_concept") == "S" + + def test_search_with_multiple_filters_and_pagination( + self, integration_client: OMOPHub + ) -> None: + """Search with multiple filters to test COUNT query parameter binding. + + This test specifically catches COUNT query bugs where parameter binding + differs between the main query and the count query. If the COUNT query + fails (like with missing standard_concept parameter), the API would + return a 500 error instead of results. + """ + results = integration_client.search.basic( + "diabetes", + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + standard_concept="S", + page_size=10, + ) + + # Extract concepts - SDK may return list directly or wrapped in dict + concepts = extract_data(results, "concepts") + assert len(concepts) > 0, "Expected concepts but got empty result" + + # Verify all filters applied correctly + for concept in concepts: + assert concept.get("vocabulary_id") == "SNOMED" + assert concept.get("domain_id") == "Condition" + assert concept.get("standard_concept") == "S" + + # Note: The SDK extracts concepts from the response, so we verify + # the COUNT query worked by the fact that we got results without + # an error (COUNT query failure would cause HTTP 500) + def test_autocomplete(self, integration_client: OMOPHub) -> None: """Test autocomplete suggestions.""" result = integration_client.search.autocomplete( From 0a64db988ee667db1e22bc161b1970982924c0d6 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 24 Jan 2026 22:12:35 +0000 Subject: [PATCH 21/48] Prepare release v1.3.1 --- CHANGELOG.md | 14 ++- src/omophub/_request.py | 150 ++++++++++++++++++++++++++++ src/omophub/resources/search.py | 68 +++++++++---- tests/integration/test_search.py | 24 ++++- tests/unit/resources/test_search.py | 26 ++--- 5 files changed, 243 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8414d2..77e8429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.1] - 2026-01-24 + +### Fixed + +- Fixed `search.basic_iter()` pagination bug that caused only the first page of results to be returned. The iterator now correctly fetches all pages when iterating through search results. + +### Changed + +- Added `get_raw()` method to internal request classes for retrieving full API responses with pagination metadata. +- Expanded `search.basic_iter()` method signature to explicitly list all filter parameters instead of using `**kwargs`. + ## [1.3.0] - 2026-01-06 ### Changes @@ -72,7 +83,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full type hints and PEP 561 compliance - HTTP/2 support via httpx -[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.3.1...HEAD +[1.3.1]: https://github.com/omopHub/omophub-python/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/omopHub/omophub-python/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/omopHub/omophub-python/compare/v0.1.0...v1.2.0 [0.1.0]: https://github.com/omopHub/omophub-python/releases/tag/v0.1.0 diff --git a/src/omophub/_request.py b/src/omophub/_request.py index ceaa0bd..61b3cf9 100644 --- a/src/omophub/_request.py +++ b/src/omophub/_request.py @@ -97,6 +97,62 @@ def _parse_response( response: APIResponse = data # type: ignore[assignment] return response.get("data", data) + def _parse_response_raw( + self, + content: bytes, + status_code: int, + headers: Mapping[str, str], + ) -> dict[str, Any]: + """Parse API response and return full response dict with meta. + + Unlike _parse_response which extracts just the 'data' field, + this method returns the complete response including 'meta' for pagination. + """ + request_id = headers.get("X-Request-Id") or headers.get("x-request-id") + + try: + data = json.loads(content) if content else {} + except json.JSONDecodeError as exc: + if status_code >= 400: + raise_for_status( + status_code, + f"Request failed with status {status_code}", + request_id=request_id, + ) + raise OMOPHubError( + f"Invalid JSON response: {content[:200].decode(errors='replace')}" + ) from exc + + # Handle error responses + if status_code >= 400: + error_response: ErrorResponse = data # type: ignore[assignment] + error = error_response.get("error", {}) + message = error.get("message", f"Request failed with status {status_code}") + error_code = error.get("code") + details = error.get("details") + + # Check for rate limit retry-after + retry_after = None + if status_code == 429: + retry_after_header = headers.get("Retry-After") or headers.get( + "retry-after" + ) + if retry_after_header: + with contextlib.suppress(ValueError): + retry_after = int(retry_after_header) + + raise_for_status( + status_code, + message, + request_id=request_id, + error_code=error_code, + details=details, + retry_after=retry_after, + ) + + # Return full response dict (includes 'data' and 'meta') + return data + def get( self, path: str, @@ -112,6 +168,25 @@ def get( ) return self._parse_response(content, status_code, headers) + def get_raw( + self, + path: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make a GET request and return full response with meta. + + Unlike get() which extracts just the 'data' field, + this method returns the complete response including 'meta' for pagination. + """ + url = self._build_url(path) + content, status_code, headers = self._http_client.request( + "GET", + url, + headers=self._get_auth_headers(), + params=params, + ) + return self._parse_response_raw(content, status_code, headers) + def post( self, path: str, @@ -210,6 +285,62 @@ def _parse_response( response: APIResponse = data # type: ignore[assignment] return response.get("data", data) + def _parse_response_raw( + self, + content: bytes, + status_code: int, + headers: Mapping[str, str], + ) -> dict[str, Any]: + """Parse API response and return full response dict with meta. + + Unlike _parse_response which extracts just the 'data' field, + this method returns the complete response including 'meta' for pagination. + """ + request_id = headers.get("X-Request-Id") or headers.get("x-request-id") + + try: + data = json.loads(content) if content else {} + except json.JSONDecodeError as exc: + if status_code >= 400: + raise_for_status( + status_code, + f"Request failed with status {status_code}", + request_id=request_id, + ) + raise OMOPHubError( + f"Invalid JSON response: {content[:200].decode(errors='replace')}" + ) from exc + + # Handle error responses + if status_code >= 400: + error_response: ErrorResponse = data # type: ignore[assignment] + error = error_response.get("error", {}) + message = error.get("message", f"Request failed with status {status_code}") + error_code = error.get("code") + details = error.get("details") + + # Check for rate limit retry-after + retry_after = None + if status_code == 429: + retry_after_header = headers.get("Retry-After") or headers.get( + "retry-after" + ) + if retry_after_header: + with contextlib.suppress(ValueError): + retry_after = int(retry_after_header) + + raise_for_status( + status_code, + message, + request_id=request_id, + error_code=error_code, + details=details, + retry_after=retry_after, + ) + + # Return full response dict (includes 'data' and 'meta') + return data + async def get( self, path: str, @@ -225,6 +356,25 @@ async def get( ) return self._parse_response(content, status_code, headers) + async def get_raw( + self, + path: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make an async GET request and return full response with meta. + + Unlike get() which extracts just the 'data' field, + this method returns the complete response including 'meta' for pagination. + """ + url = self._build_url(path) + content, status_code, headers = await self._http_client.request( + "GET", + url, + headers=self._get_auth_headers(), + params=params, + ) + return self._parse_response_raw(content, status_code, headers) + async def post( self, path: str, diff --git a/src/omophub/resources/search.py b/src/omophub/resources/search.py index 7c85bc8..989d08a 100644 --- a/src/omophub/resources/search.py +++ b/src/omophub/resources/search.py @@ -125,8 +125,15 @@ def basic_iter( *, vocabulary_ids: list[str] | None = None, domain_ids: list[str] | None = None, + concept_class_ids: list[str] | None = None, + standard_concept: str | None = None, + include_synonyms: bool = False, + include_invalid: bool = False, + min_score: float | None = None, + exact_match: bool = False, page_size: int = DEFAULT_PAGE_SIZE, - **kwargs: Any, + sort_by: str | None = None, + sort_order: str | None = None, ) -> Iterator[Concept]: """Iterate through all search results with auto-pagination. @@ -134,8 +141,15 @@ def basic_iter( query: Search query string vocabulary_ids: Filter by vocabulary IDs domain_ids: Filter by domain IDs + concept_class_ids: Filter by concept class IDs + standard_concept: Filter by standard concept ("S", "C", or None) + include_synonyms: Search in synonyms + include_invalid: Include invalid concepts + min_score: Minimum relevance score + exact_match: Require exact match page_size: Results per page - **kwargs: Additional search parameters + sort_by: Sort field + sort_order: Sort order ("asc" or "desc") Yields: Individual concepts from all pages @@ -144,22 +158,40 @@ def basic_iter( def fetch_page( page: int, size: int ) -> tuple[list[Concept], PaginationMeta | None]: - result = self.basic( - query, - vocabulary_ids=vocabulary_ids, - domain_ids=domain_ids, - page=page, - page_size=size, - **kwargs, - ) - concepts = ( - result.get("concepts", result) if isinstance(result, dict) else result - ) - meta = ( - result.get("meta", {}).get("pagination") - if isinstance(result, dict) - else None - ) + # Build params manually to use get_raw() for full response with meta + params: dict[str, Any] = { + "query": query, + "page": page, + "page_size": size, + } + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if concept_class_ids: + params["concept_class_ids"] = ",".join(concept_class_ids) + if standard_concept: + params["standard_concept"] = standard_concept + if include_synonyms: + params["include_synonyms"] = "true" + if include_invalid: + params["include_invalid"] = "true" + if min_score is not None: + params["min_score"] = min_score + if exact_match: + params["exact_match"] = "true" + if sort_by: + params["sort_by"] = sort_by + if sort_order: + params["sort_order"] = sort_order + + # Use get_raw() to preserve pagination metadata + result = self._request.get_raw("/search/concepts", params=params) + + # Extract concepts from 'data' field (may be list or dict with 'concepts') + data = result.get("data", []) + concepts = data.get("concepts", data) if isinstance(data, dict) else data + meta = result.get("meta", {}).get("pagination") return concepts, meta yield from paginate_sync(fetch_page, page_size) diff --git a/tests/integration/test_search.py b/tests/integration/test_search.py index ef60212..da380af 100644 --- a/tests/integration/test_search.py +++ b/tests/integration/test_search.py @@ -131,18 +131,32 @@ def test_autocomplete_with_filters(self, integration_client: OMOPHub) -> None: assert isinstance(suggestions, list) def test_basic_iter_pagination(self, integration_client: OMOPHub) -> None: - """Test auto-pagination with basic_iter.""" - # Collect first 5 concepts using iterator + """Test auto-pagination with basic_iter. + + This test verifies that basic_iter correctly fetches multiple pages + of results. With page_size=2, we should be able to collect 5 concepts + which requires fetching at least 3 pages, proving pagination works. + """ + # Collect concepts using iterator with small page size concepts = [] + page_size = 2 + max_concepts = 5 + for concept in integration_client.search.basic_iter( "diabetes", - page_size=2, # Small page size to test pagination + page_size=page_size, # Small page size to test pagination ): concepts.append(concept) - if len(concepts) >= 5: + if len(concepts) >= max_concepts: break - assert len(concepts) == 5 + # Should get requested number of concepts (proves pagination worked) + assert len(concepts) == max_concepts, ( + f"Expected {max_concepts} concepts from paginated iterator, " + f"got {len(concepts)}. With page_size={page_size}, getting only " + f"{len(concepts)} concepts suggests pagination is broken." + ) + # All should have concept_id for concept in concepts: assert "concept_id" in concept diff --git a/tests/unit/resources/test_search.py b/tests/unit/resources/test_search.py index fafafc0..91edd61 100644 --- a/tests/unit/resources/test_search.py +++ b/tests/unit/resources/test_search.py @@ -77,15 +77,14 @@ def test_basic_search_with_filters( @respx.mock def test_basic_iter_single_page(self, sync_client: OMOPHub, base_url: str) -> None: """Test basic_iter with single page of results.""" + # Note: meta is at top level, not nested inside data search_response = { "success": True, - "data": { - "concepts": [ - {"concept_id": 201826, "concept_name": "Type 2 diabetes mellitus"}, - {"concept_id": 201820, "concept_name": "Diabetes mellitus"}, - ], - "meta": {"pagination": {"page": 1, "has_next": False}}, - }, + "data": [ + {"concept_id": 201826, "concept_name": "Type 2 diabetes mellitus"}, + {"concept_id": 201820, "concept_name": "Diabetes mellitus"}, + ], + "meta": {"pagination": {"page": 1, "has_next": False}}, } respx.get(f"{base_url}/search/concepts").mock( return_value=Response(200, json=search_response) @@ -99,19 +98,16 @@ def test_basic_iter_multiple_pages( self, sync_client: OMOPHub, base_url: str ) -> None: """Test basic_iter auto-pagination across multiple pages.""" + # Note: meta is at top level, not nested inside data page1_response = { "success": True, - "data": { - "concepts": [{"concept_id": 1}], - "meta": {"pagination": {"page": 1, "has_next": True}}, - }, + "data": [{"concept_id": 1}], + "meta": {"pagination": {"page": 1, "has_next": True}}, } page2_response = { "success": True, - "data": { - "concepts": [{"concept_id": 2}], - "meta": {"pagination": {"page": 2, "has_next": False}}, - }, + "data": [{"concept_id": 2}], + "meta": {"pagination": {"page": 2, "has_next": False}}, } call_count = 0 From 5dcb15b5a8094f80e69f75b4945c555f647f4cc9 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 24 Jan 2026 22:45:19 +0000 Subject: [PATCH 22/48] Refactor tests for API key validation and enhance request handling tests. Updated synchronous and asynchronous client tests to use monkeypatching for API key checks. Added new tests for handling raw requests, including error parsing, rate limits, and JSON decoding issues. --- tests/unit/test_client.py | 27 +++---- tests/unit/test_request.py | 159 +++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 18 deletions(-) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 55b7b7c..a7bbdd2 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -13,17 +13,12 @@ class TestOMOPHubClient: """Tests for the synchronous OMOPHub client.""" - def test_client_requires_api_key(self) -> None: + def test_client_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that client raises error without API key.""" - # Clear any module-level API key - original_key = omophub.api_key - omophub.api_key = None + monkeypatch.setattr("omophub._client.default_api_key", None) - try: - with pytest.raises(AuthenticationError): - OMOPHub() - finally: - omophub.api_key = original_key + with pytest.raises(AuthenticationError): + OMOPHub() def test_client_accepts_api_key(self, api_key: str) -> None: """Test that client accepts API key parameter.""" @@ -93,16 +88,12 @@ def test_async_client_has_resources( assert hasattr(async_client, "vocabularies") assert hasattr(async_client, "domains") - def test_async_client_requires_api_key(self) -> None: + def test_async_client_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that async client raises error without API key.""" - original_key = omophub.api_key - omophub.api_key = None - - try: - with pytest.raises(AuthenticationError): - omophub.AsyncOMOPHub() - finally: - omophub.api_key = original_key + monkeypatch.setattr("omophub._client.default_api_key", None) + + with pytest.raises(AuthenticationError): + omophub.AsyncOMOPHub() class TestClientLazyPropertyCaching: diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py index 35709b3..49844c4 100644 --- a/tests/unit/test_request.py +++ b/tests/unit/test_request.py @@ -260,6 +260,83 @@ def test_url_building(self) -> None: # Test without leading slash assert request._build_url("concepts") == "https://api.example.com/v1/concepts" + def test_get_raw_request(self, request_handler: Request) -> None: + """Test get_raw returns full response with data and meta.""" + with respx.mock: + respx.get("https://api.example.com/v1/search").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"concepts": [{"concept_id": 1}]}, + "meta": {"pagination": {"page": 1, "total_pages": 5}}, + }, + ) + ) + result = request_handler.get_raw("/search") + assert "data" in result + assert "meta" in result + assert result["meta"]["pagination"]["page"] == 1 + assert result["meta"]["pagination"]["total_pages"] == 5 + + def test_get_raw_with_params(self, request_handler: Request) -> None: + """Test get_raw passes query parameters correctly.""" + with respx.mock: + route = respx.get("https://api.example.com/v1/search").mock( + return_value=Response( + 200, + json={ + "data": {"concepts": []}, + "meta": {"pagination": {"page": 2, "has_next": True}}, + }, + ) + ) + + result = request_handler.get_raw("/search", params={"query": "test", "page": 2}) + + url_str = str(route.calls[0].request.url) + assert "query=test" in url_str + assert "page=2" in url_str + assert result["meta"]["pagination"]["page"] == 2 + + def test_get_raw_error_parsing(self, request_handler: Request) -> None: + """Test get_raw raises errors correctly.""" + with respx.mock: + respx.get("https://api.example.com/v1/test").mock( + return_value=Response( + 404, + json={"error": {"message": "Not found"}}, + headers={"X-Request-Id": "req_123"}, + ) + ) + with pytest.raises(NotFoundError) as exc_info: + request_handler.get_raw("/test") + assert exc_info.value.request_id == "req_123" + + def test_get_raw_rate_limit(self, request_handler: Request) -> None: + """Test get_raw handles rate limit with retry-after.""" + with respx.mock: + respx.get("https://api.example.com/v1/test").mock( + return_value=Response( + 429, + json={"error": {"message": "Rate limited"}}, + headers={"Retry-After": "45"}, + ) + ) + with pytest.raises(RateLimitError) as exc_info: + request_handler.get_raw("/test") + assert exc_info.value.retry_after == 45 + + def test_get_raw_json_decode_error(self, request_handler: Request) -> None: + """Test get_raw handles invalid JSON.""" + with respx.mock: + respx.get("https://api.example.com/v1/test").mock( + return_value=Response(200, content=b"not json") + ) + with pytest.raises(OMOPHubError) as exc_info: + request_handler.get_raw("/test") + assert "Invalid JSON" in str(exc_info.value) + class TestAsyncRequest: """Tests for asynchronous AsyncRequest class.""" @@ -407,3 +484,85 @@ async def test_async_rate_limit_error(self, request_handler: AsyncRequest) -> No await request_handler.get("/test") assert exc_info.value.retry_after == 30 + + @pytest.mark.asyncio + async def test_async_get_raw_request(self, request_handler: AsyncRequest) -> None: + """Test async get_raw returns full response with data and meta.""" + with respx.mock: + respx.get("https://api.example.com/v1/search").mock( + return_value=Response( + 200, + json={ + "data": {"concepts": [{"concept_id": 42}]}, + "meta": {"pagination": {"page": 1, "has_next": True, "total_pages": 3}}, + }, + ) + ) + result = await request_handler.get_raw("/search") + assert "data" in result + assert "meta" in result + assert result["meta"]["pagination"]["page"] == 1 + assert result["meta"]["pagination"]["has_next"] is True + + @pytest.mark.asyncio + async def test_async_get_raw_with_params(self, request_handler: AsyncRequest) -> None: + """Test async get_raw passes query parameters correctly.""" + with respx.mock: + route = respx.get("https://api.example.com/v1/search").mock( + return_value=Response( + 200, + json={ + "data": {"concepts": []}, + "meta": {"pagination": {"page": 3}}, + }, + ) + ) + + result = await request_handler.get_raw("/search", params={"page": 3}) + + url_str = str(route.calls[0].request.url) + assert "page=3" in url_str + assert result["meta"]["pagination"]["page"] == 3 + + @pytest.mark.asyncio + async def test_async_get_raw_error(self, request_handler: AsyncRequest) -> None: + """Test async get_raw raises errors correctly.""" + with respx.mock: + respx.get("https://api.example.com/v1/test").mock( + return_value=Response( + 404, + json={"error": {"message": "Not found"}}, + headers={"X-Request-Id": "req_async_456"}, + ) + ) + with pytest.raises(NotFoundError) as exc_info: + await request_handler.get_raw("/test") + assert exc_info.value.request_id == "req_async_456" + + @pytest.mark.asyncio + async def test_async_get_raw_rate_limit(self, request_handler: AsyncRequest) -> None: + """Test async get_raw handles rate limit with retry-after.""" + with respx.mock: + respx.get("https://api.example.com/v1/test").mock( + return_value=Response( + 429, + json={"error": {"message": "Rate limited"}}, + headers={"Retry-After": "60"}, + ) + ) + with pytest.raises(RateLimitError) as exc_info: + await request_handler.get_raw("/test") + assert exc_info.value.retry_after == 60 + + @pytest.mark.asyncio + async def test_async_get_raw_json_decode_error( + self, request_handler: AsyncRequest + ) -> None: + """Test async get_raw handles invalid JSON.""" + with respx.mock: + respx.get("https://api.example.com/v1/test").mock( + return_value=Response(200, content=b"invalid json response") + ) + with pytest.raises(OMOPHubError) as exc_info: + await request_handler.get_raw("/test") + assert "Invalid JSON" in str(exc_info.value) From 88cb440f56d4b539fa23a71b3ada40f86e85c17c Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Tue, 27 Jan 2026 09:56:44 +0000 Subject: [PATCH 23/48] Update vocab_version in tests for consistency across mock responses and client configurations. --- tests/conftest.py | 2 +- tests/unit/test_client.py | 6 +++--- tests/unit/test_request.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b5233ae..195e418 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,7 +86,7 @@ def mock_api_response(mock_concept: dict[str, Any]) -> dict[str, Any]: "meta": { "request_id": "req_test123", "timestamp": "2024-12-01T00:00:00Z", - "vocab_release": "2024.4", + "vocab_release": "2024.2", }, } diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index a7bbdd2..793c512 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -197,11 +197,11 @@ def test_client_custom_max_retries(self, api_key: str) -> None: def test_client_vocab_version(self, api_key: str) -> None: """Test client accepts vocab_version parameter.""" - client = OMOPHub(api_key=api_key, vocab_version="2024.4") + client = OMOPHub(api_key=api_key, vocab_version="2024.2") - assert client._vocab_version == "2024.4" + assert client._vocab_version == "2024.2" # Verify it's passed to request handler - assert client._request._vocab_version == "2024.4" + assert client._request._vocab_version == "2024.2" client.close() diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py index 49844c4..5a652f9 100644 --- a/tests/unit/test_request.py +++ b/tests/unit/test_request.py @@ -103,7 +103,7 @@ def test_vocab_version_header(self) -> None: http_client=http_client, base_url="https://api.example.com/v1", api_key="test_api_key", - vocab_version="2024.4", + vocab_version="2024.2", ) with respx.mock: @@ -114,7 +114,7 @@ def test_vocab_version_header(self) -> None: request_handler.get("/test") assert "X-Vocab-Version" in route.calls[0].request.headers - assert route.calls[0].request.headers["X-Vocab-Version"] == "2024.4" + assert route.calls[0].request.headers["X-Vocab-Version"] == "2024.2" def test_request_id_extraction(self, request_handler: Request) -> None: """Test request ID is extracted from headers on error.""" From b6c74dd1b35abd8edfbad813dabdd88c24ee0e11 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Fri, 30 Jan 2026 21:56:04 +0000 Subject: [PATCH 24/48] Extending mapping method with source_codes option --- src/omophub/resources/mappings.py | 62 +++++++++++--- tests/unit/resources/test_mappings.py | 115 ++++++++++++++++++++++++-- 2 files changed, 160 insertions(+), 17 deletions(-) diff --git a/src/omophub/resources/mappings.py b/src/omophub/resources/mappings.py index f768a5e..1b3399e 100644 --- a/src/omophub/resources/mappings.py +++ b/src/omophub/resources/mappings.py @@ -47,9 +47,10 @@ def get( def map( self, - source_concepts: list[int], target_vocabulary: str, *, + source_concepts: list[int] | None = None, + source_codes: list[dict[str, str]] | None = None, mapping_type: str | None = None, include_invalid: bool = False, vocab_release: str | None = None, @@ -57,19 +58,39 @@ def map( """Map concepts to a target vocabulary. Args: - source_concepts: List of OMOP concept IDs to map - target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED") - mapping_type: Mapping type (direct, equivalent, broader, narrower) + target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED", "RxNorm") + source_concepts: List of OMOP concept IDs to map. Use this OR source_codes, + not both. + source_codes: List of vocabulary/code pairs to map, e.g., + [{"vocabulary_id": "SNOMED", "concept_code": "387517004"}]. + Use this OR source_concepts, not both. + mapping_type: Mapping type filter (direct, equivalent, broader, narrower) include_invalid: Include invalid mappings vocab_release: Specific vocabulary release version (e.g., "2025.1") Returns: Mapping results with summary + + Raises: + ValueError: If neither or both source_concepts and source_codes are provided """ + # Validate: exactly one of source_concepts or source_codes required + has_concepts = source_concepts is not None and len(source_concepts) > 0 + has_codes = source_codes is not None and len(source_codes) > 0 + + if not has_concepts and not has_codes: + raise ValueError("Either source_concepts or source_codes is required") + if has_concepts and has_codes: + raise ValueError("Cannot use both source_concepts and source_codes") + body: dict[str, Any] = { - "source_concepts": source_concepts, "target_vocabulary": target_vocabulary, } + + if source_concepts: + body["source_concepts"] = source_concepts + if source_codes: + body["source_codes"] = source_codes if mapping_type: body["mapping_type"] = mapping_type if include_invalid: @@ -123,9 +144,10 @@ async def get( async def map( self, - source_concepts: list[int], target_vocabulary: str, *, + source_concepts: list[int] | None = None, + source_codes: list[dict[str, str]] | None = None, mapping_type: str | None = None, include_invalid: bool = False, vocab_release: str | None = None, @@ -133,19 +155,39 @@ async def map( """Map concepts to a target vocabulary. Args: - source_concepts: List of OMOP concept IDs to map - target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED") - mapping_type: Mapping type (direct, equivalent, broader, narrower) + target_vocabulary: Target vocabulary ID (e.g., "ICD10CM", "SNOMED", "RxNorm") + source_concepts: List of OMOP concept IDs to map. Use this OR source_codes, + not both. + source_codes: List of vocabulary/code pairs to map, e.g., + [{"vocabulary_id": "SNOMED", "concept_code": "387517004"}]. + Use this OR source_concepts, not both. + mapping_type: Mapping type filter (direct, equivalent, broader, narrower) include_invalid: Include invalid mappings vocab_release: Specific vocabulary release version (e.g., "2025.1") Returns: Mapping results with summary + + Raises: + ValueError: If neither or both source_concepts and source_codes are provided """ + # Validate: exactly one of source_concepts or source_codes required + has_concepts = source_concepts is not None and len(source_concepts) > 0 + has_codes = source_codes is not None and len(source_codes) > 0 + + if not has_concepts and not has_codes: + raise ValueError("Either source_concepts or source_codes is required") + if has_concepts and has_codes: + raise ValueError("Cannot use both source_concepts and source_codes") + body: dict[str, Any] = { - "source_concepts": source_concepts, "target_vocabulary": target_vocabulary, } + + if source_concepts: + body["source_concepts"] = source_concepts + if source_codes: + body["source_codes"] = source_codes if mapping_type: body["mapping_type"] = mapping_type if include_invalid: diff --git a/tests/unit/resources/test_mappings.py b/tests/unit/resources/test_mappings.py index 6848ad4..dfdd691 100644 --- a/tests/unit/resources/test_mappings.py +++ b/tests/unit/resources/test_mappings.py @@ -79,8 +79,8 @@ def test_map_concepts(self, sync_client: OMOPHub, base_url: str) -> None: ) result = sync_client.mappings.map( - source_concepts=[{"concept_id": 201826}], target_vocabulary="ICD10CM", + source_concepts=[201826], ) assert "mappings" in result @@ -97,11 +97,8 @@ def test_map_concepts_with_options( ) sync_client.mappings.map( - source_concepts=[ - {"concept_id": 201826}, - {"vocabulary_id": "SNOMED", "concept_code": "44054006"}, - ], target_vocabulary="ICD10CM", + source_concepts=[201826, 4329847], mapping_type="equivalent", include_invalid=True, ) @@ -109,6 +106,63 @@ def test_map_concepts_with_options( # Verify POST body assert route.calls[0].request.content + @respx.mock + def test_map_concepts_with_source_codes( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test mapping concepts using source_codes parameter.""" + import json + + map_response = { + "success": True, + "data": { + "mappings": [ + { + "source_concept_id": 4306040, + "source_concept_name": "Acetaminophen", + "target_concept_id": 1125315, + "target_vocabulary_id": "RxNorm", + } + ], + }, + } + route = respx.post(f"{base_url}/concepts/map").mock( + return_value=Response(200, json=map_response) + ) + + result = sync_client.mappings.map( + target_vocabulary="RxNorm", + source_codes=[ + {"vocabulary_id": "SNOMED", "concept_code": "387517004"}, + {"vocabulary_id": "SNOMED", "concept_code": "108774000"}, + ], + ) + + assert "mappings" in result + # Verify request body contains source_codes + body = json.loads(route.calls[0].request.content) + assert "source_codes" in body + assert len(body["source_codes"]) == 2 + assert body["source_codes"][0]["vocabulary_id"] == "SNOMED" + + def test_map_concepts_requires_source(self, sync_client: OMOPHub) -> None: + """Test that map() raises error when neither source_concepts nor source_codes provided.""" + with pytest.raises( + ValueError, match="Either source_concepts or source_codes is required" + ): + sync_client.mappings.map(target_vocabulary="ICD10CM") + + def test_map_concepts_rejects_both_sources(self, sync_client: OMOPHub) -> None: + """Test that map() raises error when both source_concepts and source_codes provided.""" + with pytest.raises( + ValueError, match="Cannot use both source_concepts and source_codes" + ): + sync_client.mappings.map( + target_vocabulary="ICD10CM", + source_concepts=[201826], + source_codes=[{"vocabulary_id": "SNOMED", "concept_code": "44054006"}], + ) + class TestAsyncMappingsResource: """Tests for the asynchronous AsyncMappings resource.""" @@ -157,8 +211,8 @@ async def test_async_map_concepts( ) result = await async_client.mappings.map( - source_concepts=[{"concept_id": 201826}], target_vocabulary="ICD10CM", + source_concepts=[201826], ) assert "mappings" in result @@ -174,10 +228,57 @@ async def test_async_map_concepts_with_options( ) await async_client.mappings.map( - source_concepts=[{"concept_id": 201826}], target_vocabulary="ICD10CM", + source_concepts=[201826], mapping_type="direct", include_invalid=True, ) assert route.calls[0].request.content + + @pytest.mark.asyncio + @respx.mock + async def test_async_map_concepts_with_source_codes( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async mapping concepts using source_codes.""" + import json + + route = respx.post(f"{base_url}/concepts/map").mock( + return_value=Response(200, json={"success": True, "data": {"mappings": []}}) + ) + + result = await async_client.mappings.map( + target_vocabulary="RxNorm", + source_codes=[ + {"vocabulary_id": "SNOMED", "concept_code": "387517004"}, + ], + ) + + assert "mappings" in result + body = json.loads(route.calls[0].request.content) + assert "source_codes" in body + + @pytest.mark.asyncio + async def test_async_map_requires_source( + self, async_client: omophub.AsyncOMOPHub + ) -> None: + """Test async map() raises error without sources.""" + with pytest.raises( + ValueError, match="Either source_concepts or source_codes is required" + ): + await async_client.mappings.map(target_vocabulary="ICD10CM") + + @pytest.mark.asyncio + async def test_async_map_rejects_both_sources( + self, async_client: omophub.AsyncOMOPHub + ) -> None: + """Test async map() raises error with both sources.""" + with pytest.raises( + ValueError, match="Cannot use both source_concepts and source_codes" + ): + await async_client.mappings.map( + target_vocabulary="ICD10CM", + source_concepts=[201826], + source_codes=[{"vocabulary_id": "SNOMED", "concept_code": "44054006"}], + ) From c89789c9849dc99cb30b10bad4003c1077a91484 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 16 Feb 2026 21:48:28 +0000 Subject: [PATCH 25/48] Add semantic and similar search functionality with corresponding types and integration tests - Introduced `semantic` and `similar` search methods in the search resource, allowing for advanced concept searches using neural embeddings and similarity algorithms. - Added new TypedDicts for `SemanticSearchResult`, `SemanticSearchMeta`, `SimilarConcept`, and `SimilarSearchResult` to structure the response data. - Implemented integration tests for semantic search, including filtering and pagination, as well as tests for finding similar concepts by ID and name. - Updated type imports and ensured compatibility with existing search functionality. --- src/omophub/resources/search.py | 318 ++++++++++++++- src/omophub/types/__init__.py | 10 + src/omophub/types/search.py | 58 ++- tests/integration/test_search.py | 153 ++++++++ tests/unit/resources/test_search.py | 578 ++++++++++++++++++++++++++++ 5 files changed, 1113 insertions(+), 4 deletions(-) diff --git a/src/omophub/resources/search.py b/src/omophub/resources/search.py index 989d08a..83232e8 100644 --- a/src/omophub/resources/search.py +++ b/src/omophub/resources/search.py @@ -2,17 +2,22 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict from .._pagination import DEFAULT_PAGE_SIZE, paginate_sync if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import AsyncIterator, Iterator from .._request import AsyncRequest, Request from ..types.common import PaginationMeta from ..types.concept import Concept - from ..types.search import SearchResult, Suggestion + from ..types.search import ( + SearchResult, + SemanticSearchResult, + SimilarSearchResult, + Suggestion, + ) class BasicSearchParams(TypedDict, total=False): @@ -272,6 +277,179 @@ def autocomplete( return self._request.get("/search/suggest", params=params) + def semantic( + self, + query: str, + *, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_concept: Literal["S", "C"] | None = None, + concept_class_id: str | None = None, + threshold: float | None = None, + page: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """Semantic concept search using neural embeddings. + + Args: + query: Natural language search query + vocabulary_ids: Filter by vocabulary IDs + domain_ids: Filter by domain IDs + standard_concept: Filter by standard concept flag ('S' or 'C') + concept_class_id: Filter by concept class + threshold: Minimum similarity threshold (0.0-1.0, default 0.3) + page: Page number (1-based) + page_size: Results per page (max 100) + + Returns: + Semantic search results with similarity scores + """ + params: dict[str, Any] = {"query": query, "page": page, "page_size": page_size} + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if standard_concept: + params["standard_concept"] = standard_concept + if concept_class_id: + params["concept_class_id"] = concept_class_id + if threshold is not None: + params["threshold"] = threshold + + return self._request.get("/concepts/semantic-search", params=params) + + def semantic_iter( + self, + query: str, + *, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_concept: Literal["S", "C"] | None = None, + concept_class_id: str | None = None, + threshold: float | None = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Iterator[SemanticSearchResult]: + """Iterate through all semantic search results with auto-pagination. + + Args: + query: Natural language search query + vocabulary_ids: Filter by vocabulary IDs + domain_ids: Filter by domain IDs + standard_concept: Filter by standard concept flag ('S' or 'C') + concept_class_id: Filter by concept class + threshold: Minimum similarity threshold (0.0-1.0) + page_size: Results per page + + Yields: + Individual semantic search results from all pages + """ + + def fetch_page( + page: int, size: int + ) -> tuple[list[SemanticSearchResult], PaginationMeta | None]: + params: dict[str, Any] = { + "query": query, + "page": page, + "page_size": size, + } + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if standard_concept: + params["standard_concept"] = standard_concept + if concept_class_id: + params["concept_class_id"] = concept_class_id + if threshold is not None: + params["threshold"] = threshold + + result = self._request.get_raw("/concepts/semantic-search", params=params) + + data = result.get("data", []) + results = data.get("results", data) if isinstance(data, dict) else data + meta = result.get("meta", {}).get("pagination") + return results, meta + + yield from paginate_sync(fetch_page, page_size) + + def similar( + self, + *, + concept_id: int | None = None, + concept_name: str | None = None, + query: str | None = None, + algorithm: Literal["semantic", "lexical", "hybrid"] = "hybrid", + similarity_threshold: float = 0.7, + page_size: int = 20, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_concept: Literal["S", "C", "N"] | None = None, + include_invalid: bool | None = None, + include_scores: bool | None = None, + include_explanations: bool | None = None, + ) -> SimilarSearchResult: + """Find concepts similar to a reference concept or query. + + Must provide exactly one of: concept_id, concept_name, or query. + + Args: + concept_id: Find concepts similar to this concept ID + concept_name: Find concepts similar to this name + query: Natural language query for semantic similarity + algorithm: 'semantic' (neural), 'lexical' (text), or 'hybrid' (both) + similarity_threshold: Minimum similarity (0.0-1.0) + page_size: Max results to return (max 1000) + vocabulary_ids: Filter by vocabulary IDs + domain_ids: Filter by domain IDs + standard_concept: Filter by standard concept flag + include_invalid: Include invalid/deprecated concepts + include_scores: Include detailed similarity scores + include_explanations: Include similarity explanations + + Returns: + Similar concepts with similarity scores and metadata + + Raises: + ValueError: If not exactly one of concept_id, concept_name, or query + is provided. + + Note: + When algorithm='semantic', only single vocabulary/domain filter supported. + """ + # Validate exactly one input source provided + input_count = sum(x is not None for x in [concept_id, concept_name, query]) + if input_count != 1: + raise ValueError( + "Exactly one of concept_id, concept_name, or query must be provided" + ) + + body: dict[str, Any] = { + "algorithm": algorithm, + "similarity_threshold": similarity_threshold, + } + if concept_id is not None: + body["concept_id"] = concept_id + if concept_name is not None: + body["concept_name"] = concept_name + if query is not None: + body["query"] = query + if page_size != 20: + body["page_size"] = page_size + if vocabulary_ids: + body["vocabulary_ids"] = vocabulary_ids + if domain_ids: + body["domain_ids"] = domain_ids + if standard_concept: + body["standard_concept"] = standard_concept + if include_invalid is not None: + body["include_invalid"] = include_invalid + if include_scores is not None: + body["include_scores"] = include_scores + if include_explanations is not None: + body["include_explanations"] = include_explanations + + return self._request.post("/search/similar", json_data=body) + class AsyncSearch: """Asynchronous search resource.""" @@ -375,3 +553,137 @@ async def autocomplete( params["domains"] = ",".join(domains) return await self._request.get("/search/suggest", params=params) + + async def semantic( + self, + query: str, + *, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_concept: Literal["S", "C"] | None = None, + concept_class_id: str | None = None, + threshold: float | None = None, + page: int = 1, + page_size: int = 20, + ) -> dict[str, Any]: + """Semantic concept search using neural embeddings.""" + params: dict[str, Any] = {"query": query, "page": page, "page_size": page_size} + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if standard_concept: + params["standard_concept"] = standard_concept + if concept_class_id: + params["concept_class_id"] = concept_class_id + if threshold is not None: + params["threshold"] = threshold + + return await self._request.get("/concepts/semantic-search", params=params) + + async def semantic_iter( + self, + query: str, + *, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_concept: Literal["S", "C"] | None = None, + concept_class_id: str | None = None, + threshold: float | None = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> AsyncIterator[SemanticSearchResult]: + """Iterate through all semantic search results with auto-pagination.""" + page = 1 + + while True: + params: dict[str, Any] = { + "query": query, + "page": page, + "page_size": page_size, + } + if vocabulary_ids: + params["vocabulary_ids"] = ",".join(vocabulary_ids) + if domain_ids: + params["domain_ids"] = ",".join(domain_ids) + if standard_concept: + params["standard_concept"] = standard_concept + if concept_class_id: + params["concept_class_id"] = concept_class_id + if threshold is not None: + params["threshold"] = threshold + + result = await self._request.get_raw( + "/concepts/semantic-search", params=params + ) + + data = result.get("data", []) + results: list[SemanticSearchResult] = ( + data.get("results", data) if isinstance(data, dict) else data + ) + meta: PaginationMeta | None = result.get("meta", {}).get("pagination") + + for item in results: + yield item + + if meta is None or not meta.get("has_next", False): + break + + page += 1 + + async def similar( + self, + *, + concept_id: int | None = None, + concept_name: str | None = None, + query: str | None = None, + algorithm: Literal["semantic", "lexical", "hybrid"] = "hybrid", + similarity_threshold: float = 0.7, + page_size: int = 20, + vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, + standard_concept: Literal["S", "C", "N"] | None = None, + include_invalid: bool | None = None, + include_scores: bool | None = None, + include_explanations: bool | None = None, + ) -> SimilarSearchResult: + """Find concepts similar to a reference concept or query. + + Must provide exactly one of: concept_id, concept_name, or query. + + Raises: + ValueError: If not exactly one of concept_id, concept_name, or query + is provided. + """ + # Validate exactly one input source provided + input_count = sum(x is not None for x in [concept_id, concept_name, query]) + if input_count != 1: + raise ValueError( + "Exactly one of concept_id, concept_name, or query must be provided" + ) + + body: dict[str, Any] = { + "algorithm": algorithm, + "similarity_threshold": similarity_threshold, + } + if concept_id is not None: + body["concept_id"] = concept_id + if concept_name is not None: + body["concept_name"] = concept_name + if query is not None: + body["query"] = query + if page_size != 20: + body["page_size"] = page_size + if vocabulary_ids: + body["vocabulary_ids"] = vocabulary_ids + if domain_ids: + body["domain_ids"] = domain_ids + if standard_concept: + body["standard_concept"] = standard_concept + if include_invalid is not None: + body["include_invalid"] = include_invalid + if include_scores is not None: + body["include_scores"] = include_scores + if include_explanations is not None: + body["include_explanations"] = include_explanations + + return await self._request.post("/search/similar", json_data=body) diff --git a/src/omophub/types/__init__.py b/src/omophub/types/__init__.py index 630bd4d..33e4061 100644 --- a/src/omophub/types/__init__.py +++ b/src/omophub/types/__init__.py @@ -38,6 +38,11 @@ SearchFacets, SearchMetadata, SearchResult, + SemanticSearchMeta, + SemanticSearchResult, + SimilarConcept, + SimilarSearchMetadata, + SimilarSearchResult, Suggestion, ) from .vocabulary import ( @@ -84,6 +89,11 @@ "SearchFacets", "SearchMetadata", "SearchResult", + "SemanticSearchMeta", + "SemanticSearchResult", + "SimilarConcept", + "SimilarSearchMetadata", + "SimilarSearchResult", "Suggestion", "Synonym", # Vocabulary diff --git a/src/omophub/types/search.py b/src/omophub/types/search.py index 0e6e768..5898683 100644 --- a/src/omophub/types/search.py +++ b/src/omophub/types/search.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict from typing_extensions import NotRequired @@ -21,6 +21,62 @@ class Suggestion(TypedDict): vocabulary_id: NotRequired[str] +class SemanticSearchResult(TypedDict): + """Result from semantic concept search.""" + + concept_id: int + concept_name: str + domain_id: str + vocabulary_id: str + concept_class_id: str + standard_concept: str | None + concept_code: str + similarity_score: float + matched_text: str + + +class SemanticSearchMeta(TypedDict, total=False): + """Metadata for semantic search.""" + + query: str + total_results: int + filters_applied: dict[str, Any] + + +class SimilarConcept(TypedDict): + """A concept similar to the query concept.""" + + concept_id: int + concept_name: str + domain_id: str + vocabulary_id: str + concept_class_id: str + standard_concept: str | None + concept_code: str + similarity_score: float + matched_text: NotRequired[str] + similarity_explanation: NotRequired[str] + + +class SimilarSearchMetadata(TypedDict, total=False): + """Metadata for similar concept search.""" + + original_query: str + algorithm_used: str + similarity_threshold: float + total_candidates: int + results_returned: int + processing_time_ms: int + embedding_latency_ms: int + + +class SimilarSearchResult(TypedDict): + """Result from similar concept search.""" + + similar_concepts: list[SimilarConcept] + search_metadata: SimilarSearchMetadata + + class SearchFacet(TypedDict): """Search facet with count.""" diff --git a/tests/integration/test_search.py b/tests/integration/test_search.py index da380af..98736c8 100644 --- a/tests/integration/test_search.py +++ b/tests/integration/test_search.py @@ -160,3 +160,156 @@ def test_basic_iter_pagination(self, integration_client: OMOPHub) -> None: # All should have concept_id for concept in concepts: assert "concept_id" in concept + + +@pytest.mark.integration +class TestSemanticSearchIntegration: + """Integration tests for semantic search endpoints.""" + + def test_semantic_search_basic(self, integration_client: OMOPHub) -> None: + """Test basic semantic search returns results with similarity scores.""" + result = integration_client.search.semantic( + "myocardial infarction", page_size=5 + ) + + # SDK may return data wrapped in 'results' key + results = result.get("results", result) + if isinstance(results, dict) and "results" in results: + results = results["results"] + + # Should have results + assert isinstance(results, list) + if len(results) > 0: + # Each result should have similarity score + for r in results: + assert "similarity_score" in r or "score" in r + assert "concept_id" in r + + def test_semantic_search_with_filters(self, integration_client: OMOPHub) -> None: + """Test semantic search with vocabulary/domain filters.""" + result = integration_client.search.semantic( + "diabetes", + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + threshold=0.5, + page_size=10, + ) + + results = result.get("results", result) + if isinstance(results, dict) and "results" in results: + results = results["results"] + + # If results exist, verify filters applied + if len(results) > 0: + for r in results: + assert r.get("vocabulary_id") == "SNOMED" + assert r.get("domain_id") == "Condition" + + def test_semantic_search_with_threshold(self, integration_client: OMOPHub) -> None: + """Test that higher threshold returns fewer or equal results.""" + result_low = integration_client.search.semantic( + "heart attack", threshold=0.3, page_size=20 + ) + result_high = integration_client.search.semantic( + "heart attack", threshold=0.8, page_size=20 + ) + + results_low = result_low.get("results", []) + results_high = result_high.get("results", []) + + if isinstance(results_low, dict) and "results" in results_low: + results_low = results_low["results"] + if isinstance(results_high, dict) and "results" in results_high: + results_high = results_high["results"] + + # Guard: skip test if no results to compare + if not results_low: + pytest.skip( + "No results returned for low threshold - cannot test threshold comparison" + ) + + # Higher threshold should return fewer or equal results + assert len(results_high) <= len(results_low) + + def test_semantic_iter_pagination(self, integration_client: OMOPHub) -> None: + """Test auto-pagination via semantic_iter.""" + import itertools + + results = list( + itertools.islice( + integration_client.search.semantic_iter("diabetes", page_size=5), 10 + ) + ) + + # Should get up to 10 results across multiple pages + assert len(results) > 0 + # Each result should have required fields + for r in results: + assert "concept_id" in r + + def test_similar_by_concept_id(self, integration_client: OMOPHub) -> None: + """Test finding similar concepts by ID.""" + from tests.conftest import MI_CONCEPT_ID + + result = integration_client.search.similar( + concept_id=MI_CONCEPT_ID, page_size=5 + ) + + # Should have similar_concepts key or be a list + similar = result.get("similar_concepts", result) + if isinstance(similar, dict) and "similar_concepts" in similar: + similar = similar["similar_concepts"] + + assert isinstance(similar, list) + # If results exist, verify structure + if len(similar) > 0: + for concept in similar: + assert "concept_id" in concept + + def test_similar_by_query(self, integration_client: OMOPHub) -> None: + """Test finding similar concepts by natural language query.""" + result = integration_client.search.similar( + query="elevated blood glucose", page_size=5 + ) + + similar = result.get("similar_concepts", result) + if isinstance(similar, dict) and "similar_concepts" in similar: + similar = similar["similar_concepts"] + + assert isinstance(similar, list) + + def test_similar_with_algorithm(self, integration_client: OMOPHub) -> None: + """Test similar search with different algorithms.""" + from tests.conftest import MI_CONCEPT_ID + + result = integration_client.search.similar( + concept_id=MI_CONCEPT_ID, + algorithm="semantic", + similarity_threshold=0.6, + page_size=5, + ) + + # Verify response structure + assert isinstance(result, dict) + # May have search_metadata with algorithm info + metadata = result.get("search_metadata", {}) + if metadata: + assert metadata.get("algorithm_used") in ["semantic", "hybrid", "lexical"] + + def test_similar_with_vocabulary_filter(self, integration_client: OMOPHub) -> None: + """Test similar search filtered by vocabulary.""" + from tests.conftest import DIABETES_CONCEPT_ID + + result = integration_client.search.similar( + concept_id=DIABETES_CONCEPT_ID, + vocabulary_ids=["SNOMED"], + page_size=10, + ) + + similar = result.get("similar_concepts", []) + if isinstance(similar, dict) and "similar_concepts" in similar: + similar = similar["similar_concepts"] + + # If results, all should be from SNOMED + for concept in similar: + assert concept.get("vocabulary_id") == "SNOMED" diff --git a/tests/unit/resources/test_search.py b/tests/unit/resources/test_search.py index 91edd61..a355ae0 100644 --- a/tests/unit/resources/test_search.py +++ b/tests/unit/resources/test_search.py @@ -272,3 +272,581 @@ async def test_async_autocomplete( result = await async_client.search.autocomplete("asp") assert len(result) == 1 + + +class TestSemanticSearch: + """Tests for semantic search functionality.""" + + @respx.mock + def test_semantic_search(self, sync_client: OMOPHub, base_url: str) -> None: + """Test semantic concept search.""" + semantic_response = { + "success": True, + "data": { + "results": [ + { + "concept_id": 4329847, + "concept_name": "Myocardial infarction", + "domain_id": "Condition", + "vocabulary_id": "SNOMED", + "concept_class_id": "Clinical Finding", + "standard_concept": "S", + "concept_code": "22298006", + "similarity_score": 0.95, + "matched_text": "heart attack", + } + ], + }, + "meta": {"pagination": {"page": 1, "has_next": False, "total_items": 1}}, + } + route = respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json=semantic_response) + ) + + result = sync_client.search.semantic("heart attack") + assert "results" in result + assert len(result["results"]) == 1 + assert result["results"][0]["similarity_score"] == 0.95 + + url_str = str(route.calls[0].request.url) + assert "query=heart+attack" in url_str + + @respx.mock + def test_semantic_search_with_filters( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test semantic search with all filters.""" + route = respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json={"success": True, "data": {"results": []}}) + ) + + sync_client.search.semantic( + "heart attack", + vocabulary_ids=["SNOMED", "ICD10CM"], + domain_ids=["Condition"], + standard_concept="S", + concept_class_id="Clinical Finding", + threshold=0.5, + page=2, + page_size=50, + ) + + url_str = str(route.calls[0].request.url) + assert "vocabulary_ids=SNOMED%2CICD10CM" in url_str + assert "domain_ids=Condition" in url_str + assert "standard_concept=S" in url_str + assert "concept_class_id=Clinical+Finding" in url_str + assert "threshold=0.5" in url_str + assert "page=2" in url_str + assert "page_size=50" in url_str + + @respx.mock + def test_semantic_iter_single_page( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test semantic_iter with single page.""" + semantic_response = { + "success": True, + "data": [ + {"concept_id": 1, "similarity_score": 0.9}, + {"concept_id": 2, "similarity_score": 0.8}, + ], + "meta": {"pagination": {"page": 1, "has_next": False}}, + } + respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json=semantic_response) + ) + + results = list(sync_client.search.semantic_iter("diabetes")) + assert len(results) == 2 + + @respx.mock + def test_semantic_iter_multiple_pages( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test semantic_iter auto-pagination.""" + page1_response = { + "success": True, + "data": [{"concept_id": 1, "similarity_score": 0.9}], + "meta": {"pagination": {"page": 1, "has_next": True}}, + } + page2_response = { + "success": True, + "data": [{"concept_id": 2, "similarity_score": 0.8}], + "meta": {"pagination": {"page": 2, "has_next": False}}, + } + + call_count = 0 + + def mock_response(request): + nonlocal call_count + call_count += 1 + if call_count == 1: + return Response(200, json=page1_response) + return Response(200, json=page2_response) + + respx.get(f"{base_url}/concepts/semantic-search").mock(side_effect=mock_response) + + results = list(sync_client.search.semantic_iter("diabetes", page_size=1)) + assert len(results) == 2 + assert results[0]["concept_id"] == 1 + assert results[1]["concept_id"] == 2 + + @respx.mock + def test_semantic_iter_empty_response( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test semantic_iter with empty response yields no items.""" + semantic_response = { + "success": True, + "data": [], + "meta": {"pagination": {"page": 1, "has_next": False}}, + } + respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json=semantic_response) + ) + + results = list(sync_client.search.semantic_iter("nonexistent query")) + assert len(results) == 0 + + +class TestSimilarSearch: + """Tests for similar concept search functionality.""" + + @respx.mock + def test_similar_by_concept_id(self, sync_client: OMOPHub, base_url: str) -> None: + """Test finding similar concepts by concept_id.""" + similar_response = { + "success": True, + "data": { + "similar_concepts": [ + { + "concept_id": 1234, + "concept_name": "Similar condition", + "domain_id": "Condition", + "vocabulary_id": "SNOMED", + "concept_class_id": "Clinical Finding", + "standard_concept": "S", + "concept_code": "12345", + "similarity_score": 0.85, + } + ], + "search_metadata": { + "original_query": "4329847", + "algorithm_used": "hybrid", + "similarity_threshold": 0.7, + "total_candidates": 100, + "results_returned": 1, + }, + }, + } + route = respx.post(f"{base_url}/search/similar").mock( + return_value=Response(200, json=similar_response) + ) + + result = sync_client.search.similar(concept_id=4329847) + assert "similar_concepts" in result + assert len(result["similar_concepts"]) == 1 + + # Verify POST body + import json + + body = json.loads(route.calls[0].request.content) + assert body["concept_id"] == 4329847 + assert body["algorithm"] == "hybrid" + assert body["similarity_threshold"] == 0.7 + + @respx.mock + def test_similar_by_concept_name( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test finding similar concepts by concept_name.""" + route = respx.post(f"{base_url}/search/similar").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"similar_concepts": [], "search_metadata": {}}, + }, + ) + ) + + sync_client.search.similar(concept_name="Type 2 diabetes mellitus") + + import json + + body = json.loads(route.calls[0].request.content) + assert body["concept_name"] == "Type 2 diabetes mellitus" + + @respx.mock + def test_similar_by_query(self, sync_client: OMOPHub, base_url: str) -> None: + """Test finding similar concepts by natural language query.""" + route = respx.post(f"{base_url}/search/similar").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"similar_concepts": [], "search_metadata": {}}, + }, + ) + ) + + sync_client.search.similar(query="high blood sugar condition") + + import json + + body = json.loads(route.calls[0].request.content) + assert body["query"] == "high blood sugar condition" + + @respx.mock + def test_similar_with_all_options( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Test similar search with all options.""" + route = respx.post(f"{base_url}/search/similar").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"similar_concepts": [], "search_metadata": {}}, + }, + ) + ) + + sync_client.search.similar( + concept_id=4329847, + algorithm="semantic", + similarity_threshold=0.8, + page_size=50, + vocabulary_ids=["SNOMED", "ICD10CM"], + domain_ids=["Condition"], + standard_concept="S", + include_invalid=True, + include_scores=True, + include_explanations=True, + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["algorithm"] == "semantic" + assert body["similarity_threshold"] == 0.8 + assert body["page_size"] == 50 + assert body["vocabulary_ids"] == ["SNOMED", "ICD10CM"] + assert body["domain_ids"] == ["Condition"] + assert body["standard_concept"] == "S" + assert body["include_invalid"] is True + assert body["include_scores"] is True + assert body["include_explanations"] is True + + +class TestAsyncSemanticSearch: + """Tests for async semantic search functionality.""" + + @pytest.mark.asyncio + @respx.mock + async def test_async_semantic_search( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async semantic search.""" + semantic_response = { + "success": True, + "data": { + "results": [{"concept_id": 4329847, "similarity_score": 0.95}], + }, + } + respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json=semantic_response) + ) + + result = await async_client.search.semantic("heart attack") + assert "results" in result + + @pytest.mark.asyncio + @respx.mock + async def test_async_semantic_with_filters( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async semantic search with filters.""" + route = respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json={"success": True, "data": {"results": []}}) + ) + + await async_client.search.semantic( + "diabetes", + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + standard_concept="S", + threshold=0.6, + ) + + url_str = str(route.calls[0].request.url) + assert "vocabulary_ids=SNOMED" in url_str + assert "standard_concept=S" in url_str + assert "threshold=0.6" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_semantic_with_all_filters( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async semantic search with all available filters.""" + route = respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json={"success": True, "data": {"results": []}}) + ) + + await async_client.search.semantic( + "heart attack", + vocabulary_ids=["SNOMED", "ICD10CM"], + domain_ids=["Condition", "Observation"], + standard_concept="C", + concept_class_id="Clinical Finding", + threshold=0.7, + page=3, + page_size=50, + ) + + url_str = str(route.calls[0].request.url) + assert "vocabulary_ids=SNOMED%2CICD10CM" in url_str + assert "domain_ids=Condition%2CObservation" in url_str + assert "standard_concept=C" in url_str + assert "concept_class_id=Clinical+Finding" in url_str + assert "threshold=0.7" in url_str + assert "page=3" in url_str + assert "page_size=50" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_semantic_iter_single_page( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async semantic_iter with single page.""" + semantic_response = { + "success": True, + "data": [ + {"concept_id": 1, "similarity_score": 0.9}, + {"concept_id": 2, "similarity_score": 0.8}, + ], + "meta": {"pagination": {"page": 1, "has_next": False}}, + } + respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json=semantic_response) + ) + + results = [] + async for item in async_client.search.semantic_iter("diabetes"): + results.append(item) + + assert len(results) == 2 + assert results[0]["concept_id"] == 1 + assert results[1]["concept_id"] == 2 + + @pytest.mark.asyncio + @respx.mock + async def test_async_semantic_iter_multiple_pages( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async semantic_iter auto-pagination across multiple pages.""" + page1_response = { + "success": True, + "data": [{"concept_id": 1, "similarity_score": 0.9}], + "meta": {"pagination": {"page": 1, "has_next": True}}, + } + page2_response = { + "success": True, + "data": [{"concept_id": 2, "similarity_score": 0.8}], + "meta": {"pagination": {"page": 2, "has_next": True}}, + } + page3_response = { + "success": True, + "data": [{"concept_id": 3, "similarity_score": 0.7}], + "meta": {"pagination": {"page": 3, "has_next": False}}, + } + + call_count = 0 + + def mock_response(request): + nonlocal call_count + call_count += 1 + if call_count == 1: + return Response(200, json=page1_response) + elif call_count == 2: + return Response(200, json=page2_response) + return Response(200, json=page3_response) + + respx.get(f"{base_url}/concepts/semantic-search").mock(side_effect=mock_response) + + results = [] + async for item in async_client.search.semantic_iter("diabetes", page_size=1): + results.append(item) + + assert len(results) == 3 + assert results[0]["concept_id"] == 1 + assert results[1]["concept_id"] == 2 + assert results[2]["concept_id"] == 3 + + @pytest.mark.asyncio + @respx.mock + async def test_async_semantic_iter_with_filters( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async semantic_iter with filters are forwarded correctly.""" + semantic_response = { + "success": True, + "data": [{"concept_id": 1, "similarity_score": 0.9}], + "meta": {"pagination": {"page": 1, "has_next": False}}, + } + route = respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json=semantic_response) + ) + + results = [] + async for item in async_client.search.semantic_iter( + "diabetes", + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + standard_concept="S", + concept_class_id="Clinical Finding", + threshold=0.5, + page_size=10, + ): + results.append(item) + + assert len(results) == 1 + url_str = str(route.calls[0].request.url) + assert "vocabulary_ids=SNOMED" in url_str + assert "domain_ids=Condition" in url_str + assert "standard_concept=S" in url_str + assert "concept_class_id=Clinical+Finding" in url_str + assert "threshold=0.5" in url_str + assert "page_size=10" in url_str + + @pytest.mark.asyncio + @respx.mock + async def test_async_semantic_iter_empty_response( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async semantic_iter with empty response yields no items.""" + semantic_response = { + "success": True, + "data": [], + "meta": {"pagination": {"page": 1, "has_next": False}}, + } + respx.get(f"{base_url}/concepts/semantic-search").mock( + return_value=Response(200, json=semantic_response) + ) + + results = [] + async for item in async_client.search.semantic_iter("nonexistent query"): + results.append(item) + + assert len(results) == 0 + + @pytest.mark.asyncio + @respx.mock + async def test_async_similar( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async similar search.""" + similar_response = { + "success": True, + "data": { + "similar_concepts": [{"concept_id": 1234, "similarity_score": 0.85}], + "search_metadata": {"algorithm_used": "hybrid"}, + }, + } + respx.post(f"{base_url}/search/similar").mock( + return_value=Response(200, json=similar_response) + ) + + result = await async_client.search.similar(concept_id=4329847) + assert "similar_concepts" in result + assert len(result["similar_concepts"]) == 1 + + @pytest.mark.asyncio + @respx.mock + async def test_async_similar_by_concept_name( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async similar search by concept_name.""" + route = respx.post(f"{base_url}/search/similar").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"similar_concepts": [], "search_metadata": {}}, + }, + ) + ) + + await async_client.search.similar(concept_name="Type 2 diabetes mellitus") + + import json + + body = json.loads(route.calls[0].request.content) + assert body["concept_name"] == "Type 2 diabetes mellitus" + + @pytest.mark.asyncio + @respx.mock + async def test_async_similar_by_query( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async similar search by natural language query.""" + route = respx.post(f"{base_url}/search/similar").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"similar_concepts": [], "search_metadata": {}}, + }, + ) + ) + + await async_client.search.similar(query="high blood sugar condition") + + import json + + body = json.loads(route.calls[0].request.content) + assert body["query"] == "high blood sugar condition" + + @pytest.mark.asyncio + @respx.mock + async def test_async_similar_with_all_options( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """Test async similar search with all options.""" + route = respx.post(f"{base_url}/search/similar").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"similar_concepts": [], "search_metadata": {}}, + }, + ) + ) + + await async_client.search.similar( + concept_id=4329847, + algorithm="semantic", + similarity_threshold=0.8, + page_size=50, + vocabulary_ids=["SNOMED", "ICD10CM"], + domain_ids=["Condition"], + standard_concept="S", + include_invalid=True, + include_scores=True, + include_explanations=True, + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["algorithm"] == "semantic" + assert body["similarity_threshold"] == 0.8 + assert body["page_size"] == 50 + assert body["vocabulary_ids"] == ["SNOMED", "ICD10CM"] + assert body["domain_ids"] == ["Condition"] + assert body["standard_concept"] == "S" + assert body["include_invalid"] is True + assert body["include_scores"] is True + assert body["include_explanations"] is True From 156be70818433fd191a81091ed4b3f1a30970b72 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 16 Feb 2026 22:15:08 +0000 Subject: [PATCH 26/48] Add semantic search examples to README --- .github/FUNDING.yml | 4 ---- README.md | 25 ++++++++++++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) delete mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index c15574b..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,4 +0,0 @@ -# These are supported funding model platforms - -github: [omophub] - diff --git a/README.md b/README.md index 711aa90..e5f2b84 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,29 @@ mappings = client.mappings.get_by_code("ICD10CM", "E11.9", target_vocabulary="SN ancestors = client.hierarchy.ancestors(201826, max_levels=3) ``` +## Semantic Search + +Use natural language queries to find concepts using neural embeddings: + +```python +# Natural language search - understands clinical intent +results = client.search.semantic("high blood sugar levels") +for r in results["data"]["results"]: + print(f"{r['concept_name']} (similarity: {r['similarity_score']:.2f})") + +# Filter by vocabulary and set minimum similarity threshold +results = client.search.semantic( + "heart attack", + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + threshold=0.5 +) + +# Iterate through all results with auto-pagination +for result in client.search.semantic_iter("chronic kidney disease", page_size=50): + print(f"{result['concept_id']}: {result['concept_name']}") +``` + ## Async Support ```python @@ -130,7 +153,7 @@ suggestions = client.concepts.suggest("diab", vocabulary_ids=["SNOMED"], page_si | Resource | Description | Key Methods | |----------|-------------|-------------| | `concepts` | Concept lookup and batch operations | `get()`, `get_by_code()`, `batch()`, `suggest()` | -| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `fuzzy()` | +| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `semantic_iter()`, `fuzzy()` | | `hierarchy` | Navigate concept relationships | `ancestors()`, `descendants()` | | `mappings` | Cross-vocabulary mappings | `get()`, `map()` | | `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` | From 1c875244099d87d32273aa2d9a1efffec46a49c2 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 16 Feb 2026 22:39:45 +0000 Subject: [PATCH 27/48] Refactor README and integration tests to streamline result extraction - Updated README example to reflect changes in the results structure for semantic search. - Refactored integration tests to utilize a new `extract_data` function for consistent handling of results and similar concepts, improving code clarity and maintainability. --- README.md | 2 +- tests/integration/test_search.py | 31 ++++++++----------------------- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e5f2b84..2474460 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Use natural language queries to find concepts using neural embeddings: ```python # Natural language search - understands clinical intent results = client.search.semantic("high blood sugar levels") -for r in results["data"]["results"]: +for r in results["results"]: print(f"{r['concept_name']} (similarity: {r['similarity_score']:.2f})") # Filter by vocabulary and set minimum similarity threshold diff --git a/tests/integration/test_search.py b/tests/integration/test_search.py index 98736c8..ad9e527 100644 --- a/tests/integration/test_search.py +++ b/tests/integration/test_search.py @@ -172,10 +172,8 @@ def test_semantic_search_basic(self, integration_client: OMOPHub) -> None: "myocardial infarction", page_size=5 ) - # SDK may return data wrapped in 'results' key - results = result.get("results", result) - if isinstance(results, dict) and "results" in results: - results = results["results"] + # SDK may return data wrapped in 'results' key or as a list + results = extract_data(result, "results") # Should have results assert isinstance(results, list) @@ -195,9 +193,7 @@ def test_semantic_search_with_filters(self, integration_client: OMOPHub) -> None page_size=10, ) - results = result.get("results", result) - if isinstance(results, dict) and "results" in results: - results = results["results"] + results = extract_data(result, "results") # If results exist, verify filters applied if len(results) > 0: @@ -214,13 +210,8 @@ def test_semantic_search_with_threshold(self, integration_client: OMOPHub) -> No "heart attack", threshold=0.8, page_size=20 ) - results_low = result_low.get("results", []) - results_high = result_high.get("results", []) - - if isinstance(results_low, dict) and "results" in results_low: - results_low = results_low["results"] - if isinstance(results_high, dict) and "results" in results_high: - results_high = results_high["results"] + results_low = extract_data(result_low, "results") + results_high = extract_data(result_high, "results") # Guard: skip test if no results to compare if not results_low: @@ -256,9 +247,7 @@ def test_similar_by_concept_id(self, integration_client: OMOPHub) -> None: ) # Should have similar_concepts key or be a list - similar = result.get("similar_concepts", result) - if isinstance(similar, dict) and "similar_concepts" in similar: - similar = similar["similar_concepts"] + similar = extract_data(result, "similar_concepts") assert isinstance(similar, list) # If results exist, verify structure @@ -272,9 +261,7 @@ def test_similar_by_query(self, integration_client: OMOPHub) -> None: query="elevated blood glucose", page_size=5 ) - similar = result.get("similar_concepts", result) - if isinstance(similar, dict) and "similar_concepts" in similar: - similar = similar["similar_concepts"] + similar = extract_data(result, "similar_concepts") assert isinstance(similar, list) @@ -306,9 +293,7 @@ def test_similar_with_vocabulary_filter(self, integration_client: OMOPHub) -> No page_size=10, ) - similar = result.get("similar_concepts", []) - if isinstance(similar, dict) and "similar_concepts" in similar: - similar = similar["similar_concepts"] + similar = extract_data(result, "similar_concepts") # If results, all should be from SNOMED for concept in similar: From 89b86842a11b393085056c48444221bb7ef37de5 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 16 Feb 2026 22:47:44 +0000 Subject: [PATCH 28/48] Update CI workflow to support multiple branches and manual triggering --- .github/workflows/ci.yml | 7 +++++-- tests/conftest.py | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3fd817..b817a67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,10 @@ name: CI on: push: - branches: [main] + branches: [main, master, develop] pull_request: - branches: [main] + branches: [main, master, develop] + workflow_dispatch: # Allow manual triggering jobs: lint: @@ -52,6 +53,8 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} integration: + # Only run on push to main/develop, not on PRs (saves ~7-8 min per PR) + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/tests/conftest.py b/tests/conftest.py index 195e418..cbd3a09 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -172,6 +172,11 @@ def extract_data(result: dict[str, Any] | list[Any], key: str) -> list[Any]: value = result.get(key) if isinstance(value, list): return value + # Handle nested dicts: {"results": {"results": [...]}} + if isinstance(value, dict) and key in value: + nested = value.get(key) + if isinstance(nested, list): + return nested # Fallback: check 'results' key for batch endpoint backward compatibility # (production API returns 'results', new API will return 'concepts') if key == "concepts": From 52a51134dbeb17675fdd8f56f294f72ff4ed31a9 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Wed, 18 Feb 2026 13:51:53 +0000 Subject: [PATCH 29/48] Update minimum similarity threshold in search.py from 0.3 to 0.5 for improved filtering accuracy. --- src/omophub/resources/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/omophub/resources/search.py b/src/omophub/resources/search.py index 83232e8..2a98dda 100644 --- a/src/omophub/resources/search.py +++ b/src/omophub/resources/search.py @@ -297,7 +297,7 @@ def semantic( domain_ids: Filter by domain IDs standard_concept: Filter by standard concept flag ('S' or 'C') concept_class_id: Filter by concept class - threshold: Minimum similarity threshold (0.0-1.0, default 0.3) + threshold: Minimum similarity threshold (0.0-1.0, default 0.5) page: Page number (1-based) page_size: Results per page (max 100) From 26c9007ac14bee2d0005276a1acb3d7dae675344 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 23 Feb 2026 08:40:14 +0000 Subject: [PATCH 30/48] v1.4.0 release --- CHANGELOG.md | 10 ++++++- README.md | 22 +++++++++++++- examples/search_concepts.py | 59 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 5 +++- 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e8429..395697b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2026-02-23 + +### Added + +- **Semantic search** (`search.semantic()`, `search.semantic_iter()`): Natural language concept search using neural embeddings. Search for clinical intent like "high blood sugar levels" to find diabetes-related concepts. Supports filtering by vocabulary, domain, standard concept, concept class, and minimum similarity threshold. `semantic_iter()` provides automatic pagination. +- **Similarity search** (`search.similar()`): Find concepts similar to a reference concept ID, concept name, or natural language query. Three algorithm options: `'semantic'` (neural embeddings), `'lexical'` (string matching), and `'hybrid'` (combined). Configurable similarity threshold with optional detailed scores and explanations. + ## [1.3.1] - 2026-01-24 ### Fixed @@ -83,7 +90,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full type hints and PEP 561 compliance - HTTP/2 support via httpx -[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.3.1...HEAD +[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/omopHub/omophub-python/compare/v1.3.1...v1.4.0 [1.3.1]: https://github.com/omopHub/omophub-python/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/omopHub/omophub-python/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/omopHub/omophub-python/compare/v0.1.0...v1.2.0 diff --git a/README.md b/README.md index 2474460..44ac4c4 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,26 @@ for result in client.search.semantic_iter("chronic kidney disease", page_size=50 print(f"{result['concept_id']}: {result['concept_name']}") ``` +### Similarity Search + +Find concepts similar to a known concept or natural language query: + +```python +# Find concepts similar to a known concept +results = client.search.similar(concept_id=201826, algorithm="hybrid") +for r in results["results"]: + print(f"{r['concept_name']} (score: {r['similarity_score']:.2f})") + +# Find similar concepts using a natural language query +results = client.search.similar( + query="medications for high blood pressure", + algorithm="semantic", + similarity_threshold=0.6, + vocabulary_ids=["RxNorm"], + include_scores=True, +) +``` + ## Async Support ```python @@ -153,7 +173,7 @@ suggestions = client.concepts.suggest("diab", vocabulary_ids=["SNOMED"], page_si | Resource | Description | Key Methods | |----------|-------------|-------------| | `concepts` | Concept lookup and batch operations | `get()`, `get_by_code()`, `batch()`, `suggest()` | -| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `semantic_iter()`, `fuzzy()` | +| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `semantic_iter()`, `similar()`, `fuzzy()` | | `hierarchy` | Navigate concept relationships | `ancestors()`, `descendants()` | | `mappings` | Cross-vocabulary mappings | `get()`, `map()` | | `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` | diff --git a/examples/search_concepts.py b/examples/search_concepts.py index 419b0a7..a3d8bec 100644 --- a/examples/search_concepts.py +++ b/examples/search_concepts.py @@ -80,9 +80,68 @@ def pagination_example() -> None: print(f" ... and {count - 3} more concepts shown (demo limited to {count})") +def semantic_search() -> None: + """Demonstrate semantic search using neural embeddings.""" + print("\n=== Semantic Search ===") + + # Natural language search - understands clinical intent + results = client.search.semantic("high blood sugar levels") + for r in results["results"][:3]: + print(f" {r['concept_name']} (similarity: {r['similarity_score']:.2f})") + + # Filtered semantic search with minimum threshold + results = client.search.semantic( + "heart attack", + vocabulary_ids=["SNOMED"], + domain_ids=["Condition"], + threshold=0.5, + ) + print(f" Found {len(results['results'])} SNOMED conditions for 'heart attack'") + + +def semantic_pagination() -> None: + """Demonstrate auto-pagination with semantic_iter.""" + print("\n=== Semantic Pagination ===") + + count = 0 + for result in client.search.semantic_iter("chronic kidney disease", page_size=20): + count += 1 + if count <= 3: + print(f" {result['concept_id']}: {result['concept_name']}") + if count >= 50: # Limit for demo + break + + if count > 3: + print(f" ... and {count - 3} more results (demo limited to {count})") + + +def similarity_search() -> None: + """Demonstrate similarity search.""" + print("\n=== Similarity Search ===") + + # Find concepts similar to Type 2 diabetes mellitus (concept_id=201826) + results = client.search.similar(concept_id=201826, algorithm="hybrid") + print("Concepts similar to 'Type 2 diabetes mellitus':") + for r in results["results"][:5]: + print(f" {r['concept_name']} (score: {r['similarity_score']:.2f})") + + # Find similar using a natural language query with semantic algorithm + results = client.search.similar( + query="medications for high blood pressure", + algorithm="semantic", + similarity_threshold=0.6, + vocabulary_ids=["RxNorm"], + include_scores=True, + ) + print(f"\n Found {len(results['results'])} similar RxNorm concepts") + + if __name__ == "__main__": basic_search() filtered_search() fuzzy_search() autocomplete_example() pagination_example() + semantic_search() + semantic_pagination() + similarity_search() diff --git a/pyproject.toml b/pyproject.toml index d70cdb8..5e8646c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "omophub" dynamic = ["version"] -description = "Python SDK for OMOPHub - Medical Vocabulary API" +description = "Python SDK for OMOPHub - Medical Vocabulary API with semantic search" readme = "README.md" license = "MIT" requires-python = ">=3.10" @@ -27,6 +27,9 @@ keywords = [ "terminology", "api", "sdk", + "semantic-search", + "embeddings", + "nlp", ] classifiers = [ "Development Status :: 4 - Beta", From 2a3d387fb9e786665a41818e5666519e791ce09a Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 23 Feb 2026 08:47:05 +0000 Subject: [PATCH 31/48] Increase rate limit delay in integration tests from 1 second to 2 seconds for improved test reliability. --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index cbd3a09..6c3287f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -142,7 +142,7 @@ def rate_limit_delay(request: pytest.FixtureRequest) -> None: yield # Only delay for integration tests if "integration" in request.keywords: - time.sleep(1) + time.sleep(2) # Well-known test concept IDs for integration tests From 494cc8aa2bbdd3812468e072a44d4b10562b14a7 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 28 Feb 2026 15:19:14 +0000 Subject: [PATCH 32/48] Update version handling --- src/omophub/_version.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/omophub/_version.py b/src/omophub/_version.py index 159d658..7476876 100644 --- a/src/omophub/_version.py +++ b/src/omophub/_version.py @@ -1,6 +1,11 @@ """Version information for the OMOPHub SDK.""" -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("omophub") +except PackageNotFoundError: + __version__ = "0.0.0-dev" def get_version() -> str: From 3d3f3a25f4d55de5ecce51b110862a25023fe41b Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Sat, 28 Feb 2026 15:22:49 +0000 Subject: [PATCH 33/48] Prepare v1.4.1 release Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 395697b..2ff399f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.1] - 2026-02-28 + +### Fixed + +- User-Agent header now reports actual SDK version (e.g., `OMOPHub-SDK-Python/1.4.1`) instead of hardcoded `0.1.0`. Version is resolved at runtime via `importlib.metadata`. + ## [1.4.0] - 2026-02-23 ### Added @@ -90,7 +96,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full type hints and PEP 561 compliance - HTTP/2 support via httpx -[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.4.0...HEAD +[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.4.1...HEAD +[1.4.1]: https://github.com/omopHub/omophub-python/compare/v1.4.0...v1.4.1 [1.4.0]: https://github.com/omopHub/omophub-python/compare/v1.3.1...v1.4.0 [1.3.1]: https://github.com/omopHub/omophub-python/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/omopHub/omophub-python/compare/v1.2.0...v1.3.0 From 05df251e104db1118aeac52e6219d10dc59bb061 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Wed, 18 Mar 2026 18:23:42 +0000 Subject: [PATCH 34/48] Add retry logic for server errors in SyncHTTPClient and AsyncHTTPClientImpl - Implemented retry mechanism for handling server errors (502, 503, 504) in both synchronous and asynchronous HTTP clients. - Added exponential backoff delay for retries to improve resilience against temporary server issues. --- src/omophub/_http.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/omophub/_http.py b/src/omophub/_http.py index 94392ce..c6a72ef 100644 --- a/src/omophub/_http.py +++ b/src/omophub/_http.py @@ -137,6 +137,10 @@ def request( params=filtered_params if filtered_params else None, json=json, ) + # Retry on server errors (502, 503, 504) + if response.status_code in (502, 503, 504) and attempt < self._max_retries: + time.sleep(2**attempt * 0.5) + continue return response.content, response.status_code, response.headers except httpx.ConnectError as e: @@ -222,6 +226,10 @@ async def request( params=filtered_params if filtered_params else None, json=json, ) + # Retry on server errors (502, 503, 504) + if response.status_code in (502, 503, 504) and attempt < self._max_retries: + await asyncio.sleep(2**attempt * 0.5) + continue return response.content, response.status_code, response.headers except httpx.ConnectError as e: From b2a98db08d18999bf995206d4c9d886f4d9ed7bb Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Thu, 19 Mar 2026 10:04:58 +0000 Subject: [PATCH 35/48] Refactor retry condition formatting in SyncHTTPClient and AsyncHTTPClientImpl --- src/omophub/_http.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/omophub/_http.py b/src/omophub/_http.py index c6a72ef..04cee9e 100644 --- a/src/omophub/_http.py +++ b/src/omophub/_http.py @@ -138,7 +138,10 @@ def request( json=json, ) # Retry on server errors (502, 503, 504) - if response.status_code in (502, 503, 504) and attempt < self._max_retries: + if ( + response.status_code in (502, 503, 504) + and attempt < self._max_retries + ): time.sleep(2**attempt * 0.5) continue return response.content, response.status_code, response.headers @@ -227,7 +230,10 @@ async def request( json=json, ) # Retry on server errors (502, 503, 504) - if response.status_code in (502, 503, 504) and attempt < self._max_retries: + if ( + response.status_code in (502, 503, 504) + and attempt < self._max_retries + ): await asyncio.sleep(2**attempt * 0.5) continue return response.content, response.status_code, response.headers From 87c5b74c7ea65503752454d2d7bf0bad59a8b1a0 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Thu, 26 Mar 2026 17:48:59 +0000 Subject: [PATCH 36/48] Add bulk search functionality to the API - Introduced `bulk_basic` and `bulk_semantic` methods for executing multiple lexical and semantic searches in a single API call, respectively. - Updated the README to include examples for bulk search usage. - Added corresponding types for bulk search inputs and responses in the type definitions. - Implemented integration and unit tests to validate the new bulk search features. --- README.md | 24 ++++- src/omophub/resources/search.py | 117 ++++++++++++++++++++++++ src/omophub/types/__init__.py | 19 ++++ src/omophub/types/search.py | 108 +++++++++++++++++++++- tests/integration/test_search.py | 92 +++++++++++++++++++ tests/unit/resources/test_search.py | 136 ++++++++++++++++++++++++++++ 6 files changed, 494 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 44ac4c4..9cff18a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,28 @@ for result in client.search.semantic_iter("chronic kidney disease", page_size=50 print(f"{result['concept_id']}: {result['concept_name']}") ``` +### Bulk Search + +Search for multiple terms in a single API call — much faster than individual requests: + +```python +# Bulk lexical search (up to 50 queries) +results = client.search.bulk_basic([ + {"search_id": "q1", "query": "diabetes mellitus"}, + {"search_id": "q2", "query": "hypertension"}, + {"search_id": "q3", "query": "aspirin"}, +], defaults={"vocabulary_ids": ["SNOMED"], "page_size": 5}) + +for item in results["results"]: + print(f"{item['search_id']}: {len(item['results'])} results") + +# Bulk semantic search (up to 25 queries) +results = client.search.bulk_semantic([ + {"search_id": "s1", "query": "heart failure treatment options"}, + {"search_id": "s2", "query": "type 2 diabetes medication"}, +], defaults={"threshold": 0.5, "page_size": 10}) +``` + ### Similarity Search Find concepts similar to a known concept or natural language query: @@ -173,7 +195,7 @@ suggestions = client.concepts.suggest("diab", vocabulary_ids=["SNOMED"], page_si | Resource | Description | Key Methods | |----------|-------------|-------------| | `concepts` | Concept lookup and batch operations | `get()`, `get_by_code()`, `batch()`, `suggest()` | -| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `semantic_iter()`, `similar()`, `fuzzy()` | +| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `similar()`, `bulk_basic()`, `bulk_semantic()` | | `hierarchy` | Navigate concept relationships | `ancestors()`, `descendants()` | | `mappings` | Cross-vocabulary mappings | `get()`, `map()` | | `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` | diff --git a/src/omophub/resources/search.py b/src/omophub/resources/search.py index 2a98dda..3a03744 100644 --- a/src/omophub/resources/search.py +++ b/src/omophub/resources/search.py @@ -13,6 +13,12 @@ from ..types.common import PaginationMeta from ..types.concept import Concept from ..types.search import ( + BulkSearchDefaults, + BulkSearchInput, + BulkSearchResponse, + BulkSemanticSearchDefaults, + BulkSemanticSearchInput, + BulkSemanticSearchResponse, SearchResult, SemanticSearchResult, SimilarSearchResult, @@ -372,6 +378,77 @@ def fetch_page( yield from paginate_sync(fetch_page, page_size) + def bulk_basic( + self, + searches: list[BulkSearchInput], + *, + defaults: BulkSearchDefaults | None = None, + ) -> BulkSearchResponse: + """Execute multiple lexical searches in a single request. + + Sends up to 50 search queries in one API call. Each search can have + its own filters, or you can set shared defaults. + + Args: + searches: List of search inputs, each with a unique ``search_id`` + and ``query``. Max 50 items. + defaults: Default filters applied to all searches. Individual + search-level values override defaults. + + Returns: + Bulk results with per-search status, results, and timing. + + Example:: + + results = client.search.bulk_basic([ + {"search_id": "q1", "query": "diabetes"}, + {"search_id": "q2", "query": "hypertension"}, + ], defaults={"vocabulary_ids": ["SNOMED"], "page_size": 5}) + + for item in results["results"]: + print(item["search_id"], len(item["results"])) + """ + body: dict[str, Any] = {"searches": searches} + if defaults: + body["defaults"] = defaults + return self._request.post("/search/bulk", json_data=body) + + def bulk_semantic( + self, + searches: list[BulkSemanticSearchInput], + *, + defaults: BulkSemanticSearchDefaults | None = None, + ) -> BulkSemanticSearchResponse: + """Execute multiple semantic searches in a single request. + + Sends up to 25 natural-language queries in one API call using neural + embeddings. Each search can have its own filters and threshold. + + Args: + searches: List of search inputs, each with a unique ``search_id`` + and ``query`` (1-500 chars). Max 25 items. + defaults: Default filters applied to all searches. Individual + search-level values override defaults. + + Returns: + Bulk results with per-search status, similarity scores, and + optional query enhancements. + + Example:: + + results = client.search.bulk_semantic([ + {"search_id": "s1", "query": "heart failure treatment"}, + {"search_id": "s2", "query": "type 2 diabetes medication"}, + ], defaults={"threshold": 0.8, "page_size": 10}) + + for item in results["results"]: + print(item["search_id"], item.get("result_count", 0)) + """ + body: dict[str, Any] = {"searches": searches} + if defaults: + body["defaults"] = defaults + return self._request.post("/search/semantic-bulk", json_data=body) + def similar( self, *, @@ -630,6 +707,46 @@ async def semantic_iter( page += 1 + async def bulk_basic( + self, + searches: list[BulkSearchInput], + *, + defaults: BulkSearchDefaults | None = None, + ) -> BulkSearchResponse: + """Execute multiple lexical searches in a single request. + + Args: + searches: List of search inputs (max 50). + defaults: Default filters for all searches. + + Returns: + Bulk results with per-search status and results. + """ + body: dict[str, Any] = {"searches": searches} + if defaults: + body["defaults"] = defaults + return await self._request.post("/search/bulk", json_data=body) + + async def bulk_semantic( + self, + searches: list[BulkSemanticSearchInput], + *, + defaults: BulkSemanticSearchDefaults | None = None, + ) -> BulkSemanticSearchResponse: + """Execute multiple semantic searches in a single request. + + Args: + searches: List of search inputs (max 25). + defaults: Default filters for all searches. + + Returns: + Bulk results with per-search status and similarity scores. + """ + body: dict[str, Any] = {"searches": searches} + if defaults: + body["defaults"] = defaults + return await self._request.post("/search/semantic-bulk", json_data=body) + async def similar( self, *, diff --git a/src/omophub/types/__init__.py b/src/omophub/types/__init__.py index 33e4061..20f6c50 100644 --- a/src/omophub/types/__init__.py +++ b/src/omophub/types/__init__.py @@ -34,6 +34,15 @@ RelationshipType, ) from .search import ( + BulkSearchDefaults, + BulkSearchInput, + BulkSearchResponse, + BulkSearchResultItem, + BulkSemanticSearchDefaults, + BulkSemanticSearchInput, + BulkSemanticSearchResponse, + BulkSemanticSearchResultItem, + QueryEnhancement, SearchFacet, SearchFacets, SearchMetadata, @@ -84,6 +93,16 @@ "RelationshipSummary", "RelationshipType", "ResponseMeta", + # Bulk Search + "BulkSearchDefaults", + "BulkSearchInput", + "BulkSearchResponse", + "BulkSearchResultItem", + "BulkSemanticSearchDefaults", + "BulkSemanticSearchInput", + "BulkSemanticSearchResponse", + "BulkSemanticSearchResultItem", + "QueryEnhancement", # Search "SearchFacet", "SearchFacets", diff --git a/src/omophub/types/search.py b/src/omophub/types/search.py index 5898683..d761b08 100644 --- a/src/omophub/types/search.py +++ b/src/omophub/types/search.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, TypedDict -from typing_extensions import NotRequired +from typing_extensions import NotRequired, Required if TYPE_CHECKING: from .concept import Concept @@ -77,6 +77,112 @@ class SimilarSearchResult(TypedDict): search_metadata: SimilarSearchMetadata +# --------------------------------------------------------------------------- +# Bulk search types +# --------------------------------------------------------------------------- + + +class BulkSearchInput(TypedDict, total=False): + """Input for a single query in a bulk lexical search.""" + + search_id: Required[str] + query: Required[str] + vocabulary_ids: list[str] + domain_ids: list[str] + concept_class_ids: list[str] + standard_concept: str + include_invalid: bool + page_size: int + + +class BulkSearchDefaults(TypedDict, total=False): + """Default filters applied to all searches in a bulk lexical request.""" + + vocabulary_ids: list[str] + domain_ids: list[str] + concept_class_ids: list[str] + standard_concept: str + include_invalid: bool + page_size: int + + +class BulkSearchResultItem(TypedDict): + """Result for a single query in a bulk lexical search.""" + + search_id: str + query: str + results: list[dict[str, Any]] + status: str # "completed" | "failed" + error: NotRequired[str] + duration: NotRequired[int] + + +class BulkSearchResponse(TypedDict): + """Response from bulk lexical search.""" + + results: list[BulkSearchResultItem] + total_searches: int + completed_searches: int + failed_searches: int + + +class BulkSemanticSearchInput(TypedDict, total=False): + """Input for a single query in a bulk semantic search.""" + + search_id: Required[str] + query: Required[str] # 1-500 characters + page_size: int + threshold: float + vocabulary_ids: list[str] + domain_ids: list[str] + standard_concept: str + concept_class_id: str + + +class BulkSemanticSearchDefaults(TypedDict, total=False): + """Default filters applied to all searches in a bulk semantic request.""" + + page_size: int + threshold: float + vocabulary_ids: list[str] + domain_ids: list[str] + standard_concept: str + concept_class_id: str + + +class QueryEnhancement(TypedDict, total=False): + """Query enhancement info from semantic search.""" + + original_query: str + enhanced_query: str + abbreviations_expanded: list[str] + misspellings_corrected: list[str] + + +class BulkSemanticSearchResultItem(TypedDict): + """Result for a single query in a bulk semantic search.""" + + search_id: str + query: str + results: list[dict[str, Any]] + status: str # "completed" | "failed" + error: NotRequired[str] + similarity_threshold: NotRequired[float] + result_count: NotRequired[int] + duration: NotRequired[int] + query_enhancement: NotRequired[QueryEnhancement] + + +class BulkSemanticSearchResponse(TypedDict): + """Response from bulk semantic search.""" + + results: list[BulkSemanticSearchResultItem] + total_searches: int + completed_count: int + failed_count: int + total_duration: NotRequired[int] + + class SearchFacet(TypedDict): """Search facet with count.""" diff --git a/tests/integration/test_search.py b/tests/integration/test_search.py index ad9e527..3b5edb7 100644 --- a/tests/integration/test_search.py +++ b/tests/integration/test_search.py @@ -298,3 +298,95 @@ def test_similar_with_vocabulary_filter(self, integration_client: OMOPHub) -> No # If results, all should be from SNOMED for concept in similar: assert concept.get("vocabulary_id") == "SNOMED" + + +@pytest.mark.integration +class TestBulkBasicSearchIntegration: + """Integration tests for bulk lexical search.""" + + def test_bulk_basic_multiple_queries(self, integration_client: OMOPHub) -> None: + """Test bulk basic search with multiple queries.""" + result = integration_client.search.bulk_basic([ + {"search_id": "q1", "query": "diabetes mellitus"}, + {"search_id": "q2", "query": "hypertension"}, + {"search_id": "q3", "query": "aspirin"}, + ], defaults={"page_size": 5}) + + results = extract_data(result, "results") + assert len(results) == 3 + + # Verify each search has results + for item in results: + assert item["search_id"] in ("q1", "q2", "q3") + assert item["status"] == "completed" + assert len(item["results"]) > 0 + + def test_bulk_basic_with_vocabulary_filter(self, integration_client: OMOPHub) -> None: + """Test bulk basic search with shared vocabulary filter.""" + result = integration_client.search.bulk_basic([ + {"search_id": "snomed1", "query": "diabetes"}, + {"search_id": "snomed2", "query": "myocardial infarction"}, + ], defaults={"vocabulary_ids": ["SNOMED"], "page_size": 3}) + + results = extract_data(result, "results") + for item in results: + assert item["status"] == "completed" + # Verify SNOMED filter applied + for concept in item["results"]: + assert concept.get("vocabulary_id") == "SNOMED" + + def test_bulk_basic_single_query(self, integration_client: OMOPHub) -> None: + """Test bulk basic search with a single query.""" + result = integration_client.search.bulk_basic([ + {"search_id": "single", "query": "metformin", "page_size": 3}, + ]) + + results = extract_data(result, "results") + assert len(results) == 1 + assert results[0]["search_id"] == "single" + assert results[0]["status"] == "completed" + + +@pytest.mark.integration +class TestBulkSemanticSearchIntegration: + """Integration tests for bulk semantic search.""" + + def test_bulk_semantic_multiple_queries(self, integration_client: OMOPHub) -> None: + """Test bulk semantic search with multiple natural-language queries.""" + result = integration_client.search.bulk_semantic([ + {"search_id": "s1", "query": "heart failure treatment options"}, + {"search_id": "s2", "query": "type 2 diabetes medication"}, + ], defaults={"threshold": 0.5, "page_size": 5}) + + results = extract_data(result, "results") + assert len(results) == 2 + + for item in results: + assert item["search_id"] in ("s1", "s2") + assert item["status"] == "completed" + + def test_bulk_semantic_with_filters(self, integration_client: OMOPHub) -> None: + """Test bulk semantic search with vocabulary and domain filters.""" + result = integration_client.search.bulk_semantic([ + { + "search_id": "filtered", + "query": "pain relief medication", + "vocabulary_ids": ["SNOMED"], + "page_size": 3, + "threshold": 0.5, + }, + ]) + + results = extract_data(result, "results") + assert len(results) == 1 + assert results[0]["status"] == "completed" + + def test_bulk_semantic_single_query(self, integration_client: OMOPHub) -> None: + """Test bulk semantic search with a single query.""" + result = integration_client.search.bulk_semantic([ + {"search_id": "one", "query": "elevated blood pressure", "threshold": 0.5}, + ]) + + results = extract_data(result, "results") + assert len(results) == 1 + assert results[0]["search_id"] == "one" diff --git a/tests/unit/resources/test_search.py b/tests/unit/resources/test_search.py index a355ae0..2b8348f 100644 --- a/tests/unit/resources/test_search.py +++ b/tests/unit/resources/test_search.py @@ -850,3 +850,139 @@ async def test_async_similar_with_all_options( assert body["include_invalid"] is True assert body["include_scores"] is True assert body["include_explanations"] is True + + +class TestBulkBasicSearch: + """Tests for bulk lexical search.""" + + @respx.mock + def test_bulk_basic(self, sync_client: OMOPHub, base_url: str) -> None: + """Test bulk basic search with multiple queries.""" + mock_response = { + "success": True, + "data": { + "results": [ + { + "search_id": "q1", + "query": "diabetes", + "results": [{"concept_id": 201826, "concept_name": "Type 2 diabetes"}], + "status": "completed", + "duration": 15, + }, + { + "search_id": "q2", + "query": "hypertension", + "results": [{"concept_id": 316866, "concept_name": "Hypertensive disorder"}], + "status": "completed", + "duration": 12, + }, + ], + "total_searches": 2, + "completed_searches": 2, + "failed_searches": 0, + }, + } + respx.post(f"{base_url}/search/bulk").mock( + return_value=Response(200, json=mock_response) + ) + + result = sync_client.search.bulk_basic([ + {"search_id": "q1", "query": "diabetes"}, + {"search_id": "q2", "query": "hypertension"}, + ]) + + assert len(result["results"]) == 2 + assert result["total_searches"] == 2 + assert result["completed_searches"] == 2 + assert result["results"][0]["search_id"] == "q1" + + @respx.mock + def test_bulk_basic_with_defaults(self, sync_client: OMOPHub, base_url: str) -> None: + """Test bulk basic search with shared defaults.""" + import json + + respx.post(f"{base_url}/search/bulk").mock( + return_value=Response(200, json={ + "success": True, + "data": { + "results": [], + "total_searches": 1, + "completed_searches": 1, + "failed_searches": 0, + }, + }) + ) + + sync_client.search.bulk_basic( + [{"search_id": "q1", "query": "diabetes"}], + defaults={"vocabulary_ids": ["SNOMED"], "page_size": 5}, + ) + + request_body = json.loads(respx.calls[0].request.content) + assert request_body["defaults"]["vocabulary_ids"] == ["SNOMED"] + assert request_body["defaults"]["page_size"] == 5 + + +class TestBulkSemanticSearch: + """Tests for bulk semantic search.""" + + @respx.mock + def test_bulk_semantic(self, sync_client: OMOPHub, base_url: str) -> None: + """Test bulk semantic search with multiple queries.""" + mock_response = { + "success": True, + "data": { + "results": [ + { + "search_id": "s1", + "query": "heart failure treatment", + "results": [{"concept_id": 316139, "similarity_score": 0.92}], + "status": "completed", + "result_count": 1, + "duration": 45, + }, + ], + "total_searches": 1, + "completed_count": 1, + "failed_count": 0, + "total_duration": 45, + }, + } + respx.post(f"{base_url}/search/semantic-bulk").mock( + return_value=Response(200, json=mock_response) + ) + + result = sync_client.search.bulk_semantic([ + {"search_id": "s1", "query": "heart failure treatment"}, + ]) + + assert len(result["results"]) == 1 + assert result["completed_count"] == 1 + assert result["results"][0]["status"] == "completed" + + @respx.mock + def test_bulk_semantic_with_defaults(self, sync_client: OMOPHub, base_url: str) -> None: + """Test bulk semantic search with shared defaults.""" + import json + + respx.post(f"{base_url}/search/semantic-bulk").mock( + return_value=Response(200, json={ + "success": True, + "data": { + "results": [], + "total_searches": 1, + "completed_count": 1, + "failed_count": 0, + }, + }) + ) + + sync_client.search.bulk_semantic( + [{"search_id": "s1", "query": "diabetes medications"}], + defaults={"threshold": 0.8, "page_size": 10, "vocabulary_ids": ["SNOMED"]}, + ) + + request_body = json.loads(respx.calls[0].request.content) + assert request_body["defaults"]["threshold"] == 0.8 + assert request_body["defaults"]["page_size"] == 10 + assert request_body["defaults"]["vocabulary_ids"] == ["SNOMED"] From 9233f8a364f3ed1ca2abcfb078c576957c5f594a Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Thu, 26 Mar 2026 17:51:50 +0000 Subject: [PATCH 37/48] Update type definitions to include bulk search and semantic search types - Added new types for bulk search and semantic search functionalities to the `__all__` exports in `__init__.py`. - Removed previously commented sections for clarity and organization. --- src/omophub/types/__init__.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/omophub/types/__init__.py b/src/omophub/types/__init__.py index 20f6c50..1ffe8ed 100644 --- a/src/omophub/types/__init__.py +++ b/src/omophub/types/__init__.py @@ -62,16 +62,20 @@ ) __all__ = [ - # Common "APIResponse", - # Hierarchy "Ancestor", "BatchConceptResult", - # Concept + "BulkSearchDefaults", + "BulkSearchInput", + "BulkSearchResponse", + "BulkSearchResultItem", + "BulkSemanticSearchDefaults", + "BulkSemanticSearchInput", + "BulkSemanticSearchResponse", + "BulkSemanticSearchResultItem", "Concept", "ConceptSummary", "Descendant", - # Domain "Domain", "DomainCategory", "DomainStats", @@ -80,30 +84,18 @@ "ErrorResponse", "HierarchyPath", "HierarchySummary", - # Mapping "Mapping", "MappingContext", "MappingQuality", "MappingSummary", "PaginationMeta", "PaginationParams", + "QueryEnhancement", "RelatedConcept", - # Relationship "Relationship", "RelationshipSummary", "RelationshipType", "ResponseMeta", - # Bulk Search - "BulkSearchDefaults", - "BulkSearchInput", - "BulkSearchResponse", - "BulkSearchResultItem", - "BulkSemanticSearchDefaults", - "BulkSemanticSearchInput", - "BulkSemanticSearchResponse", - "BulkSemanticSearchResultItem", - "QueryEnhancement", - # Search "SearchFacet", "SearchFacets", "SearchMetadata", @@ -115,7 +107,6 @@ "SimilarSearchResult", "Suggestion", "Synonym", - # Vocabulary "Vocabulary", "VocabularyDomain", "VocabularyStats", From 5a7344705422dd1e352e11eaf051ad503a810006 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Thu, 26 Mar 2026 17:56:43 +0000 Subject: [PATCH 38/48] Enhance integration tests for bulk search functionality - Updated assertions in `test_bulk_basic_search` to verify that all expected search IDs are present in the results. - Added a check in `test_bulk_semantic_search` to confirm that the SNOMED vocabulary filter is applied to the results. --- tests/integration/test_search.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_search.py b/tests/integration/test_search.py index 3b5edb7..df4b311 100644 --- a/tests/integration/test_search.py +++ b/tests/integration/test_search.py @@ -315,9 +315,10 @@ def test_bulk_basic_multiple_queries(self, integration_client: OMOPHub) -> None: results = extract_data(result, "results") assert len(results) == 3 - # Verify each search has results + # Verify all 3 search IDs are present with results + returned_ids = {item["search_id"] for item in results} + assert returned_ids == {"q1", "q2", "q3"} for item in results: - assert item["search_id"] in ("q1", "q2", "q3") assert item["status"] == "completed" assert len(item["results"]) > 0 @@ -381,6 +382,10 @@ def test_bulk_semantic_with_filters(self, integration_client: OMOPHub) -> None: assert len(results) == 1 assert results[0]["status"] == "completed" + # Verify SNOMED vocabulary filter was applied + for concept in results[0]["results"]: + assert concept.get("vocabulary_id") == "SNOMED" + def test_bulk_semantic_single_query(self, integration_client: OMOPHub) -> None: """Test bulk semantic search with a single query.""" result = integration_client.search.bulk_semantic([ From eee0a13a9e0cd68b7bfd5a8abea60b6d1f1a5dae Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Thu, 26 Mar 2026 18:07:33 +0000 Subject: [PATCH 39/48] Prep for v1.5.0 release --- CHANGELOG.md | 14 ++++++++++++++ tests/conftest.py | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff399f..aec1e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.0] - 2026-03-26 + +### Added + +- **Bulk lexical search** (`search.bulk_basic()`): Execute up to 50 keyword searches in a single API call. Supports shared defaults for vocabulary, domain, and other filters. Each search is identified by a unique `search_id` for result matching. Maps to `POST /v1/search/bulk`. +- **Bulk semantic search** (`search.bulk_semantic()`): Execute up to 25 natural-language searches using neural embeddings in a single call. Supports per-search similarity thresholds and shared defaults. Includes query enhancement data (abbreviation expansion, misspelling correction). Maps to `POST /v1/search/semantic-bulk`. +- New TypedDict types for bulk search: `BulkSearchInput`, `BulkSearchDefaults`, `BulkSearchResponse`, `BulkSearchResultItem`, `BulkSemanticSearchInput`, `BulkSemanticSearchDefaults`, `BulkSemanticSearchResponse`, `BulkSemanticSearchResultItem`, `QueryEnhancement`. +- Both sync (`OMOPHub`) and async (`AsyncOMOPHub`) clients support bulk search methods. + +### Changed + +- Updated `__all__` exports to alphabetical order (ruff RUF022 compliance). +- `BulkSearchInput` and `BulkSemanticSearchInput` now use `Required[str]` for `search_id` and `query` fields for proper type checking. + ## [1.4.1] - 2026-02-28 ### Fixed diff --git a/tests/conftest.py b/tests/conftest.py index 6c3287f..a48935c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -142,7 +142,12 @@ def rate_limit_delay(request: pytest.FixtureRequest) -> None: yield # Only delay for integration tests if "integration" in request.keywords: - time.sleep(2) + # Bulk endpoints consume more rate limit budget + test_name = request.node.name + if "bulk" in test_name: + time.sleep(5) + else: + time.sleep(2) # Well-known test concept IDs for integration tests From 4b0f544cae1bb95791f720c7f12684dc36cf0c13 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Thu, 26 Mar 2026 19:15:46 +0000 Subject: [PATCH 40/48] Examples update --- examples/search_concepts.py | 71 +++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/examples/search_concepts.py b/examples/search_concepts.py index a3d8bec..9c091d7 100644 --- a/examples/search_concepts.py +++ b/examples/search_concepts.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 -"""Examples of searching for concepts using the OMOPHub SDK.""" +"""Examples of searching for concepts using the OMOPHub SDK. + +Demonstrates: basic search, filtered search, autocomplete, pagination, +semantic search, similarity search, bulk lexical search, and bulk semantic search. +""" import omophub @@ -38,17 +42,61 @@ def filtered_search() -> None: print(f" [{c['vocabulary_id']}] {c['concept_name']}") -def fuzzy_search() -> None: - """Demonstrate typo-tolerant fuzzy search.""" - print("\n=== Fuzzy Search ===") +def bulk_lexical_search() -> None: + """Demonstrate bulk lexical search — multiple queries in one call.""" + print("\n=== Bulk Lexical Search ===") - # Fuzzy search handles typos - results = client.search.fuzzy("diabetis mellitus") # Typo in 'diabetes' - concepts = results.get("concepts", results) - print("Fuzzy search for 'diabetis mellitus' (typo):") + # Search for multiple terms at once (up to 50) + results = client.search.bulk_basic( + [ + {"search_id": "q1", "query": "diabetes mellitus"}, + {"search_id": "q2", "query": "hypertension"}, + {"search_id": "q3", "query": "aspirin"}, + ], + defaults={"vocabulary_ids": ["SNOMED"], "page_size": 5}, + ) - for c in concepts[:3]: - print(f" {c['concept_name']}") + for item in results["results"]: + print(f" {item['search_id']}: {len(item['results'])} results ({item['status']})") + + # Per-query overrides — different domains per query + results = client.search.bulk_basic( + [ + {"search_id": "conditions", "query": "diabetes", "domain_ids": ["Condition"]}, + {"search_id": "drugs", "query": "metformin", "domain_ids": ["Drug"]}, + ], + defaults={"vocabulary_ids": ["SNOMED", "RxNorm"], "page_size": 3}, + ) + + print("\n Per-query domain overrides:") + for item in results["results"]: + print(f" {item['search_id']}:") + for c in item["results"]: + print(f" {c['concept_name']} ({c['vocabulary_id']}/{c['domain_id']})") + + +def bulk_semantic_search() -> None: + """Demonstrate bulk semantic search — multiple NLP queries in one call.""" + print("\n=== Bulk Semantic Search ===") + + # Search for multiple natural-language queries (up to 25) + results = client.search.bulk_semantic( + [ + {"search_id": "s1", "query": "heart failure treatment options"}, + {"search_id": "s2", "query": "type 2 diabetes medication"}, + {"search_id": "s3", "query": "elevated blood pressure"}, + ], + defaults={"threshold": 0.5, "page_size": 5}, + ) + + for item in results["results"]: + count = item.get("result_count", len(item["results"])) + print(f" {item['search_id']}: {count} results ({item['status']})") + + # Show top result per query + if item["results"]: + top = item["results"][0] + print(f" Top: {top['concept_name']} (score: {top['similarity_score']:.2f})") def autocomplete_example() -> None: @@ -139,9 +187,10 @@ def similarity_search() -> None: if __name__ == "__main__": basic_search() filtered_search() - fuzzy_search() autocomplete_example() pagination_example() semantic_search() semantic_pagination() similarity_search() + bulk_lexical_search() + bulk_semantic_search() From e3561ea79ea70e2125d52052021ff582a9096dc4 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Fri, 27 Mar 2026 20:26:02 +0000 Subject: [PATCH 41/48] Update GitHub Actions workflows to use latest action versions - Upgraded `actions/checkout` from v4 to v6 in both `ci.yml` and `publish.yml`. - Updated `codecov/codecov-action` from v4 to v5 in `ci.yml`. - Changed `actions/upload-artifact` and `actions/download-artifact` from v4 to v5 in `publish.yml`. --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/publish.yml | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b817a67..a1e92dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -33,7 +33,7 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -46,7 +46,7 @@ jobs: pytest tests/unit --cov=omophub --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov if: matrix.python-version == '3.12' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: ./coverage.xml fail_ci_if_error: false @@ -57,7 +57,7 @@ jobs: if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: python-version: "3.12" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1d37f44..37cf58e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # Required for hatch-vcs to get version from tags - uses: actions/setup-python@v5 @@ -24,7 +24,7 @@ jobs: - name: Build package run: python -m build - name: Upload distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-dists path: dist/ @@ -39,7 +39,7 @@ jobs: id-token: write # Required for trusted publishing steps: - name: Download distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: release-dists path: dist/ From 214f1b566bbb6453e039b19e01ab78c3a5194816 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Mon, 30 Mar 2026 21:39:28 +0100 Subject: [PATCH 42/48] Add retry logic with exponential backoff and jitter for rate limits and server errors (#6) - Introduced a new `_calculate_retry_delay` function to handle retry delays based on the Retry-After header and exponential backoff with jitter. - Updated `SyncHTTPClient` and `AsyncHTTPClientImpl` to utilize the new retry delay calculation for handling rate limits (429) and server errors (502, 503, 504). - Enhanced retry mechanism to improve resilience against temporary issues. Co-authored-by: alex-omophub --- src/omophub/_http.py | 62 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/src/omophub/_http.py b/src/omophub/_http.py index 04cee9e..31384c2 100644 --- a/src/omophub/_http.py +++ b/src/omophub/_http.py @@ -2,6 +2,7 @@ from __future__ import annotations +import random import time from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any @@ -12,6 +13,12 @@ from ._exceptions import ConnectionError, TimeoutError from ._version import get_version +# Retry constants (OpenAI-style exponential backoff with jitter) +INITIAL_RETRY_DELAY = 0.5 # seconds +MAX_RETRY_DELAY = 8.0 # seconds +MAX_RETRY_AFTER = 60 # max seconds to respect from Retry-After header +RETRYABLE_STATUS_CODES = (429, 502, 503, 504) + if TYPE_CHECKING: from collections.abc import Mapping @@ -24,6 +31,37 @@ HTTP2_AVAILABLE = False +def _calculate_retry_delay( + attempt: int, + max_retries: int, + response_headers: Mapping[str, str] | None = None, +) -> float: + """Calculate retry delay with Retry-After support and exponential backoff + jitter. + + Follows the OpenAI pattern: + 1. If Retry-After header present and <= 60s, use it + 2. Otherwise, exponential backoff (0.5s * 2^attempt) with 25% jitter, capped at 8s + """ + # Check Retry-After header first + if response_headers: + retry_after = response_headers.get("retry-after") or response_headers.get( + "Retry-After" + ) + if retry_after: + try: + retry_after_seconds = float(retry_after) + if 0 < retry_after_seconds <= MAX_RETRY_AFTER: + return retry_after_seconds + except ValueError: + pass + + # Exponential backoff with jitter + retries_done = min(max_retries - (max_retries - attempt), 1000) + sleep_seconds = min(INITIAL_RETRY_DELAY * (2.0**retries_done), MAX_RETRY_DELAY) + jitter = 1 - 0.25 * random.random() + return sleep_seconds * jitter + + class HTTPClient(ABC): """Abstract base class for HTTP clients.""" @@ -137,12 +175,15 @@ def request( params=filtered_params if filtered_params else None, json=json, ) - # Retry on server errors (502, 503, 504) + # Retry on rate limits (429) and server errors (502, 503, 504) if ( - response.status_code in (502, 503, 504) + response.status_code in RETRYABLE_STATUS_CODES and attempt < self._max_retries ): - time.sleep(2**attempt * 0.5) + delay = _calculate_retry_delay( + attempt, self._max_retries, response.headers + ) + time.sleep(delay) continue return response.content, response.status_code, response.headers @@ -155,7 +196,8 @@ def request( # Exponential backoff before retry if attempt < self._max_retries: - time.sleep(2**attempt * 0.1) + delay = _calculate_retry_delay(attempt, self._max_retries) + time.sleep(delay) raise last_exception or ConnectionError("Request failed after retries") @@ -229,12 +271,15 @@ async def request( params=filtered_params if filtered_params else None, json=json, ) - # Retry on server errors (502, 503, 504) + # Retry on rate limits (429) and server errors (502, 503, 504) if ( - response.status_code in (502, 503, 504) + response.status_code in RETRYABLE_STATUS_CODES and attempt < self._max_retries ): - await asyncio.sleep(2**attempt * 0.5) + delay = _calculate_retry_delay( + attempt, self._max_retries, response.headers + ) + await asyncio.sleep(delay) continue return response.content, response.status_code, response.headers @@ -247,7 +292,8 @@ async def request( # Exponential backoff before retry if attempt < self._max_retries: - await asyncio.sleep(2**attempt * 0.1) + delay = _calculate_retry_delay(attempt, self._max_retries) + await asyncio.sleep(delay) raise last_exception or ConnectionError("Request failed after retries") From 1915148637aff9e915724d4abf6dba3acf4c6ef2 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Wed, 8 Apr 2026 18:58:09 +0100 Subject: [PATCH 43/48] Update CHANGELOG for v1.5.1 release --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aec1e14..3e4a534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.1] - 2026-04-08 + +### Fixed + +- **Rate-limit handling**: HTTP client now respects the `Retry-After` header on `429 Too Many Requests` responses and applies exponential backoff with jitter on retries. Previous versions retried only on `502/503/504` with a fixed `2^attempt * 0.5s` schedule and did not back off on `429` at all, so a client that hit the server's rate limit at high volume could burn through thousands of failed requests in a tight loop. The new behavior: +- Updated `examples/search_concepts.py` to reflect current API. + ## [1.5.0] - 2026-03-26 ### Added From 140c7ca913a3212de64370d33be5112dec157be0 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Fri, 10 Apr 2026 20:01:28 +0100 Subject: [PATCH 44/48] Enhance HTTP client with FHIR resource support and improve rate-limit handling - Added FHIR resource access to both synchronous and asynchronous OMOPHub clients. - Updated CHANGELOG to reflect changes in rate-limit handling, including honoring the `Retry-After` header and implementing exponential backoff with jitter for retries. - Included new FHIR-related types in the type definitions for better integration. --- CHANGELOG.md | 2 +- src/omophub/_client.py | 17 ++ src/omophub/resources/fhir.py | 272 ++++++++++++++++++ src/omophub/types/__init__.py | 16 ++ src/omophub/types/fhir.py | 80 ++++++ tests/integration/test_fhir.py | 84 ++++++ tests/unit/resources/test_fhir.py | 441 ++++++++++++++++++++++++++++++ 7 files changed, 911 insertions(+), 1 deletion(-) create mode 100644 src/omophub/resources/fhir.py create mode 100644 src/omophub/types/fhir.py create mode 100644 tests/integration/test_fhir.py create mode 100644 tests/unit/resources/test_fhir.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4a534..557074c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Rate-limit handling**: HTTP client now respects the `Retry-After` header on `429 Too Many Requests` responses and applies exponential backoff with jitter on retries. Previous versions retried only on `502/503/504` with a fixed `2^attempt * 0.5s` schedule and did not back off on `429` at all, so a client that hit the server's rate limit at high volume could burn through thousands of failed requests in a tight loop. The new behavior: +- **Rate-limit handling**: HTTP client now respects the `Retry-After` header on `429 Too Many Requests` responses and applies exponential backoff with jitter on retries. Previous versions retried only on `502/503/504` with a fixed `2^attempt * 0.5s` schedule and did not back off on `429` at all, so a client that hit the server's rate limit at high volume could burn through thousands of failed requests in a tight loop. The client now honors `Retry-After`, uses exponential backoff with jitter, respects the configured `max_retries`, and caps backoff at 30 seconds. - Updated `examples/search_concepts.py` to reflect current API. ## [1.5.0] - 2026-03-26 diff --git a/src/omophub/_client.py b/src/omophub/_client.py index b9e1d0f..338e857 100644 --- a/src/omophub/_client.py +++ b/src/omophub/_client.py @@ -17,6 +17,7 @@ from ._request import AsyncRequest, Request from .resources.concepts import AsyncConcepts, Concepts from .resources.domains import AsyncDomains, Domains +from .resources.fhir import AsyncFhir, Fhir from .resources.hierarchy import AsyncHierarchy, Hierarchy from .resources.mappings import AsyncMappings, Mappings from .resources.relationships import AsyncRelationships, Relationships @@ -97,6 +98,14 @@ def __init__( self._mappings: Mappings | None = None self._vocabularies: Vocabularies | None = None self._domains: Domains | None = None + self._fhir: Fhir | None = None + + @property + def fhir(self) -> Fhir: + """Access the FHIR resolver resource.""" + if self._fhir is None: + self._fhir = Fhir(self._request) + return self._fhir @property def concepts(self) -> Concepts: @@ -228,6 +237,14 @@ def __init__( self._mappings: AsyncMappings | None = None self._vocabularies: AsyncVocabularies | None = None self._domains: AsyncDomains | None = None + self._fhir: AsyncFhir | None = None + + @property + def fhir(self) -> AsyncFhir: + """Access the FHIR resolver resource.""" + if self._fhir is None: + self._fhir = AsyncFhir(self._request) + return self._fhir @property def concepts(self) -> AsyncConcepts: diff --git a/src/omophub/resources/fhir.py b/src/omophub/resources/fhir.py new file mode 100644 index 0000000..5f8560e --- /dev/null +++ b/src/omophub/resources/fhir.py @@ -0,0 +1,272 @@ +"""FHIR Resolver resource implementation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .._request import AsyncRequest, Request + from ..types.fhir import ( + FhirBatchResult, + FhirCodeableConceptResult, + FhirResolveResult, + ) + + +def _build_resolve_body( + *, + system: str | None = None, + code: str | None = None, + display: str | None = None, + vocabulary_id: str | None = None, + resource_type: str | None = None, + include_recommendations: bool = False, + recommendations_limit: int = 5, + include_quality: bool = False, +) -> dict[str, Any]: + body: dict[str, Any] = {} + if system is not None: + body["system"] = system + if code is not None: + body["code"] = code + if display is not None: + body["display"] = display + if vocabulary_id is not None: + body["vocabulary_id"] = vocabulary_id + if resource_type is not None: + body["resource_type"] = resource_type + if include_recommendations: + body["include_recommendations"] = True + body["recommendations_limit"] = recommendations_limit + if include_quality: + body["include_quality"] = True + return body + + +class Fhir: + """Synchronous FHIR resolver resource. + + Provides access to the FHIR-to-OMOP Concept Resolver endpoints that + translate FHIR coded values into OMOP standard concepts, CDM target + tables, and optional Phoebe recommendations. + + Example: + >>> result = client.fhir.resolve( + ... system="http://snomed.info/sct", + ... code="44054006", + ... resource_type="Condition", + ... ) + >>> print(result["resolution"]["target_table"]) + "condition_occurrence" + """ + + def __init__(self, request: Request[Any]) -> None: + self._request = request + + def resolve( + self, + *, + system: str | None = None, + code: str | None = None, + display: str | None = None, + vocabulary_id: str | None = None, + resource_type: str | None = None, + include_recommendations: bool = False, + recommendations_limit: int = 5, + include_quality: bool = False, + ) -> FhirResolveResult: + """Resolve a single FHIR Coding to an OMOP standard concept. + + Provide at least one of (``system`` + ``code``), + (``vocabulary_id`` + ``code``), or ``display``. + + Args: + system: FHIR code system URI (e.g. ``http://snomed.info/sct``) + code: Code value from the FHIR Coding + display: Human-readable text (semantic search fallback) + vocabulary_id: Direct OMOP vocabulary_id, bypasses URI resolution + resource_type: FHIR resource type for domain alignment check + include_recommendations: Include Phoebe recommendations + recommendations_limit: Max recommendations to return (1-20) + include_quality: Include mapping quality signal + + Returns: + Resolution result with source concept, standard concept, + target CDM table, and optional enrichments. + """ + body = _build_resolve_body( + system=system, + code=code, + display=display, + vocabulary_id=vocabulary_id, + resource_type=resource_type, + include_recommendations=include_recommendations, + recommendations_limit=recommendations_limit, + include_quality=include_quality, + ) + return self._request.post("/fhir/resolve", json_data=body) + + def resolve_batch( + self, + codings: list[dict[str, str | None]], + *, + resource_type: str | None = None, + include_recommendations: bool = False, + recommendations_limit: int = 5, + include_quality: bool = False, + ) -> FhirBatchResult: + """Batch-resolve up to 100 FHIR Codings. + + Failed items are reported inline without failing the batch. + + Args: + codings: List of coding dicts, each with optional keys + ``system``, ``code``, ``display``, ``vocabulary_id``. + resource_type: FHIR resource type applied to all codings + include_recommendations: Include Phoebe recommendations + recommendations_limit: Max recommendations per item (1-20) + include_quality: Include mapping quality signal + + Returns: + Batch result with per-item results and a summary. + """ + body: dict[str, Any] = {"codings": codings} + if resource_type is not None: + body["resource_type"] = resource_type + if include_recommendations: + body["include_recommendations"] = True + body["recommendations_limit"] = recommendations_limit + if include_quality: + body["include_quality"] = True + return self._request.post("/fhir/resolve/batch", json_data=body) + + def resolve_codeable_concept( + self, + coding: list[dict[str, str]], + *, + text: str | None = None, + resource_type: str | None = None, + include_recommendations: bool = False, + recommendations_limit: int = 5, + include_quality: bool = False, + ) -> FhirCodeableConceptResult: + """Resolve a FHIR CodeableConcept with vocabulary preference. + + Picks the best match across multiple codings using the OHDSI + vocabulary preference order (SNOMED > RxNorm > LOINC > CVX > + ICD-10). Falls back to ``text`` via semantic search if no + coding resolves. + + Args: + coding: List of structured codings, each with ``system``, + ``code``, and optional ``display``. + text: CodeableConcept.text for semantic search fallback + resource_type: FHIR resource type for domain alignment + include_recommendations: Include Phoebe recommendations + recommendations_limit: Max recommendations (1-20) + include_quality: Include mapping quality signal + + Returns: + Result with ``best_match``, ``alternatives``, and + ``unresolved`` lists. + """ + body: dict[str, Any] = {"coding": coding} + if text is not None: + body["text"] = text + if resource_type is not None: + body["resource_type"] = resource_type + if include_recommendations: + body["include_recommendations"] = True + body["recommendations_limit"] = recommendations_limit + if include_quality: + body["include_quality"] = True + return self._request.post("/fhir/resolve/codeable-concept", json_data=body) + + +class AsyncFhir: + """Asynchronous FHIR resolver resource. + + Async counterpart of :class:`Fhir`. All methods are coroutines. + """ + + def __init__(self, request: AsyncRequest[Any]) -> None: + self._request = request + + async def resolve( + self, + *, + system: str | None = None, + code: str | None = None, + display: str | None = None, + vocabulary_id: str | None = None, + resource_type: str | None = None, + include_recommendations: bool = False, + recommendations_limit: int = 5, + include_quality: bool = False, + ) -> FhirResolveResult: + """Resolve a single FHIR Coding to an OMOP standard concept. + + See :meth:`Fhir.resolve` for full documentation. + """ + body = _build_resolve_body( + system=system, + code=code, + display=display, + vocabulary_id=vocabulary_id, + resource_type=resource_type, + include_recommendations=include_recommendations, + recommendations_limit=recommendations_limit, + include_quality=include_quality, + ) + return await self._request.post("/fhir/resolve", json_data=body) + + async def resolve_batch( + self, + codings: list[dict[str, str | None]], + *, + resource_type: str | None = None, + include_recommendations: bool = False, + recommendations_limit: int = 5, + include_quality: bool = False, + ) -> FhirBatchResult: + """Batch-resolve up to 100 FHIR Codings. + + See :meth:`Fhir.resolve_batch` for full documentation. + """ + body: dict[str, Any] = {"codings": codings} + if resource_type is not None: + body["resource_type"] = resource_type + if include_recommendations: + body["include_recommendations"] = True + body["recommendations_limit"] = recommendations_limit + if include_quality: + body["include_quality"] = True + return await self._request.post("/fhir/resolve/batch", json_data=body) + + async def resolve_codeable_concept( + self, + coding: list[dict[str, str]], + *, + text: str | None = None, + resource_type: str | None = None, + include_recommendations: bool = False, + recommendations_limit: int = 5, + include_quality: bool = False, + ) -> FhirCodeableConceptResult: + """Resolve a FHIR CodeableConcept with vocabulary preference. + + See :meth:`Fhir.resolve_codeable_concept` for full documentation. + """ + body: dict[str, Any] = {"coding": coding} + if text is not None: + body["text"] = text + if resource_type is not None: + body["resource_type"] = resource_type + if include_recommendations: + body["include_recommendations"] = True + body["recommendations_limit"] = recommendations_limit + if include_quality: + body["include_quality"] = True + return await self._request.post( + "/fhir/resolve/codeable-concept", json_data=body + ) diff --git a/src/omophub/types/__init__.py b/src/omophub/types/__init__.py index 1ffe8ed..715ccdf 100644 --- a/src/omophub/types/__init__.py +++ b/src/omophub/types/__init__.py @@ -16,6 +16,15 @@ Synonym, ) from .domain import Domain, DomainCategory, DomainStats, DomainSummary +from .fhir import ( + FhirBatchResult, + FhirBatchSummary, + FhirCodeableConceptResult, + FhirResolution, + FhirResolveResult, + RecommendedConceptOutput, + ResolvedConcept, +) from .hierarchy import ( Ancestor, Descendant, @@ -82,6 +91,11 @@ "DomainSummary", "ErrorDetail", "ErrorResponse", + "FhirBatchResult", + "FhirBatchSummary", + "FhirCodeableConceptResult", + "FhirResolution", + "FhirResolveResult", "HierarchyPath", "HierarchySummary", "Mapping", @@ -91,7 +105,9 @@ "PaginationMeta", "PaginationParams", "QueryEnhancement", + "RecommendedConceptOutput", "RelatedConcept", + "ResolvedConcept", "Relationship", "RelationshipSummary", "RelationshipType", diff --git a/src/omophub/types/fhir.py b/src/omophub/types/fhir.py new file mode 100644 index 0000000..26f60c6 --- /dev/null +++ b/src/omophub/types/fhir.py @@ -0,0 +1,80 @@ +"""FHIR Resolver type definitions.""" + +from __future__ import annotations + +from typing import Any, TypedDict + +from typing_extensions import NotRequired + + +class ResolvedConcept(TypedDict): + """Concept shape returned by the FHIR resolver.""" + + concept_id: int + concept_name: str + concept_code: str + vocabulary_id: str + domain_id: str + concept_class_id: str + standard_concept: str | None + + +class RecommendedConceptOutput(TypedDict): + """Phoebe recommendation returned when include_recommendations is true.""" + + concept_id: int + concept_name: str + vocabulary_id: str + domain_id: str + concept_class_id: str + standard_concept: str | None + relationship_id: str + + +class FhirResolution(TypedDict): + """The ``resolution`` block inside a single-resolve response.""" + + vocabulary_id: str | None + source_concept: ResolvedConcept + standard_concept: ResolvedConcept + mapping_type: str + target_table: str | None + domain_resource_alignment: str + relationship_id: NotRequired[str] + similarity_score: NotRequired[float] + alignment_note: NotRequired[str] + mapping_quality: NotRequired[str] + quality_note: NotRequired[str] + alternative_standard_concepts: NotRequired[list[ResolvedConcept]] + recommendations: NotRequired[list[RecommendedConceptOutput]] + + +class FhirResolveResult(TypedDict): + """Response from ``POST /v1/fhir/resolve``.""" + + input: dict[str, Any] + resolution: FhirResolution + + +class FhirBatchSummary(TypedDict): + """Summary block inside a batch-resolve response.""" + + total: int + resolved: int + failed: int + + +class FhirBatchResult(TypedDict): + """Response from ``POST /v1/fhir/resolve/batch``.""" + + results: list[dict[str, Any]] + summary: FhirBatchSummary + + +class FhirCodeableConceptResult(TypedDict): + """Response from ``POST /v1/fhir/resolve/codeable-concept``.""" + + input: dict[str, Any] + best_match: FhirResolveResult | None + alternatives: list[FhirResolveResult] + unresolved: list[dict[str, Any]] diff --git a/tests/integration/test_fhir.py b/tests/integration/test_fhir.py new file mode 100644 index 0000000..100d0e6 --- /dev/null +++ b/tests/integration/test_fhir.py @@ -0,0 +1,84 @@ +"""Integration tests for the FHIR resolver resource.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from omophub import OMOPHub + + +@pytest.mark.integration +class TestFhirIntegration: + """Integration tests for FHIR resolver against the production API.""" + + def test_resolve_snomed_live(self, integration_client: OMOPHub) -> None: + """Resolve SNOMED 44054006 (Type 2 diabetes) to OMOP concept.""" + result = integration_client.fhir.resolve( + system="http://snomed.info/sct", + code="44054006", + resource_type="Condition", + ) + + res = result["resolution"] + assert res["mapping_type"] == "direct" + assert res["target_table"] == "condition_occurrence" + assert res["standard_concept"]["vocabulary_id"] == "SNOMED" + assert res["standard_concept"]["standard_concept"] == "S" + assert res["domain_resource_alignment"] == "aligned" + + def test_resolve_icd10cm_live(self, integration_client: OMOPHub) -> None: + """Resolve ICD-10-CM E11.9 — non-standard, should traverse Maps to.""" + result = integration_client.fhir.resolve( + system="http://hl7.org/fhir/sid/icd-10-cm", + code="E11.9", + ) + + res = result["resolution"] + assert res["vocabulary_id"] == "ICD10CM" + assert res["source_concept"]["vocabulary_id"] == "ICD10CM" + assert res["standard_concept"]["standard_concept"] == "S" + assert res["target_table"] == "condition_occurrence" + + def test_resolve_batch_live(self, integration_client: OMOPHub) -> None: + """Batch resolve 3 mixed codings.""" + result = integration_client.fhir.resolve_batch( + [ + {"system": "http://snomed.info/sct", "code": "44054006"}, + {"system": "http://loinc.org", "code": "2339-0"}, + {"system": "http://www.nlm.nih.gov/research/umls/rxnorm", "code": "197696"}, + ] + ) + + assert result["summary"]["total"] == 3 + assert result["summary"]["resolved"] + result["summary"]["failed"] == 3 + assert len(result["results"]) == 3 + + def test_resolve_codeable_concept_live(self, integration_client: OMOPHub) -> None: + """CodeableConcept: SNOMED should win over ICD-10-CM.""" + result = integration_client.fhir.resolve_codeable_concept( + coding=[ + {"system": "http://snomed.info/sct", "code": "44054006"}, + {"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9"}, + ], + resource_type="Condition", + ) + + assert result["best_match"] is not None + best = result["best_match"]["resolution"] + assert best["source_concept"]["vocabulary_id"] == "SNOMED" + assert best["target_table"] == "condition_occurrence" + + def test_resolve_with_quality_live(self, integration_client: OMOPHub) -> None: + """Mapping quality signal is returned when requested.""" + result = integration_client.fhir.resolve( + system="http://snomed.info/sct", + code="44054006", + include_quality=True, + ) + + res = result["resolution"] + assert "mapping_quality" in res + assert res["mapping_quality"] in ("high", "medium", "low", "manual_review") diff --git a/tests/unit/resources/test_fhir.py b/tests/unit/resources/test_fhir.py new file mode 100644 index 0000000..8649e98 --- /dev/null +++ b/tests/unit/resources/test_fhir.py @@ -0,0 +1,441 @@ +"""Tests for the FHIR resolver resource.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import respx +from httpx import Response + +if TYPE_CHECKING: + from omophub import AsyncOMOPHub, OMOPHub + + +# -- Fixtures ---------------------------------------------------------------- + +SNOMED_RESOLVE_RESPONSE = { + "success": True, + "data": { + "input": { + "system": "http://snomed.info/sct", + "code": "44054006", + "resource_type": "Condition", + }, + "resolution": { + "vocabulary_id": "SNOMED", + "source_concept": { + "concept_id": 201826, + "concept_name": "Type 2 diabetes mellitus", + "concept_code": "44054006", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "concept_class_id": "Clinical Finding", + "standard_concept": "S", + }, + "standard_concept": { + "concept_id": 201826, + "concept_name": "Type 2 diabetes mellitus", + "concept_code": "44054006", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "concept_class_id": "Clinical Finding", + "standard_concept": "S", + }, + "mapping_type": "direct", + "target_table": "condition_occurrence", + "domain_resource_alignment": "aligned", + }, + }, + "meta": {"request_id": "test", "timestamp": "2026-04-10T00:00:00Z", "vocab_release": "2025.2"}, +} + +ICD10_MAPPED_RESPONSE = { + "success": True, + "data": { + "input": {"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9"}, + "resolution": { + "vocabulary_id": "ICD10CM", + "source_concept": { + "concept_id": 45576876, + "concept_name": "Type 2 diabetes mellitus without complications", + "concept_code": "E11.9", + "vocabulary_id": "ICD10CM", + "domain_id": "Condition", + "concept_class_id": "5-char billing code", + "standard_concept": None, + }, + "standard_concept": { + "concept_id": 201826, + "concept_name": "Type 2 diabetes mellitus", + "concept_code": "44054006", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "concept_class_id": "Clinical Finding", + "standard_concept": "S", + }, + "mapping_type": "mapped", + "relationship_id": "Maps to", + "target_table": "condition_occurrence", + "domain_resource_alignment": "not_checked", + "mapping_quality": "high", + }, + }, +} + +BATCH_RESPONSE = { + "success": True, + "data": { + "results": [SNOMED_RESOLVE_RESPONSE["data"]], + "summary": {"total": 1, "resolved": 1, "failed": 0}, + }, +} + +CODEABLE_CONCEPT_RESPONSE = { + "success": True, + "data": { + "input": { + "coding": [ + {"system": "http://snomed.info/sct", "code": "44054006"}, + {"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9"}, + ], + "resource_type": "Condition", + }, + "best_match": SNOMED_RESOLVE_RESPONSE["data"], + "alternatives": [ICD10_MAPPED_RESPONSE["data"]], + "unresolved": [], + }, +} + + +# -- Sync tests -------------------------------------------------------------- + + +class TestFhirSync: + """Tests for the synchronous Fhir resource.""" + + @respx.mock + def test_resolve_snomed_direct(self, sync_client: OMOPHub, base_url: str) -> None: + """SNOMED direct resolution returns correct shape.""" + respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response(200, json=SNOMED_RESOLVE_RESPONSE) + ) + + result = sync_client.fhir.resolve( + system="http://snomed.info/sct", + code="44054006", + resource_type="Condition", + ) + + assert result["resolution"]["mapping_type"] == "direct" + assert result["resolution"]["target_table"] == "condition_occurrence" + assert result["resolution"]["standard_concept"]["concept_id"] == 201826 + + @respx.mock + def test_resolve_icd10_mapped(self, sync_client: OMOPHub, base_url: str) -> None: + """ICD-10-CM maps to a standard SNOMED concept.""" + respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response(200, json=ICD10_MAPPED_RESPONSE) + ) + + result = sync_client.fhir.resolve( + system="http://hl7.org/fhir/sid/icd-10-cm", + code="E11.9", + include_quality=True, + ) + + assert result["resolution"]["mapping_type"] == "mapped" + assert result["resolution"]["relationship_id"] == "Maps to" + assert result["resolution"]["mapping_quality"] == "high" + + @respx.mock + def test_resolve_text_only(self, sync_client: OMOPHub, base_url: str) -> None: + """Display-only input triggers semantic search fallback.""" + semantic_response = { + "success": True, + "data": { + "input": {"display": "Blood Sugar", "resource_type": "Observation"}, + "resolution": { + "vocabulary_id": None, + "source_concept": { + "concept_id": 3004501, + "concept_name": "Glucose [Mass/volume] in Blood", + "concept_code": "2339-0", + "vocabulary_id": "LOINC", + "domain_id": "Measurement", + "concept_class_id": "Lab Test", + "standard_concept": "S", + }, + "standard_concept": { + "concept_id": 3004501, + "concept_name": "Glucose [Mass/volume] in Blood", + "concept_code": "2339-0", + "vocabulary_id": "LOINC", + "domain_id": "Measurement", + "concept_class_id": "Lab Test", + "standard_concept": "S", + }, + "mapping_type": "semantic_match", + "similarity_score": 0.91, + "target_table": "measurement", + "domain_resource_alignment": "aligned", + }, + }, + } + respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response(200, json=semantic_response) + ) + + result = sync_client.fhir.resolve(display="Blood Sugar", resource_type="Observation") + + assert result["resolution"]["mapping_type"] == "semantic_match" + assert result["resolution"]["similarity_score"] == 0.91 + + @respx.mock + def test_resolve_with_recommendations(self, sync_client: OMOPHub, base_url: str) -> None: + """Recommendations are included when requested.""" + recs_response = {**SNOMED_RESOLVE_RESPONSE} + recs_response["data"] = { + **SNOMED_RESOLVE_RESPONSE["data"], + "resolution": { + **SNOMED_RESOLVE_RESPONSE["data"]["resolution"], + "recommendations": [ + { + "concept_id": 4193704, + "concept_name": "Hyperglycemia", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "concept_class_id": "Clinical Finding", + "standard_concept": "S", + "relationship_id": "Has finding", + } + ], + }, + } + route = respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response(200, json=recs_response) + ) + + result = sync_client.fhir.resolve( + system="http://snomed.info/sct", + code="44054006", + include_recommendations=True, + recommendations_limit=3, + ) + + assert len(result["resolution"]["recommendations"]) == 1 + # Verify the request body included the flags + import json + + body = json.loads(route.calls[0].request.content) + assert body["include_recommendations"] is True + assert body["recommendations_limit"] == 3 + + @respx.mock + def test_resolve_unknown_system_400(self, sync_client: OMOPHub, base_url: str) -> None: + """Unknown URI raises an API error.""" + respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response( + 400, + json={ + "success": False, + "error": { + "code": "unknown_system", + "message": "Unknown FHIR code system URI", + "details": {"suggestion": "http://snomed.info/sct"}, + }, + }, + ) + ) + + with pytest.raises(Exception): + sync_client.fhir.resolve(system="http://snomed.info/sc", code="44054006") + + @respx.mock + def test_resolve_cpt4_403(self, sync_client: OMOPHub, base_url: str) -> None: + """CPT4 raises a 403 restricted error.""" + respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response( + 403, + json={ + "success": False, + "error": { + "code": "vocabulary_restricted", + "message": "CPT4 is excluded", + }, + }, + ) + ) + + with pytest.raises(Exception): + sync_client.fhir.resolve(system="http://www.ama-assn.org/go/cpt", code="99213") + + @respx.mock + def test_resolve_batch(self, sync_client: OMOPHub, base_url: str) -> None: + """Batch resolution returns results and summary.""" + respx.post(f"{base_url}/fhir/resolve/batch").mock( + return_value=Response(200, json=BATCH_RESPONSE) + ) + + result = sync_client.fhir.resolve_batch( + [{"system": "http://snomed.info/sct", "code": "44054006"}] + ) + + assert result["summary"]["total"] == 1 + assert result["summary"]["resolved"] == 1 + assert len(result["results"]) == 1 + + @respx.mock + def test_resolve_codeable_concept(self, sync_client: OMOPHub, base_url: str) -> None: + """CodeableConcept resolution returns best_match and alternatives.""" + respx.post(f"{base_url}/fhir/resolve/codeable-concept").mock( + return_value=Response(200, json=CODEABLE_CONCEPT_RESPONSE) + ) + + result = sync_client.fhir.resolve_codeable_concept( + coding=[ + {"system": "http://snomed.info/sct", "code": "44054006"}, + {"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9"}, + ], + resource_type="Condition", + ) + + assert result["best_match"] is not None + assert result["best_match"]["resolution"]["source_concept"]["vocabulary_id"] == "SNOMED" + assert len(result["alternatives"]) == 1 + + @respx.mock + def test_resolve_batch_with_all_options(self, sync_client: OMOPHub, base_url: str) -> None: + """Batch passes resource_type, include_recommendations, and include_quality.""" + route = respx.post(f"{base_url}/fhir/resolve/batch").mock( + return_value=Response(200, json=BATCH_RESPONSE) + ) + + sync_client.fhir.resolve_batch( + [{"system": "http://snomed.info/sct", "code": "44054006"}], + resource_type="Condition", + include_recommendations=True, + recommendations_limit=3, + include_quality=True, + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["resource_type"] == "Condition" + assert body["include_recommendations"] is True + assert body["recommendations_limit"] == 3 + assert body["include_quality"] is True + + @respx.mock + def test_resolve_codeable_concept_with_all_options( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """CodeableConcept passes text, resource_type, and enrichment flags.""" + route = respx.post(f"{base_url}/fhir/resolve/codeable-concept").mock( + return_value=Response(200, json=CODEABLE_CONCEPT_RESPONSE) + ) + + sync_client.fhir.resolve_codeable_concept( + coding=[{"system": "http://snomed.info/sct", "code": "44054006"}], + text="Type 2 diabetes", + resource_type="Condition", + include_recommendations=True, + recommendations_limit=5, + include_quality=True, + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["text"] == "Type 2 diabetes" + assert body["resource_type"] == "Condition" + assert body["include_recommendations"] is True + assert body["include_quality"] is True + + @respx.mock + def test_resolve_sends_correct_body(self, sync_client: OMOPHub, base_url: str) -> None: + """Verify the POST body includes only non-None parameters.""" + route = respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response(200, json=SNOMED_RESOLVE_RESPONSE) + ) + + sync_client.fhir.resolve( + system="http://snomed.info/sct", + code="44054006", + resource_type="Condition", + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body == { + "system": "http://snomed.info/sct", + "code": "44054006", + "resource_type": "Condition", + } + # Ensure optional flags are NOT sent when they're default False + assert "include_recommendations" not in body + assert "include_quality" not in body + + +# -- Async tests ------------------------------------------------------------- + + +class TestFhirAsync: + """Tests for the asynchronous AsyncFhir resource.""" + + @respx.mock + @pytest.mark.anyio + async def test_async_resolve(self, async_client: AsyncOMOPHub, base_url: str) -> None: + """Async resolve returns the same shape as sync.""" + respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response(200, json=SNOMED_RESOLVE_RESPONSE) + ) + + result = await async_client.fhir.resolve( + system="http://snomed.info/sct", + code="44054006", + ) + + assert result["resolution"]["mapping_type"] == "direct" + assert result["resolution"]["target_table"] == "condition_occurrence" + + @respx.mock + @pytest.mark.anyio + async def test_async_resolve_batch(self, async_client: AsyncOMOPHub, base_url: str) -> None: + """Async batch resolve returns results and summary.""" + respx.post(f"{base_url}/fhir/resolve/batch").mock( + return_value=Response(200, json=BATCH_RESPONSE) + ) + + result = await async_client.fhir.resolve_batch( + [{"system": "http://snomed.info/sct", "code": "44054006"}], + resource_type="Condition", + include_quality=True, + ) + + assert result["summary"]["total"] == 1 + + @respx.mock + @pytest.mark.anyio + async def test_async_resolve_codeable_concept( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: + """Async codeable concept resolve returns best_match.""" + respx.post(f"{base_url}/fhir/resolve/codeable-concept").mock( + return_value=Response(200, json=CODEABLE_CONCEPT_RESPONSE) + ) + + result = await async_client.fhir.resolve_codeable_concept( + coding=[ + {"system": "http://snomed.info/sct", "code": "44054006"}, + {"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9"}, + ], + text="Type 2 diabetes", + resource_type="Condition", + include_recommendations=True, + include_quality=True, + ) + + assert result["best_match"] is not None From 55255df3dec559dfd72b4e525723fcbf86c374e1 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Fri, 10 Apr 2026 20:26:15 +0100 Subject: [PATCH 45/48] Update CHANGELOG for v1.5.1 release and adjust Python version requirement in CONTRIBUTING.md. Refactor error handling in API request processing by introducing a shared `_parse_and_raise` function to streamline JSON response parsing and error management. Enhance async search functionality with pagination support and update type exports in `__init__.py`. --- CHANGELOG.md | 4 +- CONTRIBUTING.md | 2 +- src/omophub/_request.py | 254 +++++++++----------------------- src/omophub/resources/search.py | 25 ++-- src/omophub/types/__init__.py | 2 +- 5 files changed, 83 insertions(+), 204 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 557074c..346682d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,7 +117,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full type hints and PEP 561 compliance - HTTP/2 support via httpx -[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.4.1...HEAD +[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.5.1...HEAD +[1.5.1]: https://github.com/omopHub/omophub-python/compare/v1.5.0...v1.5.1 +[1.5.0]: https://github.com/omopHub/omophub-python/compare/v1.4.1...v1.5.0 [1.4.1]: https://github.com/omopHub/omophub-python/compare/v1.4.0...v1.4.1 [1.4.0]: https://github.com/omopHub/omophub-python/compare/v1.3.1...v1.4.0 [1.3.1]: https://github.com/omopHub/omophub-python/compare/v1.3.0...v1.3.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dba1590..4957456 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ Feature requests are welcome! Please open an issue with: ### Prerequisites -- Python 3.9+ +- Python 3.10+ - pip ### Installation diff --git a/src/omophub/_request.py b/src/omophub/_request.py index 61b3cf9..55e9bfb 100644 --- a/src/omophub/_request.py +++ b/src/omophub/_request.py @@ -17,6 +17,66 @@ T = TypeVar("T") +def _parse_and_raise( + content: bytes, + status_code: int, + headers: Mapping[str, str], +) -> dict[str, Any]: + """Parse JSON response body and raise on HTTP errors. + + Shared by both sync and async request classes to avoid duplicating + the JSON-decode, error-extraction, and rate-limit-retry logic. + + Returns: + The parsed JSON dict (caller decides whether to unwrap ``data``). + + Raises: + OMOPHubError: On invalid JSON from a successful response. + APIError / RateLimitError / etc.: On HTTP error status codes. + """ + request_id = headers.get("X-Request-Id") or headers.get("x-request-id") + + try: + data = json.loads(content) if content else {} + except json.JSONDecodeError as exc: + if status_code >= 400: + raise_for_status( + status_code, + f"Request failed with status {status_code}", + request_id=request_id, + ) + raise OMOPHubError( + f"Invalid JSON response: {content[:200].decode(errors='replace')}" + ) from exc + + if status_code >= 400: + error_response: ErrorResponse = data # type: ignore[assignment] + error = error_response.get("error", {}) + message = error.get("message", f"Request failed with status {status_code}") + error_code = error.get("code") + details = error.get("details") + + retry_after = None + if status_code == 429: + retry_after_header = headers.get("Retry-After") or headers.get( + "retry-after" + ) + if retry_after_header: + with contextlib.suppress(ValueError): + retry_after = int(retry_after_header) + + raise_for_status( + status_code, + message, + request_id=request_id, + error_code=error_code, + details=details, + retry_after=retry_after, + ) + + return data # type: ignore[return-value] + + class Request(Generic[T]): """Handles API request execution and response parsing.""" @@ -50,50 +110,8 @@ def _parse_response( status_code: int, headers: Mapping[str, str], ) -> T: - """Parse API response and handle errors.""" - request_id = headers.get("X-Request-Id") or headers.get("x-request-id") - - try: - data = json.loads(content) if content else {} - except json.JSONDecodeError as exc: - if status_code >= 400: - raise_for_status( - status_code, - f"Request failed with status {status_code}", - request_id=request_id, - ) - raise OMOPHubError( - f"Invalid JSON response: {content[:200].decode(errors='replace')}" - ) from exc - - # Handle error responses - if status_code >= 400: - error_response: ErrorResponse = data # type: ignore[assignment] - error = error_response.get("error", {}) - message = error.get("message", f"Request failed with status {status_code}") - error_code = error.get("code") - details = error.get("details") - - # Check for rate limit retry-after - retry_after = None - if status_code == 429: - retry_after_header = headers.get("Retry-After") or headers.get( - "retry-after" - ) - if retry_after_header: - with contextlib.suppress(ValueError): - retry_after = int(retry_after_header) - - raise_for_status( - status_code, - message, - request_id=request_id, - error_code=error_code, - details=details, - retry_after=retry_after, - ) - - # Return successful response data + """Parse API response, raise on errors, return the ``data`` field.""" + data = _parse_and_raise(content, status_code, headers) response: APIResponse = data # type: ignore[assignment] return response.get("data", data) @@ -103,55 +121,8 @@ def _parse_response_raw( status_code: int, headers: Mapping[str, str], ) -> dict[str, Any]: - """Parse API response and return full response dict with meta. - - Unlike _parse_response which extracts just the 'data' field, - this method returns the complete response including 'meta' for pagination. - """ - request_id = headers.get("X-Request-Id") or headers.get("x-request-id") - - try: - data = json.loads(content) if content else {} - except json.JSONDecodeError as exc: - if status_code >= 400: - raise_for_status( - status_code, - f"Request failed with status {status_code}", - request_id=request_id, - ) - raise OMOPHubError( - f"Invalid JSON response: {content[:200].decode(errors='replace')}" - ) from exc - - # Handle error responses - if status_code >= 400: - error_response: ErrorResponse = data # type: ignore[assignment] - error = error_response.get("error", {}) - message = error.get("message", f"Request failed with status {status_code}") - error_code = error.get("code") - details = error.get("details") - - # Check for rate limit retry-after - retry_after = None - if status_code == 429: - retry_after_header = headers.get("Retry-After") or headers.get( - "retry-after" - ) - if retry_after_header: - with contextlib.suppress(ValueError): - retry_after = int(retry_after_header) - - raise_for_status( - status_code, - message, - request_id=request_id, - error_code=error_code, - details=details, - retry_after=retry_after, - ) - - # Return full response dict (includes 'data' and 'meta') - return data + """Parse API response, raise on errors, return the full dict with ``meta``.""" + return _parse_and_raise(content, status_code, headers) def get( self, @@ -238,50 +209,8 @@ def _parse_response( status_code: int, headers: Mapping[str, str], ) -> T: - """Parse API response and handle errors.""" - request_id = headers.get("X-Request-Id") or headers.get("x-request-id") - - try: - data = json.loads(content) if content else {} - except json.JSONDecodeError as exc: - if status_code >= 400: - raise_for_status( - status_code, - f"Request failed with status {status_code}", - request_id=request_id, - ) - raise OMOPHubError( - f"Invalid JSON response: {content[:200].decode(errors='replace')}" - ) from exc - - # Handle error responses - if status_code >= 400: - error_response: ErrorResponse = data # type: ignore[assignment] - error = error_response.get("error", {}) - message = error.get("message", f"Request failed with status {status_code}") - error_code = error.get("code") - details = error.get("details") - - # Check for rate limit retry-after - retry_after = None - if status_code == 429: - retry_after_header = headers.get("Retry-After") or headers.get( - "retry-after" - ) - if retry_after_header: - with contextlib.suppress(ValueError): - retry_after = int(retry_after_header) - - raise_for_status( - status_code, - message, - request_id=request_id, - error_code=error_code, - details=details, - retry_after=retry_after, - ) - - # Return successful response data + """Parse API response, raise on errors, return the ``data`` field.""" + data = _parse_and_raise(content, status_code, headers) response: APIResponse = data # type: ignore[assignment] return response.get("data", data) @@ -291,55 +220,8 @@ def _parse_response_raw( status_code: int, headers: Mapping[str, str], ) -> dict[str, Any]: - """Parse API response and return full response dict with meta. - - Unlike _parse_response which extracts just the 'data' field, - this method returns the complete response including 'meta' for pagination. - """ - request_id = headers.get("X-Request-Id") or headers.get("x-request-id") - - try: - data = json.loads(content) if content else {} - except json.JSONDecodeError as exc: - if status_code >= 400: - raise_for_status( - status_code, - f"Request failed with status {status_code}", - request_id=request_id, - ) - raise OMOPHubError( - f"Invalid JSON response: {content[:200].decode(errors='replace')}" - ) from exc - - # Handle error responses - if status_code >= 400: - error_response: ErrorResponse = data # type: ignore[assignment] - error = error_response.get("error", {}) - message = error.get("message", f"Request failed with status {status_code}") - error_code = error.get("code") - details = error.get("details") - - # Check for rate limit retry-after - retry_after = None - if status_code == 429: - retry_after_header = headers.get("Retry-After") or headers.get( - "retry-after" - ) - if retry_after_header: - with contextlib.suppress(ValueError): - retry_after = int(retry_after_header) - - raise_for_status( - status_code, - message, - request_id=request_id, - error_code=error_code, - details=details, - retry_after=retry_after, - ) - - # Return full response dict (includes 'data' and 'meta') - return data + """Parse API response, raise on errors, return the full dict with ``meta``.""" + return _parse_and_raise(content, status_code, headers) async def get( self, diff --git a/src/omophub/resources/search.py b/src/omophub/resources/search.py index 3a03744..d90f092 100644 --- a/src/omophub/resources/search.py +++ b/src/omophub/resources/search.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, Literal, TypedDict -from .._pagination import DEFAULT_PAGE_SIZE, paginate_sync +from .._pagination import DEFAULT_PAGE_SIZE, paginate_async, paginate_sync if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterator @@ -670,13 +670,14 @@ async def semantic_iter( page_size: int = DEFAULT_PAGE_SIZE, ) -> AsyncIterator[SemanticSearchResult]: """Iterate through all semantic search results with auto-pagination.""" - page = 1 - while True: + async def fetch_page( + page: int, size: int + ) -> tuple[list[SemanticSearchResult], PaginationMeta | None]: params: dict[str, Any] = { "query": query, "page": page, - "page_size": page_size, + "page_size": size, } if vocabulary_ids: params["vocabulary_ids"] = ",".join(vocabulary_ids) @@ -694,18 +695,12 @@ async def semantic_iter( ) data = result.get("data", []) - results: list[SemanticSearchResult] = ( - data.get("results", data) if isinstance(data, dict) else data - ) - meta: PaginationMeta | None = result.get("meta", {}).get("pagination") - - for item in results: - yield item - - if meta is None or not meta.get("has_next", False): - break + results = data.get("results", data) if isinstance(data, dict) else data + meta = result.get("meta", {}).get("pagination") + return results, meta - page += 1 + async for item in paginate_async(fetch_page, page_size): + yield item async def bulk_basic( self, diff --git a/src/omophub/types/__init__.py b/src/omophub/types/__init__.py index 715ccdf..7b0842c 100644 --- a/src/omophub/types/__init__.py +++ b/src/omophub/types/__init__.py @@ -107,10 +107,10 @@ "QueryEnhancement", "RecommendedConceptOutput", "RelatedConcept", - "ResolvedConcept", "Relationship", "RelationshipSummary", "RelationshipType", + "ResolvedConcept", "ResponseMeta", "SearchFacet", "SearchFacets", From 8091a91637f082d03f97c5886ff67b2039c85e1b Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Fri, 10 Apr 2026 20:32:42 +0100 Subject: [PATCH 46/48] Refactor pagination and type hinting in async functions. Update `_parse_and_raise` to remove type ignore comment. Add tests for minimal CodeableConcept resolution and async FHIR property caching. --- src/omophub/_pagination.py | 11 +-- src/omophub/_request.py | 2 +- src/omophub/resources/search.py | 1 + tests/unit/resources/test_fhir.py | 134 ++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 9 deletions(-) diff --git a/src/omophub/_pagination.py b/src/omophub/_pagination.py index ba80bbb..a81aade 100644 --- a/src/omophub/_pagination.py +++ b/src/omophub/_pagination.py @@ -6,7 +6,7 @@ from urllib.parse import urlencode if TYPE_CHECKING: - from collections.abc import AsyncIterator, Callable, Iterator + from collections.abc import AsyncIterator, Awaitable, Callable, Iterator from ._types import PaginationMeta @@ -106,7 +106,7 @@ def paginate_sync( async def paginate_async( - fetch_page: Callable[[int, int], tuple[list[T], PaginationMeta | None]], + fetch_page: Callable[[int, int], Awaitable[tuple[list[T], PaginationMeta | None]]], page_size: int = DEFAULT_PAGE_SIZE, ) -> AsyncIterator[T]: """Create an async iterator that auto-paginates through results. @@ -121,12 +121,7 @@ async def paginate_async( page = 1 while True: - # Note: fetch_page should be an async function - result = fetch_page(page, page_size) - if hasattr(result, "__await__"): - items, meta = await result # type: ignore - else: - items, meta = result + items, meta = await fetch_page(page, page_size) for item in items: yield item diff --git a/src/omophub/_request.py b/src/omophub/_request.py index 55e9bfb..4649bc0 100644 --- a/src/omophub/_request.py +++ b/src/omophub/_request.py @@ -74,7 +74,7 @@ def _parse_and_raise( retry_after=retry_after, ) - return data # type: ignore[return-value] + return data class Request(Generic[T]): diff --git a/src/omophub/resources/search.py b/src/omophub/resources/search.py index d90f092..e63eefb 100644 --- a/src/omophub/resources/search.py +++ b/src/omophub/resources/search.py @@ -699,6 +699,7 @@ async def fetch_page( meta = result.get("meta", {}).get("pagination") return results, meta + item: SemanticSearchResult async for item in paginate_async(fetch_page, page_size): yield item diff --git a/tests/unit/resources/test_fhir.py b/tests/unit/resources/test_fhir.py index 8649e98..17aa2dc 100644 --- a/tests/unit/resources/test_fhir.py +++ b/tests/unit/resources/test_fhir.py @@ -353,6 +353,25 @@ def test_resolve_codeable_concept_with_all_options( assert body["include_recommendations"] is True assert body["include_quality"] is True + @respx.mock + def test_resolve_codeable_concept_minimal(self, sync_client: OMOPHub, base_url: str) -> None: + """CodeableConcept with no optional flags (covers False branches).""" + route = respx.post(f"{base_url}/fhir/resolve/codeable-concept").mock( + return_value=Response(200, json=CODEABLE_CONCEPT_RESPONSE) + ) + + sync_client.fhir.resolve_codeable_concept( + coding=[{"system": "http://snomed.info/sct", "code": "44054006"}], + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert "text" not in body + assert "resource_type" not in body + assert "include_recommendations" not in body + assert "include_quality" not in body + @respx.mock def test_resolve_sends_correct_body(self, sync_client: OMOPHub, base_url: str) -> None: """Verify the POST body includes only non-None parameters.""" @@ -439,3 +458,118 @@ async def test_async_resolve_codeable_concept( ) assert result["best_match"] is not None + + @respx.mock + @pytest.mark.anyio + async def test_async_resolve_vocabulary_id_bypass( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: + """Async resolve with vocabulary_id exercises the bypass branch.""" + route = respx.post(f"{base_url}/fhir/resolve").mock( + return_value=Response(200, json=ICD10_MAPPED_RESPONSE) + ) + + result = await async_client.fhir.resolve( + vocabulary_id="ICD10CM", + code="E11.9", + include_recommendations=True, + recommendations_limit=3, + include_quality=True, + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["vocabulary_id"] == "ICD10CM" + assert body["include_recommendations"] is True + assert body["recommendations_limit"] == 3 + assert body["include_quality"] is True + assert "resolution" in result + + @respx.mock + @pytest.mark.anyio + async def test_async_resolve_batch_all_flags( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: + """Async batch with include_recommendations exercises that branch.""" + route = respx.post(f"{base_url}/fhir/resolve/batch").mock( + return_value=Response(200, json=BATCH_RESPONSE) + ) + + await async_client.fhir.resolve_batch( + [{"system": "http://snomed.info/sct", "code": "44054006"}], + resource_type="Condition", + include_recommendations=True, + recommendations_limit=5, + include_quality=True, + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert body["include_recommendations"] is True + assert body["recommendations_limit"] == 5 + assert body["include_quality"] is True + + @respx.mock + @pytest.mark.anyio + async def test_async_fhir_property_cached(self, async_client: AsyncOMOPHub, base_url: str) -> None: + """Accessing client.fhir twice returns the same cached instance.""" + fhir1 = async_client.fhir + fhir2 = async_client.fhir + assert fhir1 is fhir2 + + + @respx.mock + @pytest.mark.anyio + async def test_async_resolve_codeable_minimal( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: + """Async codeable concept with no optional flags (covers False branches).""" + route = respx.post(f"{base_url}/fhir/resolve/codeable-concept").mock( + return_value=Response(200, json=CODEABLE_CONCEPT_RESPONSE) + ) + + await async_client.fhir.resolve_codeable_concept( + coding=[{"system": "http://snomed.info/sct", "code": "44054006"}], + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert "text" not in body + assert "resource_type" not in body + assert "include_recommendations" not in body + assert "include_quality" not in body + + @respx.mock + @pytest.mark.anyio + async def test_async_resolve_batch_minimal( + self, async_client: AsyncOMOPHub, base_url: str + ) -> None: + """Async batch with no optional flags (covers False branches).""" + route = respx.post(f"{base_url}/fhir/resolve/batch").mock( + return_value=Response(200, json=BATCH_RESPONSE) + ) + + await async_client.fhir.resolve_batch( + [{"system": "http://snomed.info/sct", "code": "44054006"}], + ) + + import json + + body = json.loads(route.calls[0].request.content) + assert "resource_type" not in body + assert "include_recommendations" not in body + assert "include_quality" not in body + + +class TestFhirPropertyCaching: + """Test lazy-property cache hit on both client types.""" + + @respx.mock + def test_sync_fhir_property_cached(self, sync_client: OMOPHub) -> None: + """Accessing client.fhir twice returns the same cached instance.""" + fhir1 = sync_client.fhir + fhir2 = sync_client.fhir + assert fhir1 is fhir2 From 85ddaae479e9d6547c002ea3fcdc2f175278a195 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Fri, 10 Apr 2026 20:37:02 +0100 Subject: [PATCH 47/48] Implement FHIR-to-OMOP concept resolution in v1.6.0, adding methods for single and batch resolution of FHIR codings, and CodeableConcept handling. Refactor shared response parsing in API requests to improve maintainability. Update README with usage examples and correct Python version requirement in CONTRIBUTING.md. --- CHANGELOG.md | 25 ++++++++++++++++++++++++- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 346682d..dd305b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.6.0] - 2026-04-10 + +### Added + +- **FHIR-to-OMOP Concept Resolver** (`client.fhir`): Translate FHIR coded values into OMOP standard concepts, CDM target tables, and optional Phoebe recommendations in a single API call. + - `resolve()`: Resolve a single FHIR `Coding` (system URI + code) or text-only input via semantic search fallback. Returns the standard concept, target CDM table, domain alignment check, and optional mapping quality signal. + - `resolve_batch()`: Batch-resolve up to 100 FHIR codings per request with inline per-item error reporting. Failed items do not fail the batch. + - `resolve_codeable_concept()`: Resolve a FHIR `CodeableConcept` with multiple codings. Automatically picks the best match per OHDSI vocabulary preference (SNOMED > RxNorm > LOINC > CVX > ICD-10). Falls back to the `text` field via semantic search when no coding resolves. +- New TypedDict types for FHIR resolver: `FhirResolveResult`, `FhirResolution`, `FhirBatchResult`, `FhirBatchSummary`, `FhirCodeableConceptResult`, `ResolvedConcept`, `RecommendedConceptOutput`. +- Both sync (`OMOPHub`) and async (`AsyncOMOPHub`) clients support FHIR resolver methods via `client.fhir.*`. + +### Changed + +- **Extracted shared response parsing** (`_request.py`): The duplicated JSON decode / error-handling / rate-limit-retry logic across `Request._parse_response`, `Request._parse_response_raw`, `AsyncRequest._parse_response`, and `AsyncRequest._parse_response_raw` (4 copies of ~50 lines each) is now a single `_parse_and_raise()` module-level function. All four methods delegate to it, eliminating the risk of divergence bugs. +- **Fixed `paginate_async` signature** (`_pagination.py`): The type hint now correctly declares `Callable[[int, int], Awaitable[tuple[...]]]` instead of `Callable[[int, int], tuple[...]]`, and the runtime `hasattr(__await__)` duck-typing hack has been replaced with a clean `await`. +- **`AsyncSearch.semantic_iter`** now delegates to `paginate_async` instead of manually reimplementing the pagination loop, matching the sync `semantic_iter` which already uses `paginate_sync`. + +### Fixed + +- Python prerequisite in CONTRIBUTING.md corrected from `3.9+` to `3.10+` (matching `pyproject.toml`). +- `__all__` in `types/__init__.py` sorted per RUF022. + ## [1.5.1] - 2026-04-08 ### Fixed @@ -117,7 +139,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full type hints and PEP 561 compliance - HTTP/2 support via httpx -[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.5.1...HEAD +[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.6.0...HEAD +[1.6.0]: https://github.com/omopHub/omophub-python/compare/v1.5.1...v1.6.0 [1.5.1]: https://github.com/omopHub/omophub-python/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/omopHub/omophub-python/compare/v1.4.1...v1.5.0 [1.4.1]: https://github.com/omopHub/omophub-python/compare/v1.4.0...v1.4.1 diff --git a/README.md b/README.md index 9cff18a..339bcae 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,46 @@ mappings = client.mappings.get_by_code("ICD10CM", "E11.9", target_vocabulary="SN ancestors = client.hierarchy.ancestors(201826, max_levels=3) ``` +## FHIR-to-OMOP Resolution + +Resolve FHIR coded values to OMOP standard concepts in one call: + +```python +# Single FHIR Coding → OMOP concept + CDM target table +result = client.fhir.resolve( + system="http://snomed.info/sct", + code="44054006", + resource_type="Condition", +) +print(result["resolution"]["target_table"]) # "condition_occurrence" +print(result["resolution"]["mapping_type"]) # "direct" + +# ICD-10-CM → traverses "Maps to" automatically +result = client.fhir.resolve( + system="http://hl7.org/fhir/sid/icd-10-cm", + code="E11.9", +) +print(result["resolution"]["standard_concept"]["vocabulary_id"]) # "SNOMED" + +# Batch resolve up to 100 codings +batch = client.fhir.resolve_batch([ + {"system": "http://snomed.info/sct", "code": "44054006"}, + {"system": "http://loinc.org", "code": "2339-0"}, + {"system": "http://www.nlm.nih.gov/research/umls/rxnorm", "code": "197696"}, +]) +print(f"Resolved {batch['summary']['resolved']}/{batch['summary']['total']}") + +# CodeableConcept with vocabulary preference (SNOMED wins over ICD-10) +result = client.fhir.resolve_codeable_concept( + coding=[ + {"system": "http://snomed.info/sct", "code": "44054006"}, + {"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9"}, + ], + resource_type="Condition", +) +print(result["best_match"]["resolution"]["source_concept"]["vocabulary_id"]) # "SNOMED" +``` + ## Semantic Search Use natural language queries to find concepts using neural embeddings: @@ -200,6 +240,7 @@ suggestions = client.concepts.suggest("diab", vocabulary_ids=["SNOMED"], page_si | `mappings` | Cross-vocabulary mappings | `get()`, `map()` | | `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` | | `domains` | Domain information | `list()`, `get()`, `concepts()` | +| `fhir` | FHIR-to-OMOP resolution | `resolve()`, `resolve_batch()`, `resolve_codeable_concept()` | ## Configuration From 4be3a329c5ddc040187c63a212f8d61031d3f6a0 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Fri, 10 Apr 2026 20:43:01 +0100 Subject: [PATCH 48/48] Refactor test for pagination to enforce async callable requirement. Update test name and docstring for clarity, and change fetch_page to be an async function. --- tests/unit/test_pagination.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index e213972..97413f6 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -199,12 +199,12 @@ async def fetch_page(page: int, page_size: int) -> tuple: assert result == items @pytest.mark.asyncio - async def test_sync_callable_fallback(self) -> None: - """Test paginate_async works with sync callables too.""" + async def test_async_callable_required(self) -> None: + """Test paginate_async requires an async callable (not sync).""" items = [{"id": 1}, {"id": 2}] - meta = {"page": 1, "has_next": False} + meta: dict[str, object] = {"page": 1, "has_next": False} - def fetch_page(page: int, page_size: int) -> tuple: + async def fetch_page(page: int, page_size: int) -> tuple: return items, meta result = [item async for item in paginate_async(fetch_page)]