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
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ 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.9.1] - 2026-09-07

### Added

- Similarity search now exposes pagination, the full supported filter/options
set, source-concept metadata, and lower-bound/degraded-search indicators.
- `ResponseError` distinguishes an unreadable autocomplete response from a
legitimate empty result.

### Changed

- Autocomplete uses `domain_ids`, accepts the deprecated `domains` alias, and
models the API's seven-field suggestion entries. Similarity search now uses
the API's `semantic` default algorithm.

### Fixed

- `Mappings.map()` / `AsyncMappings.map()` now send their declared
`include_invalid=False` default explicitly. Their documentation now describes
the API's per-source `unmapped_sources` and required `summary` result fields.

## [1.9.0] - 2026-08-11

### Added
Expand Down Expand Up @@ -307,7 +328,8 @@ and are not shipped in the wheel/sdist.
- Full type hints and PEP 561 compliance
- HTTP/2 support via httpx

[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.9.0...HEAD
[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.9.1...HEAD
[1.9.1]: https://github.com/omopHub/omophub-python/compare/v1.9.0...v1.9.1
[1.9.0]: https://github.com/omopHub/omophub-python/compare/v1.8.1...v1.9.0
[1.8.1]: https://github.com/omopHub/omophub-python/compare/v1.8.0...v1.8.1
[1.8.0]: https://github.com/omopHub/omophub-python/compare/v1.7.1...v1.8.0
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ for c in results["concepts"]:
icd = client.concepts.get_by_code("ICD10CM", "E11.9")
mappings = client.mappings.get(icd["concept_id"], target_vocabulary="SNOMED")

# Or map native codes in one request. Every source that produces no mapping is
# returned in unmapped_sources with source_not_found or no_mapping_found.
mapped = client.mappings.map(
"SNOMED",
source_codes=[{"vocabulary_id": "ICD10CM", "concept_code": "E11.9"}],
)
print(mapped["summary"])
print(mapped["unmapped_sources"])
Comment on lines +70 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The README example indexes mapped["summary"], but the repo's own example for the same client.mappings.map() method (examples/map_between_vocabularies.py) reads result.get("mapping_summary", {}). map() returns the raw API data unmodified, so only one key name is correct; if the API field is mapping_summary, the README's mapped["summary"] raises a KeyError. Align the README with the actual response key (or confirm which is right and fix the other).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 70:

<comment>The README example indexes `mapped["summary"]`, but the repo's own example for the same `client.mappings.map()` method (`examples/map_between_vocabularies.py`) reads `result.get("mapping_summary", {})`. `map()` returns the raw API `data` unmodified, so only one key name is correct; if the API field is `mapping_summary`, the README's `mapped["summary"]` raises a KeyError. Align the README with the actual response key (or confirm which is right and fix the other).</comment>

<file context>
@@ -61,6 +61,15 @@ for c in results["concepts"]:
+    "SNOMED",
+    source_codes=[{"vocabulary_id": "ICD10CM", "concept_code": "E11.9"}],
+)
+print(mapped["summary"])
+print(mapped["unmapped_sources"])
+
</file context>
Suggested change
print(mapped["summary"])
print(mapped["unmapped_sources"])
print(mapped["mapping_summary"])
print(mapped["unmapped_sources"])


# Navigate concept hierarchy
ancestors = client.hierarchy.ancestors(201826, max_levels=3)
```
Expand Down Expand Up @@ -237,9 +246,10 @@ results = client.search.bulk_semantic([
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"]:
# Find concepts similar to a known concept.
# `algorithm` defaults to "semantic"; "lexical" and "hybrid" are also available.
results = client.search.similar(concept_id=201826)
for r in results["similar_concepts"]:
print(f"{r['concept_name']} (score: {r['similarity_score']:.2f})")

# Find similar concepts using a natural language query
Expand Down
7 changes: 5 additions & 2 deletions examples/map_between_vocabularies.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,13 @@ def map_concepts() -> None:
)

mappings = result.get("mappings", [])
summary = result.get("mapping_summary", {})
summary = result.get("summary", {})

print(f"Mapped {len(mappings)} concepts to ICD-10-CM")
print(f"Coverage: {summary.get('coverage_percentage', 'N/A')}%")
print(
f"Mapped sources: {summary.get('mapped_sources', 0)}/"
f"{summary.get('requested_sources', 0)}"
)

for m in mappings:
source_name = m.get("source_concept_name", "Unknown")
Expand Down
3 changes: 2 additions & 1 deletion examples/search_concepts.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ def similarity_search() -> None:
"""
print("\n=== Similarity Search ===")

# Find concepts similar to Type 2 diabetes mellitus (concept_id=201826)
# Find concepts similar to Type 2 diabetes mellitus (concept_id=201826).
# Fusing both signals; omit `algorithm` for the "semantic" default.
response = client.search.similar(concept_id=201826, algorithm="hybrid")
print("Concepts similar to 'Type 2 diabetes mellitus':")
for r in response["similar_concepts"][:5]:
Expand Down
2 changes: 2 additions & 0 deletions src/omophub/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
NotFoundError,
OMOPHubError,
RateLimitError,
ResponseError,
ServerError,
TimeoutError,
ValidationError,
Expand Down Expand Up @@ -85,6 +86,7 @@
"RateLimitError",
"Relationship",
"RelationshipType",
"ResponseError",
"SearchFacets",
"SearchResult",
"ServerError",
Expand Down
14 changes: 14 additions & 0 deletions src/omophub/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ class ServerError(APIError):
pass


class ResponseError(OMOPHubError):
"""The API responded successfully but not in the documented shape.

Raised instead of returning an empty result, so protocol drift is
distinguishable from "the server found nothing". An empty list is a real
answer; a payload the SDK cannot read is not, and silently turning one into
the other hides a breaking change until someone notices missing data.
"""

def __init__(self, message: str, *, payload: Any = None) -> None:
self.payload = payload
super().__init__(message)


class ConnectionError(OMOPHubError):
"""Network connection error."""

Expand Down
43 changes: 43 additions & 0 deletions src/omophub/_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,29 @@ def post(
)
return self._parse_response(content, status_code, headers)

def post_raw(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new public post_raw methods (sync and async) have no unit tests, while the parallel get_raw methods have full coverage (success, params, error, rate-limit, invalid-JSON) and post has its own test. post_raw is now a dependency of resources/search.py pagination, so a regression in raw-response handling would go undetected. Add sync and async post_raw tests mirroring the get_raw/post cases.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/omophub/_request.py, line 178:

<comment>The new public `post_raw` methods (sync and async) have no unit tests, while the parallel `get_raw` methods have full coverage (success, params, error, rate-limit, invalid-JSON) and `post` has its own test. `post_raw` is now a dependency of `resources/search.py` pagination, so a regression in raw-response handling would go undetected. Add sync and async `post_raw` tests mirroring the `get_raw`/`post` cases.</comment>

<file context>
@@ -175,6 +175,29 @@ def post(
         )
         return self._parse_response(content, status_code, headers)
 
+    def post_raw(
+        self,
+        path: str,
</file context>

self,
path: str,
json_data: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Make a POST request and return the full response including ``meta``.

Unlike :meth:`post`, which extracts just the ``data`` field, this keeps
``meta`` — which is where pagination lives. A paginated POST endpoint
served through :meth:`post` leaves the caller unable to tell whether
another page exists.
"""
url = self._build_url(path)
content, status_code, headers = self._http_client.request(
"POST",
url,
headers=self._get_auth_headers(),
params=params,
json=json_data,
)
return self._parse_response_raw(content, status_code, headers)


class AsyncRequest(Generic[T]):
"""Handles async API request execution and response parsing."""
Expand Down Expand Up @@ -273,3 +296,23 @@ async def post(
json=json_data,
)
return self._parse_response(content, status_code, headers)

async def post_raw(
self,
path: str,
json_data: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Make an async POST request returning the full response with ``meta``.

See :meth:`Request.post_raw`.
"""
url = self._build_url(path)
content, status_code, headers = await self._http_client.request(
"POST",
url,
headers=self._get_auth_headers(),
params=params,
json=json_data,
)
return self._parse_response_raw(content, status_code, headers)
18 changes: 10 additions & 8 deletions src/omophub/resources/mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,13 @@ def map(
[{"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
include_invalid: Include invalid mappings. Defaults to False and is
always sent explicitly.
vocab_release: Specific vocabulary release version (e.g., "2025.1")

Returns:
Mapping results with summary
Mapping results with ``mappings``, per-input ``unmapped_sources``,
and a ``summary`` of requested, mapped, and unmapped sources.

Raises:
ValueError: If neither or both source_concepts and source_codes are provided
Expand All @@ -197,8 +199,7 @@ def map(
body["source_codes"] = source_codes
if mapping_type:
body["mapping_type"] = mapping_type
if include_invalid:
body["include_invalid"] = True
body["include_invalid"] = include_invalid

params: dict[str, Any] = {}
if vocab_release:
Expand Down Expand Up @@ -366,11 +367,13 @@ async def map(
[{"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
include_invalid: Include invalid mappings. Defaults to False and is
always sent explicitly.
vocab_release: Specific vocabulary release version (e.g., "2025.1")

Returns:
Mapping results with summary
Mapping results with ``mappings``, per-input ``unmapped_sources``,
and a ``summary`` of requested, mapped, and unmapped sources.

Raises:
ValueError: If neither or both source_concepts and source_codes are provided
Expand All @@ -394,8 +397,7 @@ async def map(
body["source_codes"] = source_codes
if mapping_type:
body["mapping_type"] = mapping_type
if include_invalid:
body["include_invalid"] = True
body["include_invalid"] = include_invalid

params: dict[str, Any] = {}
if vocab_release:
Expand Down
Loading
Loading