Skip to content

Commit 0a64db9

Browse files
author
alex-omophub
committed
Prepare release v1.3.1
1 parent 7bdf440 commit 0a64db9

5 files changed

Lines changed: 243 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ 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.3.1] - 2026-01-24
9+
10+
### Fixed
11+
12+
- Fixed `search.basic_iter()` pagination bug that caused only the first page of results to be returned. The iterator now correctly fetches all pages when iterating through search results.
13+
14+
### Changed
15+
16+
- Added `get_raw()` method to internal request classes for retrieving full API responses with pagination metadata.
17+
- Expanded `search.basic_iter()` method signature to explicitly list all filter parameters instead of using `**kwargs`.
18+
819
## [1.3.0] - 2026-01-06
920

1021
### Changes
@@ -72,7 +83,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7283
- Full type hints and PEP 561 compliance
7384
- HTTP/2 support via httpx
7485

75-
[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.3.0...HEAD
86+
[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.3.1...HEAD
87+
[1.3.1]: https://github.com/omopHub/omophub-python/compare/v1.3.0...v1.3.1
7688
[1.3.0]: https://github.com/omopHub/omophub-python/compare/v1.2.0...v1.3.0
7789
[1.2.0]: https://github.com/omopHub/omophub-python/compare/v0.1.0...v1.2.0
7890
[0.1.0]: https://github.com/omopHub/omophub-python/releases/tag/v0.1.0

src/omophub/_request.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,62 @@ def _parse_response(
9797
response: APIResponse = data # type: ignore[assignment]
9898
return response.get("data", data)
9999

100+
def _parse_response_raw(
101+
self,
102+
content: bytes,
103+
status_code: int,
104+
headers: Mapping[str, str],
105+
) -> dict[str, Any]:
106+
"""Parse API response and return full response dict with meta.
107+
108+
Unlike _parse_response which extracts just the 'data' field,
109+
this method returns the complete response including 'meta' for pagination.
110+
"""
111+
request_id = headers.get("X-Request-Id") or headers.get("x-request-id")
112+
113+
try:
114+
data = json.loads(content) if content else {}
115+
except json.JSONDecodeError as exc:
116+
if status_code >= 400:
117+
raise_for_status(
118+
status_code,
119+
f"Request failed with status {status_code}",
120+
request_id=request_id,
121+
)
122+
raise OMOPHubError(
123+
f"Invalid JSON response: {content[:200].decode(errors='replace')}"
124+
) from exc
125+
126+
# Handle error responses
127+
if status_code >= 400:
128+
error_response: ErrorResponse = data # type: ignore[assignment]
129+
error = error_response.get("error", {})
130+
message = error.get("message", f"Request failed with status {status_code}")
131+
error_code = error.get("code")
132+
details = error.get("details")
133+
134+
# Check for rate limit retry-after
135+
retry_after = None
136+
if status_code == 429:
137+
retry_after_header = headers.get("Retry-After") or headers.get(
138+
"retry-after"
139+
)
140+
if retry_after_header:
141+
with contextlib.suppress(ValueError):
142+
retry_after = int(retry_after_header)
143+
144+
raise_for_status(
145+
status_code,
146+
message,
147+
request_id=request_id,
148+
error_code=error_code,
149+
details=details,
150+
retry_after=retry_after,
151+
)
152+
153+
# Return full response dict (includes 'data' and 'meta')
154+
return data
155+
100156
def get(
101157
self,
102158
path: str,
@@ -112,6 +168,25 @@ def get(
112168
)
113169
return self._parse_response(content, status_code, headers)
114170

171+
def get_raw(
172+
self,
173+
path: str,
174+
params: dict[str, Any] | None = None,
175+
) -> dict[str, Any]:
176+
"""Make a GET request and return full response with meta.
177+
178+
Unlike get() which extracts just the 'data' field,
179+
this method returns the complete response including 'meta' for pagination.
180+
"""
181+
url = self._build_url(path)
182+
content, status_code, headers = self._http_client.request(
183+
"GET",
184+
url,
185+
headers=self._get_auth_headers(),
186+
params=params,
187+
)
188+
return self._parse_response_raw(content, status_code, headers)
189+
115190
def post(
116191
self,
117192
path: str,
@@ -210,6 +285,62 @@ def _parse_response(
210285
response: APIResponse = data # type: ignore[assignment]
211286
return response.get("data", data)
212287

288+
def _parse_response_raw(
289+
self,
290+
content: bytes,
291+
status_code: int,
292+
headers: Mapping[str, str],
293+
) -> dict[str, Any]:
294+
"""Parse API response and return full response dict with meta.
295+
296+
Unlike _parse_response which extracts just the 'data' field,
297+
this method returns the complete response including 'meta' for pagination.
298+
"""
299+
request_id = headers.get("X-Request-Id") or headers.get("x-request-id")
300+
301+
try:
302+
data = json.loads(content) if content else {}
303+
except json.JSONDecodeError as exc:
304+
if status_code >= 400:
305+
raise_for_status(
306+
status_code,
307+
f"Request failed with status {status_code}",
308+
request_id=request_id,
309+
)
310+
raise OMOPHubError(
311+
f"Invalid JSON response: {content[:200].decode(errors='replace')}"
312+
) from exc
313+
314+
# Handle error responses
315+
if status_code >= 400:
316+
error_response: ErrorResponse = data # type: ignore[assignment]
317+
error = error_response.get("error", {})
318+
message = error.get("message", f"Request failed with status {status_code}")
319+
error_code = error.get("code")
320+
details = error.get("details")
321+
322+
# Check for rate limit retry-after
323+
retry_after = None
324+
if status_code == 429:
325+
retry_after_header = headers.get("Retry-After") or headers.get(
326+
"retry-after"
327+
)
328+
if retry_after_header:
329+
with contextlib.suppress(ValueError):
330+
retry_after = int(retry_after_header)
331+
332+
raise_for_status(
333+
status_code,
334+
message,
335+
request_id=request_id,
336+
error_code=error_code,
337+
details=details,
338+
retry_after=retry_after,
339+
)
340+
341+
# Return full response dict (includes 'data' and 'meta')
342+
return data
343+
213344
async def get(
214345
self,
215346
path: str,
@@ -225,6 +356,25 @@ async def get(
225356
)
226357
return self._parse_response(content, status_code, headers)
227358

359+
async def get_raw(
360+
self,
361+
path: str,
362+
params: dict[str, Any] | None = None,
363+
) -> dict[str, Any]:
364+
"""Make an async GET request and return full response with meta.
365+
366+
Unlike get() which extracts just the 'data' field,
367+
this method returns the complete response including 'meta' for pagination.
368+
"""
369+
url = self._build_url(path)
370+
content, status_code, headers = await self._http_client.request(
371+
"GET",
372+
url,
373+
headers=self._get_auth_headers(),
374+
params=params,
375+
)
376+
return self._parse_response_raw(content, status_code, headers)
377+
228378
async def post(
229379
self,
230380
path: str,

src/omophub/resources/search.py

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -125,17 +125,31 @@ def basic_iter(
125125
*,
126126
vocabulary_ids: list[str] | None = None,
127127
domain_ids: list[str] | None = None,
128+
concept_class_ids: list[str] | None = None,
129+
standard_concept: str | None = None,
130+
include_synonyms: bool = False,
131+
include_invalid: bool = False,
132+
min_score: float | None = None,
133+
exact_match: bool = False,
128134
page_size: int = DEFAULT_PAGE_SIZE,
129-
**kwargs: Any,
135+
sort_by: str | None = None,
136+
sort_order: str | None = None,
130137
) -> Iterator[Concept]:
131138
"""Iterate through all search results with auto-pagination.
132139
133140
Args:
134141
query: Search query string
135142
vocabulary_ids: Filter by vocabulary IDs
136143
domain_ids: Filter by domain IDs
144+
concept_class_ids: Filter by concept class IDs
145+
standard_concept: Filter by standard concept ("S", "C", or None)
146+
include_synonyms: Search in synonyms
147+
include_invalid: Include invalid concepts
148+
min_score: Minimum relevance score
149+
exact_match: Require exact match
137150
page_size: Results per page
138-
**kwargs: Additional search parameters
151+
sort_by: Sort field
152+
sort_order: Sort order ("asc" or "desc")
139153
140154
Yields:
141155
Individual concepts from all pages
@@ -144,22 +158,40 @@ def basic_iter(
144158
def fetch_page(
145159
page: int, size: int
146160
) -> tuple[list[Concept], PaginationMeta | None]:
147-
result = self.basic(
148-
query,
149-
vocabulary_ids=vocabulary_ids,
150-
domain_ids=domain_ids,
151-
page=page,
152-
page_size=size,
153-
**kwargs,
154-
)
155-
concepts = (
156-
result.get("concepts", result) if isinstance(result, dict) else result
157-
)
158-
meta = (
159-
result.get("meta", {}).get("pagination")
160-
if isinstance(result, dict)
161-
else None
162-
)
161+
# Build params manually to use get_raw() for full response with meta
162+
params: dict[str, Any] = {
163+
"query": query,
164+
"page": page,
165+
"page_size": size,
166+
}
167+
if vocabulary_ids:
168+
params["vocabulary_ids"] = ",".join(vocabulary_ids)
169+
if domain_ids:
170+
params["domain_ids"] = ",".join(domain_ids)
171+
if concept_class_ids:
172+
params["concept_class_ids"] = ",".join(concept_class_ids)
173+
if standard_concept:
174+
params["standard_concept"] = standard_concept
175+
if include_synonyms:
176+
params["include_synonyms"] = "true"
177+
if include_invalid:
178+
params["include_invalid"] = "true"
179+
if min_score is not None:
180+
params["min_score"] = min_score
181+
if exact_match:
182+
params["exact_match"] = "true"
183+
if sort_by:
184+
params["sort_by"] = sort_by
185+
if sort_order:
186+
params["sort_order"] = sort_order
187+
188+
# Use get_raw() to preserve pagination metadata
189+
result = self._request.get_raw("/search/concepts", params=params)
190+
191+
# Extract concepts from 'data' field (may be list or dict with 'concepts')
192+
data = result.get("data", [])
193+
concepts = data.get("concepts", data) if isinstance(data, dict) else data
194+
meta = result.get("meta", {}).get("pagination")
163195
return concepts, meta
164196

165197
yield from paginate_sync(fetch_page, page_size)

tests/integration/test_search.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,18 +131,32 @@ def test_autocomplete_with_filters(self, integration_client: OMOPHub) -> None:
131131
assert isinstance(suggestions, list)
132132

133133
def test_basic_iter_pagination(self, integration_client: OMOPHub) -> None:
134-
"""Test auto-pagination with basic_iter."""
135-
# Collect first 5 concepts using iterator
134+
"""Test auto-pagination with basic_iter.
135+
136+
This test verifies that basic_iter correctly fetches multiple pages
137+
of results. With page_size=2, we should be able to collect 5 concepts
138+
which requires fetching at least 3 pages, proving pagination works.
139+
"""
140+
# Collect concepts using iterator with small page size
136141
concepts = []
142+
page_size = 2
143+
max_concepts = 5
144+
137145
for concept in integration_client.search.basic_iter(
138146
"diabetes",
139-
page_size=2, # Small page size to test pagination
147+
page_size=page_size, # Small page size to test pagination
140148
):
141149
concepts.append(concept)
142-
if len(concepts) >= 5:
150+
if len(concepts) >= max_concepts:
143151
break
144152

145-
assert len(concepts) == 5
153+
# Should get requested number of concepts (proves pagination worked)
154+
assert len(concepts) == max_concepts, (
155+
f"Expected {max_concepts} concepts from paginated iterator, "
156+
f"got {len(concepts)}. With page_size={page_size}, getting only "
157+
f"{len(concepts)} concepts suggests pagination is broken."
158+
)
159+
146160
# All should have concept_id
147161
for concept in concepts:
148162
assert "concept_id" in concept

tests/unit/resources/test_search.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,14 @@ def test_basic_search_with_filters(
7777
@respx.mock
7878
def test_basic_iter_single_page(self, sync_client: OMOPHub, base_url: str) -> None:
7979
"""Test basic_iter with single page of results."""
80+
# Note: meta is at top level, not nested inside data
8081
search_response = {
8182
"success": True,
82-
"data": {
83-
"concepts": [
84-
{"concept_id": 201826, "concept_name": "Type 2 diabetes mellitus"},
85-
{"concept_id": 201820, "concept_name": "Diabetes mellitus"},
86-
],
87-
"meta": {"pagination": {"page": 1, "has_next": False}},
88-
},
83+
"data": [
84+
{"concept_id": 201826, "concept_name": "Type 2 diabetes mellitus"},
85+
{"concept_id": 201820, "concept_name": "Diabetes mellitus"},
86+
],
87+
"meta": {"pagination": {"page": 1, "has_next": False}},
8988
}
9089
respx.get(f"{base_url}/search/concepts").mock(
9190
return_value=Response(200, json=search_response)
@@ -99,19 +98,16 @@ def test_basic_iter_multiple_pages(
9998
self, sync_client: OMOPHub, base_url: str
10099
) -> None:
101100
"""Test basic_iter auto-pagination across multiple pages."""
101+
# Note: meta is at top level, not nested inside data
102102
page1_response = {
103103
"success": True,
104-
"data": {
105-
"concepts": [{"concept_id": 1}],
106-
"meta": {"pagination": {"page": 1, "has_next": True}},
107-
},
104+
"data": [{"concept_id": 1}],
105+
"meta": {"pagination": {"page": 1, "has_next": True}},
108106
}
109107
page2_response = {
110108
"success": True,
111-
"data": {
112-
"concepts": [{"concept_id": 2}],
113-
"meta": {"pagination": {"page": 2, "has_next": False}},
114-
},
109+
"data": [{"concept_id": 2}],
110+
"meta": {"pagination": {"page": 2, "has_next": False}},
115111
}
116112

117113
call_count = 0

0 commit comments

Comments
 (0)