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
77 changes: 77 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,83 @@ 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.7.0] - 2026-04-14

### Added

- **FHIR type interoperability** (`client.fhir.*`): The resolver now
accepts any Coding-like input via duck typing, so pipelines that
already parse FHIR with `fhir.resources` or `fhirpy` can pass those
objects directly - no manual conversion.
- New `coding=` kwarg on `Fhir.resolve` / `AsyncFhir.resolve`. Accepts
a plain dict, the `omophub.types.fhir.Coding` TypedDict, or any
object exposing `.system` / `.code` / `.display` attributes (e.g.
`fhir.resources.Coding`, `fhirpy` codings).
- `Fhir.resolve_batch` / `AsyncFhir.resolve_batch` now accept a
mixed list of dicts and Coding-like objects; each item is
normalized to the wire format automatically.
- `Fhir.resolve_codeable_concept` / async counterpart now accept
either a list of Coding-likes (existing) or a full
CodeableConcept-like object exposing `.coding` and `.text`
(new); explicit `text=` kwarg still wins when both are passed.
- Explicit `system` / `code` kwargs override the corresponding
fields on a `coding=` input - handy for last-mile overrides.
- **New FHIR type definitions** in `omophub.types.fhir`:
- `Coding` and `CodeableConcept` lightweight TypedDicts
- `CodingLike` and `CodeableConceptLike` runtime-checkable
`Protocol`s for structural matching against external libraries.
- **FHIR client interop helpers** (`omophub.fhir_interop`): Thin
helpers for configuring an external FHIR client library against
the OMOPHub FHIR Terminology Service.
- `get_fhir_server_url(version)` returns the FHIR base URL for
`"r4"` (default), `"r4b"`, `"r5"`, or `"r6"`.
- `get_fhirpy_client(api_key, version)` and
`get_async_fhirpy_client(api_key, version)` return `fhirpy`
clients pre-wired with the right URL and `Authorization: Bearer`
header. `fhirpy` is imported lazily, so it is never a required
dependency; a helpful `ImportError` with install instructions is
raised only when you actually call the helper.
- All three helpers are re-exported from the top-level `omophub`
namespace.
- **`OMOPHub.fhir_server_url` / `AsyncOMOPHub.fhir_server_url`**:
Convenience read-only property returning the R4 FHIR endpoint for
drop-in use with external FHIR clients (`httpx`, `fhirpy`,
`fhir.resources`).
- **Optional extras** in `pyproject.toml`:
- `pip install omophub[fhirpy]` pulls in `fhirpy>=1.4.0`.
- `pip install omophub[fhir-resources]` pulls in
`fhir.resources>=7.0.0`.
Both are purely optional; duck typing means neither is required
for core SDK use.

### Changed

- `Fhir.resolve_batch` signature broadened from
`codings: list[dict[str, str | None]]` to
`codings: list[CodingInput]` where `CodingInput` is the union of
dict, `Coding` TypedDict, and `CodingLike` protocol. Existing
call sites keep working unchanged.
- `Fhir.resolve_codeable_concept` signature broadened from
`coding: list[dict[str, str]]` to
`coding: list[CodingInput] | CodeableConceptInput`, accepting
either the legacy list-of-codings shape or a full
CodeableConcept-like object.

### Tests

- 16 new unit tests covering `_extract_coding`, `_coding_to_dict`,
and duck-typed inputs across `resolve`, `resolve_batch`, and
`resolve_codeable_concept` on the sync client. Explicit-kwargs-win
precedence is covered.
- New `tests/unit/test_fhir_interop.py` with 10 cases: URL builder
for all four FHIR versions, `fhirpy` lazy import with stubbed
success and missing-module failure paths, and the
`fhir_server_url` property on both sync and async clients.
- 5 new integration tests in `tests/integration/test_fhir.py`
exercising the new `coding=` kwarg, mixed dict + duck-typed batch
inputs, CodeableConcept-like object resolution, and the
`fhir_server_url` property against the live API.

## [1.6.0] - 2026-04-10

### Added
Expand Down
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ Working with OHDSI ATHENA vocabularies traditionally requires downloading multi-

