Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,33 @@ 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

- **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
Expand Down Expand Up @@ -117,7 +139,10 @@ 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.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
[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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Feature requests are welcome! Please open an issue with:

### Prerequisites

- Python 3.9+
- Python 3.10+
- pip

### Installation
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions src/omophub/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 3 additions & 8 deletions src/omophub/_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading