Skip to content

Commit 78f3867

Browse files
authored
FHIR support (#12)
* Add FHIR resolver example script - Introduced a new script `fhir_resolver.py` that demonstrates FHIR-to-OMOP concept resolution using the OMOPHub SDK. - The script includes examples for resolving SNOMED, ICD-10-CM, LOINC, RxNorm codes, and text-only semantic searches. - Added functionality for direct standard lookups, non-standard code mapping, and Phoebe recommendations. - Supports batch resolution and asynchronous usage for improved performance. * Implement FHIR type interoperability and client helpers - Added support for FHIR type interoperability in the resolver, allowing acceptance of various Coding-like inputs (dicts, TypedDicts, and objects with .system/.code attributes). - Introduced new FHIR type definitions (`Coding`, `CodeableConcept`) and runtime-checkable protocols (`CodingLike`, `CodeableConceptLike`) for structural matching. - Implemented FHIR client interop helpers to configure external libraries (`fhirpy`, `fhir.resources`) with OMOPHub's FHIR Terminology Service. - Enhanced `Fhir.resolve`, `Fhir.resolve_batch`, and `Fhir.resolve_codeable_concept` methods to accept mixed input types. - Updated documentation and examples to reflect new functionalities and usage patterns. - Added comprehensive unit and integration tests to validate new features and ensure compatibility. * Refactor examples to remove hardcoded API keys - Updated example scripts. - Adjusted search and resolution methods in examples to reflect the new API response structures, ensuring consistency across examples. - Improved documentation and comments in example scripts for clarity and better user guidance.
1 parent 72bb565 commit 78f3867

20 files changed

Lines changed: 1450 additions & 102 deletions

CHANGELOG.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,83 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.7.0] - 2026-04-14
9+
10+
### Added
11+
12+
- **FHIR type interoperability** (`client.fhir.*`): The resolver now
13+
accepts any Coding-like input via duck typing, so pipelines that
14+
already parse FHIR with `fhir.resources` or `fhirpy` can pass those
15+
objects directly - no manual conversion.
16+
- New `coding=` kwarg on `Fhir.resolve` / `AsyncFhir.resolve`. Accepts
17+
a plain dict, the `omophub.types.fhir.Coding` TypedDict, or any
18+
object exposing `.system` / `.code` / `.display` attributes (e.g.
19+
`fhir.resources.Coding`, `fhirpy` codings).
20+
- `Fhir.resolve_batch` / `AsyncFhir.resolve_batch` now accept a
21+
mixed list of dicts and Coding-like objects; each item is
22+
normalized to the wire format automatically.
23+
- `Fhir.resolve_codeable_concept` / async counterpart now accept
24+
either a list of Coding-likes (existing) or a full
25+
CodeableConcept-like object exposing `.coding` and `.text`
26+
(new); explicit `text=` kwarg still wins when both are passed.
27+
- Explicit `system` / `code` kwargs override the corresponding
28+
fields on a `coding=` input - handy for last-mile overrides.
29+
- **New FHIR type definitions** in `omophub.types.fhir`:
30+
- `Coding` and `CodeableConcept` lightweight TypedDicts
31+
- `CodingLike` and `CodeableConceptLike` runtime-checkable
32+
`Protocol`s for structural matching against external libraries.
33+
- **FHIR client interop helpers** (`omophub.fhir_interop`): Thin
34+
helpers for configuring an external FHIR client library against
35+
the OMOPHub FHIR Terminology Service.
36+
- `get_fhir_server_url(version)` returns the FHIR base URL for
37+
`"r4"` (default), `"r4b"`, `"r5"`, or `"r6"`.
38+
- `get_fhirpy_client(api_key, version)` and
39+
`get_async_fhirpy_client(api_key, version)` return `fhirpy`
40+
clients pre-wired with the right URL and `Authorization: Bearer`
41+
header. `fhirpy` is imported lazily, so it is never a required
42+
dependency; a helpful `ImportError` with install instructions is
43+
raised only when you actually call the helper.
44+
- All three helpers are re-exported from the top-level `omophub`
45+
namespace.
46+
- **`OMOPHub.fhir_server_url` / `AsyncOMOPHub.fhir_server_url`**:
47+
Convenience read-only property returning the R4 FHIR endpoint for
48+
drop-in use with external FHIR clients (`httpx`, `fhirpy`,
49+
`fhir.resources`).
50+
- **Optional extras** in `pyproject.toml`:
51+
- `pip install omophub[fhirpy]` pulls in `fhirpy>=1.4.0`.
52+
- `pip install omophub[fhir-resources]` pulls in
53+
`fhir.resources>=7.0.0`.
54+
Both are purely optional; duck typing means neither is required
55+
for core SDK use.
56+
57+
### Changed
58+
59+
- `Fhir.resolve_batch` signature broadened from
60+
`codings: list[dict[str, str | None]]` to
61+
`codings: list[CodingInput]` where `CodingInput` is the union of
62+
dict, `Coding` TypedDict, and `CodingLike` protocol. Existing
63+
call sites keep working unchanged.
64+
- `Fhir.resolve_codeable_concept` signature broadened from
65+
`coding: list[dict[str, str]]` to
66+
`coding: list[CodingInput] | CodeableConceptInput`, accepting
67+
either the legacy list-of-codings shape or a full
68+
CodeableConcept-like object.
69+
70+
### Tests
71+
72+
- 16 new unit tests covering `_extract_coding`, `_coding_to_dict`,
73+
and duck-typed inputs across `resolve`, `resolve_batch`, and
74+
`resolve_codeable_concept` on the sync client. Explicit-kwargs-win
75+
precedence is covered.
76+
- New `tests/unit/test_fhir_interop.py` with 10 cases: URL builder
77+
for all four FHIR versions, `fhirpy` lazy import with stubbed
78+
success and missing-module failure paths, and the
79+
`fhir_server_url` property on both sync and async clients.
80+
- 5 new integration tests in `tests/integration/test_fhir.py`
81+
exercising the new `coding=` kwarg, mixed dict + duck-typed batch
82+
inputs, CodeableConcept-like object resolution, and the
83+
`fhir_server_url` property against the live API.
84+
885
## [1.6.0] - 2026-04-10
986

1087
### Added

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ Working with OHDSI ATHENA vocabularies traditionally requires downloading multi-
3232

3333
```bash
3434
pip install omophub
35+
36+
# Optional extras for FHIR client interop
37+
pip install omophub[fhirpy] # Pre-wired fhirpy client
38+
pip install omophub[fhir-resources] # Install marker for fhir.resources
3539
```
3640

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

105+
### Type Interoperability
106+
107+
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).
108+
109+
```python
110+
from omophub.types.fhir import Coding
111+
112+
# omophub's TypedDict - IDE autocomplete, no extra deps
113+
coding: Coding = {"system": "http://snomed.info/sct", "code": "44054006"}
114+
result = client.fhir.resolve(coding=coding)
115+
116+
# fhir.resources objects work via duck typing - no conversion needed
117+
from fhir.resources.R4B.coding import Coding as FhirCoding
118+
fhir_coding = FhirCoding(system="http://snomed.info/sct", code="44054006")
119+
result = client.fhir.resolve(coding=fhir_coding)
120+
121+
# Mixed shapes in a single batch call
122+
result = client.fhir.resolve_batch([
123+
{"system": "http://snomed.info/sct", "code": "44054006"}, # dict
124+
FhirCoding(system="http://loinc.org", code="2339-0"), # fhir.resources
125+
])
126+
```
127+
128+
`fhir.resources` is **never** a required dependency. See [`examples/fhir_interop.py`](./examples/fhir_interop.py) for the full set of supported input shapes.
129+
130+
### FHIR Client Interop
131+
132+
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.
133+
134+
```python
135+
from omophub import OMOPHub, get_fhir_server_url
136+
137+
client = OMOPHub(api_key="oh_xxx")
138+
139+
# Property on the client returns the R4 base URL
140+
print(client.fhir_server_url)
141+
# "https://fhir.omophub.com/fhir/r4"
142+
143+
# Helper for other FHIR versions
144+
print(get_fhir_server_url("r5"))
145+
# "https://fhir.omophub.com/fhir/r5"
146+
```
147+
148+
For `fhirpy`, install the optional extra and use the pre-wired client:
149+
150+
```bash
151+
pip install omophub[fhirpy]
152+
```
153+
154+
```python
155+
from omophub import get_fhirpy_client
156+
157+
fhir = get_fhirpy_client("oh_xxx")
158+
159+
# Call CodeSystem/$lookup directly via fhirpy
160+
params = fhir.execute(
161+
"CodeSystem/$lookup",
162+
method="GET",
163+
params={"system": "http://snomed.info/sct", "code": "44054006"},
164+
)
165+
```
166+
167+
**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.
168+
101169
## Semantic Search
102170