```bash
pip install omophub

# Optional extras for FHIR client interop
pip install omophub[fhirpy] # Pre-wired fhirpy client
pip install omophub[fhir-resources] # Install marker for fhir.resources
```

## Quick Start
Expand Down Expand Up @@ -98,6 +102,70 @@ result = client.fhir.resolve_codeable_concept(
print(result["best_match"]["resolution"]["source_concept"]["vocabulary_id"]) # "SNOMED"
```

### Type Interoperability

The resolver accepts any Coding-like input via duck typing - a plain dict, omophub's lightweight `Coding` TypedDict, or any object with `.system` / `.code` attributes (e.g. `fhir.resources.Coding`, `fhirpy` codings).

```python
from omophub.types.fhir import Coding

# omophub's TypedDict - IDE autocomplete, no extra deps
coding: Coding = {"system": "http://snomed.info/sct", "code": "44054006"}
result = client.fhir.resolve(coding=coding)

# fhir.resources objects work via duck typing - no conversion needed
from fhir.resources.R4B.coding import Coding as FhirCoding
fhir_coding = FhirCoding(system="http://snomed.info/sct", code="44054006")
result = client.fhir.resolve(coding=fhir_coding)

# Mixed shapes in a single batch call
result = client.fhir.resolve_batch([
{"system": "http://snomed.info/sct", "code": "44054006"}, # dict
FhirCoding(system="http://loinc.org", code="2339-0"), # fhir.resources
])
```

`fhir.resources` is **never** a required dependency. See [`examples/fhir_interop.py`](./examples/fhir_interop.py) for the full set of supported input shapes.

### FHIR Client Interop

Point external FHIR client libraries at OMOPHub's FHIR Terminology Service directly - useful when you need raw FHIR `Parameters` / `Bundle` responses instead of the Concept Resolver envelope.

```python
from omophub import OMOPHub, get_fhir_server_url

client = OMOPHub(api_key="oh_xxx")

# Property on the client returns the R4 base URL
print(client.fhir_server_url)
# "https://fhir.omophub.com/fhir/r4"

# Helper for other FHIR versions
print(get_fhir_server_url("r5"))
# "https://fhir.omophub.com/fhir/r5"
```

For `fhirpy`, install the optional extra and use the pre-wired client:

```bash
pip install omophub[fhirpy]
```

```python
from omophub import get_fhirpy_client

fhir = get_fhirpy_client("oh_xxx")

# Call CodeSystem/$lookup directly via fhirpy
params = fhir.execute(
"CodeSystem/$lookup",
method="GET",
params={"system": "http://snomed.info/sct", "code": "44054006"},
)
```

**When to use which**: the Concept Resolver (`client.fhir.resolve`) gives you OMOP-enriched answers - standard concept ID, CDM target table, mapping quality. Use `fhirpy` via `get_fhirpy_client()` when you need raw FHIR responses for FHIR-native tooling.

## Semantic Search

Use natural language queries to find concepts using neural embeddings:
Expand Down
37 changes: 15 additions & 22 deletions examples/async_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,23 @@ async def basic_async_usage() -> None:
print("=== Basic Async Usage ===")

# Use async context manager
async with omophub.AsyncOMOPHub(api_key="oh_your_api_key") as client:
async with omophub.AsyncOMOPHub() as client:
# Get a concept
concept = await client.concepts.get(201826)
print(f"Concept: {concept['concept_name']}")

# Search
results = await client.search.basic("diabetes", page_size=5)
concepts = results.get("concepts", [])
# Search - returns a flat list of concept dicts
concepts = await client.search.basic("diabetes", page_size=5)
print(f"Found {len(concepts)} concepts")


async def concurrent_requests() -> None:
"""Demonstrate concurrent API requests."""
print("\n=== Concurrent Requests ===")

async with omophub.AsyncOMOPHub(api_key="oh_your_api_key") as client:
async with omophub.AsyncOMOPHub() as client:
# Fetch multiple concepts concurrently
concept_ids = [201826, 4329847, 1112807, 40483312, 37311061]
concept_ids = [201826, 4329847, 1112807, 316866, 37311061]

tasks = [client.concepts.get(cid) for cid in concept_ids]
concepts = await asyncio.gather(*tasks)
Expand All @@ -42,26 +41,20 @@ async def parallel_searches() -> None:
"""Demonstrate parallel search operations."""
print("\n=== Parallel Searches ===")

async with omophub.AsyncOMOPHub(api_key="oh_your_api_key") as client:
# Run multiple searches in parallel
async with omophub.AsyncOMOPHub() as client:
# Run multiple searches in parallel. ``search.basic`` returns a
# flat list of concept dicts for the current page; for the full
# total count we iterate across pages via ``basic_iter``.
search_terms = ["diabetes", "hypertension", "asthma", "depression"]

async def search_and_count(term: str) -> tuple[str, int]:
results = await client.search.basic(term, page_size=1)
# Get total from pagination metadata if available
meta = results.get("meta", {}).get("pagination", {})
total_items = meta.get("total_items")
if total_items is not None:
total = total_items
else:
concepts = results.get("concepts", [])
total = len(concepts) if isinstance(concepts, list) else 0
return term, total

tasks = [search_and_count(term) for term in search_terms]
async def page_count(term: str) -> tuple[str, int]:
concepts = await client.search.basic(term, page_size=50)
return term, len(concepts)

tasks = [page_count(term) for term in search_terms]
results = await asyncio.gather(*tasks)

print("Search results:")
print("First-page hit counts:")
for term, count in results:
print(f" '{term}': {count} concepts")

Expand Down
12 changes: 6 additions & 6 deletions examples/basic_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

def main() -> None:
"""Demonstrate basic SDK usage."""
# Initialize the client with your API key
# You can also set OMOPHUB_API_KEY environment variable
client = omophub.OMOPHub(api_key="oh_your_api_key")
# Reads OMOPHUB_API_KEY from the environment. To pass it explicitly:
# client = omophub.OMOPHub(api_key="oh_your_api_key")
client = omophub.OMOPHub()

# Get a concept by ID
concept = client.concepts.get(201826)
Expand All @@ -18,18 +18,18 @@ def main() -> None:
print(f" Domain: {concept['domain_id']}")
print()

# Search for concepts
# Search for concepts - returns a list of concept dicts
results = client.search.basic(
"diabetes",
vocabulary_ids=["SNOMED"],
page_size=5,
)
print("Search results for 'diabetes':")
for c in results.get("concepts", []):
for c in results:
print(f" {c['concept_id']}: {c['concept_name']}")
print()

# List vocabularies
# List vocabularies - returns a dict with a 'vocabularies' key
vocabs = client.vocabularies.list(page_size=5)
print("Available vocabularies:")
for v in vocabs.get("vocabularies", []):
Expand Down
8 changes: 4 additions & 4 deletions examples/error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def handle_not_found() -> None:
"""Handle concept not found errors."""
print("=== Handling Not Found ===")

client = omophub.OMOPHub(api_key="oh_your_api_key")
client = omophub.OMOPHub()

try:
# Try to get a non-existent concept
Expand Down Expand Up @@ -40,7 +40,7 @@ def handle_rate_limit() -> None:
"""Handle rate limit errors with retry."""
print("\n=== Handling Rate Limits ===")

client = omophub.OMOPHub(api_key="oh_your_api_key")
client = omophub.OMOPHub()

for i in range(5):
try:
Expand All @@ -58,7 +58,7 @@ def handle_validation() -> None:
"""Handle validation errors."""
print("\n=== Handling Validation Errors ===")

client = omophub.OMOPHub(api_key="oh_your_api_key")
client = omophub.OMOPHub()

try:
# Try an invalid request
Expand All @@ -73,7 +73,7 @@ def comprehensive_error_handling() -> None:
"""Demonstrate comprehensive error handling."""
print("\n=== Comprehensive Error Handling ===")

client = omophub.OMOPHub(api_key="oh_your_api_key")
client = omophub.OMOPHub()

try:
concept = client.concepts.get(201826)
Expand Down
Loading
Loading