diff --git a/CHANGELOG.md b/CHANGELOG.md index 9edc0df..10053f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 1c192e3..0284854 100644 --- a/README.md +++ b/README.md @@ -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"]) + # Navigate concept hierarchy ancestors = client.hierarchy.ancestors(201826, max_levels=3) ``` @@ -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 diff --git a/examples/map_between_vocabularies.py b/examples/map_between_vocabularies.py index 811ac09..0a57eb2 100644 --- a/examples/map_between_vocabularies.py +++ b/examples/map_between_vocabularies.py @@ -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") diff --git a/examples/search_concepts.py b/examples/search_concepts.py index 6b005c2..1945cef 100644 --- a/examples/search_concepts.py +++ b/examples/search_concepts.py @@ -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]: diff --git a/src/omophub/__init__.py b/src/omophub/__init__.py index c19084d..82f8859 100644 --- a/src/omophub/__init__.py +++ b/src/omophub/__init__.py @@ -25,6 +25,7 @@ NotFoundError, OMOPHubError, RateLimitError, + ResponseError, ServerError, TimeoutError, ValidationError, @@ -85,6 +86,7 @@ "RateLimitError", "Relationship", "RelationshipType", + "ResponseError", "SearchFacets", "SearchResult", "ServerError", diff --git a/src/omophub/_exceptions.py b/src/omophub/_exceptions.py index d433d2a..b024c98 100644 --- a/src/omophub/_exceptions.py +++ b/src/omophub/_exceptions.py @@ -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.""" diff --git a/src/omophub/_request.py b/src/omophub/_request.py index 4649bc0..73cf3c0 100644 --- a/src/omophub/_request.py +++ b/src/omophub/_request.py @@ -175,6 +175,29 @@ def post( ) return self._parse_response(content, status_code, headers) + def post_raw( + 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.""" @@ -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) diff --git a/src/omophub/resources/mappings.py b/src/omophub/resources/mappings.py index 8e53e56..2337420 100644 --- a/src/omophub/resources/mappings.py +++ b/src/omophub/resources/mappings.py @@ -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 @@ -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: @@ -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 @@ -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: diff --git a/src/omophub/resources/search.py b/src/omophub/resources/search.py index 4577281..61b3bea 100644 --- a/src/omophub/resources/search.py +++ b/src/omophub/resources/search.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Literal, TypedDict +from .._exceptions import ResponseError from .._pagination import DEFAULT_PAGE_SIZE, paginate_async, paginate_sync if TYPE_CHECKING: @@ -59,6 +60,64 @@ class AdvancedSearchParams(TypedDict, total=False): page_size: int +def _read_suggestions(payload: Any) -> list[Suggestion]: + """Read the suggestions out of an autocomplete payload. + + `/search/suggest` has returned two shapes over its life: a bare list, and + an object with a `suggestions` key. Both are accepted. + + Anything else raises. This used to `return []`, which made a changed + response indistinguishable from "no suggestions matched" - a caller saw an + empty box and had no way to learn the SDK could no longer read the server. + An empty list is a real answer and stays one; an unreadable payload is not. + """ + if isinstance(payload, list): + return payload + + if isinstance(payload, dict): + suggestions = payload.get("suggestions") + if isinstance(suggestions, list): + return suggestions + if suggestions is None: + raise ResponseError( + "autocomplete response has no 'suggestions' key " + f"(keys: {sorted(payload)})", + payload=payload, + ) + raise ResponseError( + "autocomplete 'suggestions' is " + f"{type(suggestions).__name__}, expected list", + payload=payload, + ) + + raise ResponseError( + f"autocomplete response is {type(payload).__name__}, " + "expected a list or an object with a 'suggestions' key", + payload=payload, + ) + + +def _with_pagination(response: dict[str, Any]) -> SimilarSearchResult: + """Attach the envelope's pagination to the similarity result. + + ``/v1/search/similar`` is paginated, but its pagination lives in the + response envelope's ``meta`` while the results live in ``data``. Returning + only ``data`` — as the plain ``post()`` helper does — left the caller with + a ``page`` parameter and no way to tell whether another page existed. + + The pagination is added as a key on the returned result rather than + changing its shape, so ``result["similar_concepts"]`` keeps working. + """ + data = response.get("data", response) + if not isinstance(data, dict): + return data + + pagination = (response.get("meta") or {}).get("pagination") + if pagination is not None: + data = {**data, "pagination": pagination} + return data # type: ignore[return-value] + + class Search: """Synchronous search resource.""" @@ -261,6 +320,7 @@ def autocomplete( query: str, *, vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, domains: list[str] | None = None, page_size: int = 10, ) -> list[Suggestion]: @@ -269,7 +329,8 @@ def autocomplete( Args: query: Partial query string vocabulary_ids: Filter by vocabulary IDs - domains: Filter by domains + domain_ids: Filter by domain IDs + domains: Deprecated alias for ``domain_ids`` page_size: Maximum suggestions to return Returns: @@ -278,10 +339,12 @@ def autocomplete( params: dict[str, Any] = {"query": query, "page_size": page_size} if vocabulary_ids: params["vocabulary_ids"] = ",".join(vocabulary_ids) - if domains: - params["domains"] = ",".join(domains) + selected_domains = domain_ids if domain_ids is not None else domains + if selected_domains: + params["domain_ids"] = ",".join(selected_domains) - return self._request.get("/search/suggest", params=params) + payload = self._request.get("/search/suggest", params=params) + return _read_suggestions(payload) def semantic( self, @@ -455,15 +518,18 @@ def similar( concept_id: int | None = None, concept_name: str | None = None, query: str | None = None, - algorithm: Literal["semantic", "lexical", "hybrid"] = "hybrid", + algorithm: Literal["semantic", "lexical", "hybrid"] = "semantic", similarity_threshold: float = 0.7, + page: int = 1, page_size: int = 20, vocabulary_ids: list[str] | None = None, domain_ids: list[str] | None = None, + concept_class_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, + exclude_self: bool | None = None, ) -> SimilarSearchResult: """Find concepts similar to a reference concept or query. @@ -473,15 +539,26 @@ def similar( 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) + algorithm: 'semantic' (neural, default), 'lexical' (text), or + 'hybrid' (both signals fused) + similarity_threshold: Minimum similarity (0.0-1.0). ``0`` is a + valid value and is honoured. + page: Page of the ranked candidate pool (1-based) + page_size: Results per page (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 + concept_class_ids: Filter by concept class IDs + standard_concept: Filter by standard concept flag. ``'N'`` selects + non-standard concepts, which OMOP stores as a null column. + include_invalid: Include invalid/deprecated concepts. Supported + only with ``algorithm='lexical'``; the embedding index holds + valid concepts only, so the API returns 400 for the other two + rather than ignoring the filter. Defaults to ``False``. + include_scores: Include ``similarity_score`` on each concept + (default true). When false the key is absent. + include_explanations: Include an ``explanation`` on each concept + exclude_self: Exclude the reference concept from its own results + (default true) Returns: Similar concepts with similarity scores and metadata @@ -491,7 +568,13 @@ def similar( is provided. Note: - When algorithm='semantic', only single vocabulary/domain filter supported. + ``total_candidates`` counts the concepts that cleared + ``similarity_threshold`` inside a bounded candidate pool, not the + concepts evaluated, so it and the pagination totals can be lower + bounds - + ``search_metadata['totals_are_lower_bound']`` says when. Page while + ``has_next`` is true rather than comparing ``page`` to + ``total_pages``. """ # Validate exactly one input source provided input_count = sum(x is not None for x in [concept_id, concept_name, query]) @@ -510,12 +593,16 @@ def similar( body["concept_name"] = concept_name if query is not None: body["query"] = query + if page != 1: + body["page"] = page 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 concept_class_ids: + body["concept_class_ids"] = concept_class_ids if standard_concept: body["standard_concept"] = standard_concept if include_invalid is not None: @@ -524,8 +611,12 @@ def similar( body["include_scores"] = include_scores if include_explanations is not None: body["include_explanations"] = include_explanations + if exclude_self is not None: + body["exclude_self"] = exclude_self - return self._request.post("/search/similar", json_data=body) + return _with_pagination( + self._request.post_raw("/search/similar", json_data=body) + ) class AsyncSearch: @@ -619,6 +710,7 @@ async def autocomplete( query: str, *, vocabulary_ids: list[str] | None = None, + domain_ids: list[str] | None = None, domains: list[str] | None = None, page_size: int = 10, ) -> list[Suggestion]: @@ -626,10 +718,12 @@ async def autocomplete( params: dict[str, Any] = {"query": query, "page_size": page_size} if vocabulary_ids: params["vocabulary_ids"] = ",".join(vocabulary_ids) - if domains: - params["domains"] = ",".join(domains) + selected_domains = domain_ids if domain_ids is not None else domains + if selected_domains: + params["domain_ids"] = ",".join(selected_domains) - return await self._request.get("/search/suggest", params=params) + payload = await self._request.get("/search/suggest", params=params) + return _read_suggestions(payload) async def semantic( self, @@ -747,15 +841,18 @@ async def similar( concept_id: int | None = None, concept_name: str | None = None, query: str | None = None, - algorithm: Literal["semantic", "lexical", "hybrid"] = "hybrid", + algorithm: Literal["semantic", "lexical", "hybrid"] = "semantic", similarity_threshold: float = 0.7, + page: int = 1, page_size: int = 20, vocabulary_ids: list[str] | None = None, domain_ids: list[str] | None = None, + concept_class_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, + exclude_self: bool | None = None, ) -> SimilarSearchResult: """Find concepts similar to a reference concept or query. @@ -782,12 +879,16 @@ async def similar( body["concept_name"] = concept_name if query is not None: body["query"] = query + if page != 1: + body["page"] = page 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 concept_class_ids: + body["concept_class_ids"] = concept_class_ids if standard_concept: body["standard_concept"] = standard_concept if include_invalid is not None: @@ -796,5 +897,9 @@ async def similar( body["include_scores"] = include_scores if include_explanations is not None: body["include_explanations"] = include_explanations + if exclude_self is not None: + body["exclude_self"] = exclude_self - return await self._request.post("/search/similar", json_data=body) + return _with_pagination( + await self._request.post_raw("/search/similar", json_data=body) + ) diff --git a/src/omophub/types/__init__.py b/src/omophub/types/__init__.py index f8a2d81..4b855fc 100644 --- a/src/omophub/types/__init__.py +++ b/src/omophub/types/__init__.py @@ -63,8 +63,11 @@ SemanticSearchMeta, SemanticSearchResult, SimilarConcept, + SimilarConceptScores, SimilarSearchMetadata, + SimilarSearchPagination, SimilarSearchResult, + SourceConcept, Suggestion, ) from .vocabulary import ( @@ -127,8 +130,11 @@ "SemanticSearchMeta", "SemanticSearchResult", "SimilarConcept", + "SimilarConceptScores", "SimilarSearchMetadata", + "SimilarSearchPagination", "SimilarSearchResult", + "SourceConcept", "Suggestion", "Synonym", "Vocabulary", diff --git a/src/omophub/types/search.py b/src/omophub/types/search.py index d761b08..131a8d7 100644 --- a/src/omophub/types/search.py +++ b/src/omophub/types/search.py @@ -10,15 +10,17 @@ from .concept import Concept -class Suggestion(TypedDict): - """Autocomplete suggestion.""" +class Suggestion(TypedDict, total=False): + """Autocomplete suggestion, including optional enriched concept metadata.""" - suggestion: str - type: NotRequired[str] - match_type: NotRequired[str] - match_score: NotRequired[float] - concept_id: NotRequired[int] - vocabulary_id: NotRequired[str] + suggestion: Required[str] + concept_id: int + concept_code: str + vocabulary_id: str + domain_id: str + concept_class_id: str + standard_concept: str | None + context: NotRequired[dict[str, str]] class SemanticSearchResult(TypedDict): @@ -43,6 +45,14 @@ class SemanticSearchMeta(TypedDict, total=False): filters_applied: dict[str, Any] +class SimilarConceptScores(TypedDict, total=False): + """Per-signal scores behind a fused similarity score.""" + + semantic: float + lexical: float + hybrid: float + + class SimilarConcept(TypedDict): """A concept similar to the query concept.""" @@ -53,8 +63,14 @@ class SimilarConcept(TypedDict): concept_class_id: str standard_concept: str | None concept_code: str - similarity_score: float + # Optional because ``include_scores=False`` removes it. It was typed as + # required, which made that documented option a type error. + similarity_score: NotRequired[float] matched_text: NotRequired[str] + scores: NotRequired[SimilarConceptScores] + explanation: NotRequired[str] + #: Deprecated alias for ``explanation``, still emitted by the API for one + #: release. Read ``explanation``. similarity_explanation: NotRequired[str] @@ -64,10 +80,51 @@ class SimilarSearchMetadata(TypedDict, total=False): original_query: str algorithm_used: str similarity_threshold: float + #: How many concepts cleared ``similarity_threshold`` inside the bounded + #: retrieval pool - not how many were evaluated. The pool holds up to 500 + #: and everything below the threshold is discarded before this is counted. total_candidates: int results_returned: int processing_time_ms: int embedding_latency_ms: int + #: True when retrieval hit its candidate bound, so ``total_candidates`` and + #: the pagination totals count only what qualified inside the pool that was + #: searched, not the whole corpus. + totals_are_lower_bound: bool + #: The algorithm that was requested, when a fallback served the request + #: instead (``hybrid`` degrading to ``lexical`` when the embedding service + #: is unavailable). + degraded_from: str + source_concept_id: int + + +class SourceConcept(TypedDict, total=False): + """The reference concept a similarity search started from.""" + + concept_id: int + concept_name: str + concept_code: str + vocabulary_id: str + domain_id: str + concept_class_id: str + standard_concept: str | None + + +class SimilarSearchPagination(TypedDict, total=False): + """Pagination for a similarity search. + + Lifted here from the response envelope's ``meta``. Page while ``has_next`` + is true rather than comparing ``page`` to ``total_pages``: every algorithm + ranks a bounded candidate pool, so the totals may be lower bounds (see + ``SimilarSearchMetadata.totals_are_lower_bound``). + """ + + page: int + page_size: int + total_items: int + total_pages: int + has_next: bool + has_previous: bool class SimilarSearchResult(TypedDict): @@ -75,6 +132,8 @@ class SimilarSearchResult(TypedDict): similar_concepts: list[SimilarConcept] search_metadata: SimilarSearchMetadata + source_concept: NotRequired[SourceConcept] + pagination: NotRequired[SimilarSearchPagination] # --------------------------------------------------------------------------- diff --git a/tests/unit/resources/test_mappings.py b/tests/unit/resources/test_mappings.py index 7b19131..ec174f1 100644 --- a/tests/unit/resources/test_mappings.py +++ b/tests/unit/resources/test_mappings.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING import pytest @@ -267,8 +268,9 @@ def test_map_concepts(self, sync_client: OMOPHub, base_url: str) -> None: ) assert "mappings" in result - # Verify request body was sent - assert route.calls[0].request.content + # The SDK's declared False default is part of the wire contract. + body = json.loads(route.calls[0].request.content) + assert body["include_invalid"] is False @respx.mock def test_map_concepts_with_options( @@ -287,15 +289,14 @@ def test_map_concepts_with_options( ) # Verify POST body - assert route.calls[0].request.content + body = json.loads(route.calls[0].request.content) + assert body["include_invalid"] is True @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": { @@ -423,7 +424,7 @@ async def test_async_map_concepts( self, async_client: omophub.AsyncOMOPHub, base_url: str ) -> None: """Test async mapping concepts.""" - respx.post(f"{base_url}/concepts/map").mock( + route = respx.post(f"{base_url}/concepts/map").mock( return_value=Response(200, json={"success": True, "data": {"mappings": []}}) ) @@ -433,6 +434,8 @@ async def test_async_map_concepts( ) assert "mappings" in result + body = json.loads(route.calls[0].request.content) + assert body["include_invalid"] is False @pytest.mark.asyncio @respx.mock @@ -451,7 +454,8 @@ async def test_async_map_concepts_with_options( include_invalid=True, ) - assert route.calls[0].request.content + body = json.loads(route.calls[0].request.content) + assert body["include_invalid"] is True @pytest.mark.asyncio @respx.mock @@ -459,8 +463,6 @@ 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": []}}) ) diff --git a/tests/unit/resources/test_search.py b/tests/unit/resources/test_search.py index 01719b5..de30c2b 100644 --- a/tests/unit/resources/test_search.py +++ b/tests/unit/resources/test_search.py @@ -8,6 +8,8 @@ import respx from httpx import Response +from omophub import ResponseError + if TYPE_CHECKING: import omophub from omophub import OMOPHub @@ -159,10 +161,29 @@ def test_autocomplete(self, sync_client: OMOPHub, base_url: str) -> None: """Test autocomplete suggestions.""" autocomplete_response = { "success": True, - "data": [ - {"suggestion": "diabetes mellitus", "type": "concept_name"}, - {"suggestion": "diabetic", "type": "concept_name"}, - ], + "data": { + "query": "diab", + "suggestions": [ + { + "suggestion": "diabetes mellitus", + "concept_id": 201826, + "concept_code": "44054006", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "concept_class_id": "Clinical Finding", + "standard_concept": "S", + }, + { + "suggestion": "diabetic", + "concept_id": 123, + "concept_code": "123", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "concept_class_id": "Clinical Finding", + "standard_concept": "S", + }, + ], + }, } route = respx.get(f"{base_url}/search/suggest").mock( return_value=Response(200, json=autocomplete_response) @@ -171,7 +192,7 @@ def test_autocomplete(self, sync_client: OMOPHub, base_url: str) -> None: result = sync_client.search.autocomplete( "diab", vocabulary_ids=["SNOMED"], - domains=["Condition"], + domain_ids=["Condition"], page_size=5, ) @@ -179,9 +200,90 @@ def test_autocomplete(self, sync_client: OMOPHub, base_url: str) -> None: url_str = str(route.calls[0].request.url) assert "query=diab" in url_str assert "vocabulary_ids=SNOMED" in url_str - assert "domains=Condition" in url_str + assert "domain_ids=Condition" in url_str assert "page_size=5" in url_str + @respx.mock + def test_autocomplete_accepts_a_bare_list( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """The older response shape stays supported.""" + respx.get(f"{base_url}/search/suggest").mock( + return_value=Response( + 200, + json={"success": True, "data": [{"suggestion": "diabetes"}]}, + ) + ) + + assert len(sync_client.search.autocomplete("diab")) == 1 + + @respx.mock + def test_autocomplete_returns_empty_for_no_matches( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """An empty list is a real answer and must not raise.""" + respx.get(f"{base_url}/search/suggest").mock( + return_value=Response( + 200, + json={"success": True, "data": {"query": "zzz", "suggestions": []}}, + ) + ) + + assert sync_client.search.autocomplete("zzz") == [] + + @respx.mock + @pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"query": "diab"}, "no 'suggestions' key"), + ({"suggestions": {"a": 1}}, "expected list"), + ("diabetes", "expected a list or an object"), + ], + ) + def test_autocomplete_raises_on_an_unreadable_payload( + self, + sync_client: OMOPHub, + base_url: str, + payload: object, + expected: str, + ) -> None: + """Protocol drift must not read as "no suggestions". + + Returning [] here made a changed response indistinguishable from an + empty result: the caller saw an empty box with no way to learn the SDK + could no longer read the server. + """ + respx.get(f"{base_url}/search/suggest").mock( + return_value=Response(200, json={"success": True, "data": payload}) + ) + + with pytest.raises(ResponseError) as excinfo: + sync_client.search.autocomplete("diab") + + assert expected in str(excinfo.value) + assert excinfo.value.payload == payload + + @respx.mock + def test_autocomplete_maps_deprecated_domains_alias( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """The old SDK option remains compatible without leaking to the API.""" + route = respx.get(f"{base_url}/search/suggest").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"query": "diab", "suggestions": []}, + }, + ) + ) + + sync_client.search.autocomplete("diab", domains=["Condition"]) + + url_str = str(route.calls[0].request.url) + assert "domain_ids=Condition" in url_str + assert "domains=" not in url_str + class TestAsyncSearchResource: """Tests for the asynchronous AsyncSearch resource.""" @@ -256,6 +358,21 @@ async def test_async_advanced_search( assert "concepts" in result + @pytest.mark.asyncio + @respx.mock + async def test_async_autocomplete_raises_on_an_unreadable_payload( + self, async_client: omophub.AsyncOMOPHub, base_url: str + ) -> None: + """The async path shares the sync path's reader, and its contract.""" + respx.get(f"{base_url}/search/suggest").mock( + return_value=Response( + 200, json={"success": True, "data": {"query": "asp"}} + ) + ) + + with pytest.raises(ResponseError): + await async_client.search.autocomplete("asp") + @pytest.mark.asyncio @respx.mock async def test_async_autocomplete( @@ -264,7 +381,20 @@ async def test_async_autocomplete( """Test async autocomplete.""" autocomplete_response = { "success": True, - "data": [{"suggestion": "aspirin", "type": "concept_name"}], + "data": { + "query": "asp", + "suggestions": [ + { + "suggestion": "aspirin", + "concept_id": 111, + "concept_code": "111", + "vocabulary_id": "RxNorm", + "domain_id": "Drug", + "concept_class_id": "Ingredient", + "standard_concept": "S", + } + ], + }, } respx.get(f"{base_url}/search/suggest").mock( return_value=Response(200, json=autocomplete_response) @@ -433,10 +563,11 @@ def test_similar_by_concept_id(self, sync_client: OMOPHub, base_url: str) -> Non ], "search_metadata": { "original_query": "4329847", - "algorithm_used": "hybrid", + "algorithm_used": "semantic", "similarity_threshold": 0.7, "total_candidates": 100, "results_returned": 1, + "totals_are_lower_bound": False, }, }, } @@ -453,9 +584,70 @@ def test_similar_by_concept_id(self, sync_client: OMOPHub, base_url: str) -> Non body = json.loads(route.calls[0].request.content) assert body["concept_id"] == 4329847 - assert body["algorithm"] == "hybrid" + # The API's documented default. The SDK used to send "hybrid", so a + # caller who omitted `algorithm` got a different algorithm depending on + # which client they used. + assert body["algorithm"] == "semantic" assert body["similarity_threshold"] == 0.7 + @respx.mock + def test_similar_exposes_pagination( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Pagination lives in the envelope's meta, not in data. + + Returning only `data` left callers with a `page` argument and no way to + tell whether another page existed. + """ + respx.post(f"{base_url}/search/similar").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": { + "similar_concepts": [], + "search_metadata": {"algorithm_used": "semantic"}, + }, + "meta": { + "pagination": { + "page": 2, + "page_size": 20, + "total_items": 55, + "total_pages": 3, + "has_next": True, + "has_previous": True, + } + }, + }, + ) + ) + + result = sync_client.search.similar(concept_id=4329847, page=2) + + assert result["pagination"]["has_next"] is True + assert result["pagination"]["page"] == 2 + # The existing shape is unchanged. + assert "similar_concepts" in result + + @respx.mock + def test_similar_without_pagination_meta( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """A response carrying no pagination must not grow an empty key.""" + respx.post(f"{base_url}/search/similar").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"similar_concepts": [], "search_metadata": {}}, + }, + ) + ) + + result = sync_client.search.similar(concept_id=4329847) + + assert "pagination" not in result + @respx.mock def test_similar_by_concept_name( self, sync_client: OMOPHub, base_url: str @@ -752,7 +944,7 @@ async def test_async_similar( "success": True, "data": { "similar_concepts": [{"concept_id": 1234, "similarity_score": 0.85}], - "search_metadata": {"algorithm_used": "hybrid"}, + "search_metadata": {"algorithm_used": "semantic"}, }, } respx.post(f"{base_url}/search/similar").mock( diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py index 5a652f9..9aeadb0 100644 --- a/tests/unit/test_request.py +++ b/tests/unit/test_request.py @@ -337,6 +337,83 @@ def test_get_raw_json_decode_error(self, request_handler: Request) -> None: request_handler.get_raw("/test") assert "Invalid JSON" in str(exc_info.value) + def test_post_raw_request(self, request_handler: Request) -> None: + """Test post_raw returns the full response with data and meta.""" + with respx.mock: + route = respx.post("https://api.example.com/v1/search/similar").mock( + return_value=Response( + 200, + json={ + "data": {"results": [{"concept_id": 1}]}, + "meta": {"pagination": {"page": 2, "has_next": True}}, + }, + ) + ) + + result = request_handler.post_raw( + "/search/similar", json_data={"concept_id": 1, "page": 2} + ) + + assert route.calls[0].request.content == b'{"concept_id":1,"page":2}' + assert result["data"]["results"][0]["concept_id"] == 1 + assert result["meta"]["pagination"]["page"] == 2 + + def test_post_raw_with_params(self, request_handler: Request) -> None: + """Test post_raw passes query parameters correctly.""" + with respx.mock: + route = respx.post("https://api.example.com/v1/search/similar").mock( + return_value=Response(200, json={"data": [], "meta": {}}) + ) + + request_handler.post_raw( + "/search/similar", + json_data={"concept_id": 1}, + params={"vocab_release": "2026.2"}, + ) + + assert "vocab_release=2026.2" in str(route.calls[0].request.url) + + def test_post_raw_error_parsing(self, request_handler: Request) -> None: + """Test post_raw raises parsed API errors.""" + with respx.mock: + respx.post("https://api.example.com/v1/test").mock( + return_value=Response( + 404, + json={"error": {"message": "Not found"}}, + headers={"X-Request-Id": "req_post_123"}, + ) + ) + + with pytest.raises(NotFoundError) as exc_info: + request_handler.post_raw("/test") + assert exc_info.value.request_id == "req_post_123" + + def test_post_raw_rate_limit(self, request_handler: Request) -> None: + """Test post_raw preserves rate-limit retry metadata.""" + with respx.mock: + respx.post("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.post_raw("/test") + assert exc_info.value.retry_after == 45 + + def test_post_raw_json_decode_error(self, request_handler: Request) -> None: + """Test post_raw handles invalid JSON.""" + with respx.mock: + respx.post("https://api.example.com/v1/test").mock( + return_value=Response(200, content=b"not json") + ) + + with pytest.raises(OMOPHubError) as exc_info: + request_handler.post_raw("/test") + assert "Invalid JSON" in str(exc_info.value) + class TestAsyncRequest: """Tests for asynchronous AsyncRequest class.""" @@ -566,3 +643,91 @@ async def test_async_get_raw_json_decode_error( with pytest.raises(OMOPHubError) as exc_info: await request_handler.get_raw("/test") assert "Invalid JSON" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_async_post_raw_request(self, request_handler: AsyncRequest) -> None: + """Test async post_raw returns the full response with data and meta.""" + with respx.mock: + route = respx.post("https://api.example.com/v1/search/similar").mock( + return_value=Response( + 200, + json={ + "data": {"results": [{"concept_id": 1}]}, + "meta": {"pagination": {"page": 2, "has_next": True}}, + }, + ) + ) + + result = await request_handler.post_raw( + "/search/similar", json_data={"concept_id": 1, "page": 2} + ) + + assert route.calls[0].request.content == b'{"concept_id":1,"page":2}' + assert result["data"]["results"][0]["concept_id"] == 1 + assert result["meta"]["pagination"]["page"] == 2 + + @pytest.mark.asyncio + async def test_async_post_raw_with_params( + self, request_handler: AsyncRequest + ) -> None: + """Test async post_raw passes query parameters correctly.""" + with respx.mock: + route = respx.post("https://api.example.com/v1/search/similar").mock( + return_value=Response(200, json={"data": [], "meta": {}}) + ) + + await request_handler.post_raw( + "/search/similar", + json_data={"concept_id": 1}, + params={"vocab_release": "2026.2"}, + ) + + assert "vocab_release=2026.2" in str(route.calls[0].request.url) + + @pytest.mark.asyncio + async def test_async_post_raw_error(self, request_handler: AsyncRequest) -> None: + """Test async post_raw raises parsed API errors.""" + with respx.mock: + respx.post("https://api.example.com/v1/test").mock( + return_value=Response( + 404, + json={"error": {"message": "Not found"}}, + headers={"X-Request-Id": "req_async_post_456"}, + ) + ) + + with pytest.raises(NotFoundError) as exc_info: + await request_handler.post_raw("/test") + assert exc_info.value.request_id == "req_async_post_456" + + @pytest.mark.asyncio + async def test_async_post_raw_rate_limit( + self, request_handler: AsyncRequest + ) -> None: + """Test async post_raw preserves rate-limit retry metadata.""" + with respx.mock: + respx.post("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.post_raw("/test") + assert exc_info.value.retry_after == 60 + + @pytest.mark.asyncio + async def test_async_post_raw_json_decode_error( + self, request_handler: AsyncRequest + ) -> None: + """Test async post_raw handles invalid JSON.""" + with respx.mock: + respx.post("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.post_raw("/test") + assert "Invalid JSON" in str(exc_info.value)