103171
Use natural language queries to find concepts using neural embeddings:

examples/async_usage.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,23 @@ async def basic_async_usage() -> None:
1111
print("=== Basic Async Usage ===")
1212

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

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

2423

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

29-
async with omophub.AsyncOMOPHub(api_key="oh_your_api_key") as client:
28+
async with omophub.AsyncOMOPHub() as client:
3029
# Fetch multiple concepts concurrently
31-
concept_ids = [201826, 4329847, 1112807, 40483312, 37311061]
30+
concept_ids = [201826, 4329847, 1112807, 316866, 37311061]
3231

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

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

49-
async def search_and_count(term: str) -> tuple[str, int]:
50-
results = await client.search.basic(term, page_size=1)
51-
# Get total from pagination metadata if available
52-
meta = results.get("meta", {}).get("pagination", {})
53-
total_items = meta.get("total_items")
54-
if total_items is not None:
55-
total = total_items
56-
else:
57-
concepts = results.get("concepts", [])
58-
total = len(concepts) if isinstance(concepts, list) else 0
59-
return term, total
60-
61-
tasks = [search_and_count(term) for term in search_terms]
50+
async def page_count(term: str) -> tuple[str, int]:
51+
concepts = await client.search.basic(term, page_size=50)
52+
return term, len(concepts)
53+
54+
tasks = [page_count(term) for term in search_terms]
6255
results = await asyncio.gather(*tasks)
6356

64-
print("Search results:")
57+
print("First-page hit counts:")
6558
for term, count in results:
6659
print(f" '{term}': {count} concepts")
6760

examples/basic_usage.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

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

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

21-
# Search for concepts
21+
# Search for concepts - returns a list of concept dicts
2222
results = client.search.basic(
2323
"diabetes",
2424
vocabulary_ids=["SNOMED"],
2525
page_size=5,
2626
)
2727
print("Search results for 'diabetes':")
28-
for c in results.get("concepts", []):
28+
for c in results:
2929
print(f" {c['concept_id']}: {c['concept_name']}")
3030
print()
3131

32-
# List vocabularies
32+
# List vocabularies - returns a dict with a 'vocabularies' key
3333
vocabs = client.vocabularies.list(page_size=5)
3434
print("Available vocabularies:")
3535
for v in vocabs.get("vocabularies", []):

examples/error_handling.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def handle_not_found() -> None:
1010
"""Handle concept not found errors."""
1111
print("=== Handling Not Found ===")
1212

13-
client = omophub.OMOPHub(api_key="oh_your_api_key")
13+
client = omophub.OMOPHub()
1414

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

43-
client = omophub.OMOPHub(api_key="oh_your_api_key")
43+
client = omophub.OMOPHub()
4444

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

61-
client = omophub.OMOPHub(api_key="oh_your_api_key")
61+
client = omophub.OMOPHub()
6262

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

76-
client = omophub.OMOPHub(api_key="oh_your_api_key")
76+
client = omophub.OMOPHub()
7777

7878
try:
7979
concept = client.concepts.get(201826)

0 commit comments

Comments
 (0)