Skip to content

Commit 55255df

Browse files
author
alex-omophub
committed
Update CHANGELOG for v1.5.1 release and adjust Python version requirement in CONTRIBUTING.md. Refactor error handling in API request processing by introducing a shared _parse_and_raise function to streamline JSON response parsing and error management. Enhance async search functionality with pagination support and update type exports in __init__.py.
1 parent cca1f05 commit 55255df

5 files changed

Lines changed: 83 additions & 204 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
117117
- Full type hints and PEP 561 compliance
118118
- HTTP/2 support via httpx
119119

120-
[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.4.1...HEAD
120+
[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.5.1...HEAD
121+
[1.5.1]: https://github.com/omopHub/omophub-python/compare/v1.5.0...v1.5.1
122+
[1.5.0]: https://github.com/omopHub/omophub-python/compare/v1.4.1...v1.5.0
121123
[1.4.1]: https://github.com/omopHub/omophub-python/compare/v1.4.0...v1.4.1
122124
[1.4.0]: https://github.com/omopHub/omophub-python/compare/v1.3.1...v1.4.0
123125
[1.3.1]: https://github.com/omopHub/omophub-python/compare/v1.3.0...v1.3.1

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Feature requests are welcome! Please open an issue with:
5353

5454
### Prerequisites
5555

56-
- Python 3.9+
56+
- Python 3.10+
5757
- pip
5858

5959
### Installation

src/omophub/_request.py

Lines changed: 68 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,66 @@
1717
T = TypeVar("T")
1818

1919

20+
def _parse_and_raise(
21+
content: bytes,
22+
status_code: int,
23+
headers: Mapping[str, str],
24+
) -> dict[str, Any]:
25+
"""Parse JSON response body and raise on HTTP errors.
26+
27+
Shared by both sync and async request classes to avoid duplicating
28+
the JSON-decode, error-extraction, and rate-limit-retry logic.
29+
30+
Returns:
31+
The parsed JSON dict (caller decides whether to unwrap ``data``).
32+
33+
Raises:
34+
OMOPHubError: On invalid JSON from a successful response.
35+
APIError / RateLimitError / etc.: On HTTP error status codes.
36+
"""
37+
request_id = headers.get("X-Request-Id") or headers.get("x-request-id")
38+
39+
try:
40+
data = json.loads(content) if content else {}
41+
except json.JSONDecodeError as exc:
42+
if status_code >= 400:
43+
raise_for_status(
44+
status_code,
45+
f"Request failed with status {status_code}",
46+
request_id=request_id,
47+
)
48+
raise OMOPHubError(
49+
f"Invalid JSON response: {content[:200].decode(errors='replace')}"
50+
) from exc
51+
52+
if status_code >= 400:
53+
error_response: ErrorResponse = data # type: ignore[assignment]
54+
error = error_response.get("error", {})
55+
message = error.get("message", f"Request failed with status {status_code}")
56+
error_code = error.get("code")
57+
details = error.get("details")
58+
59+
retry_after = None
60+
if status_code == 429:
61+
retry_after_header = headers.get("Retry-After") or headers.get(
62+
"retry-after"
63+
)
64+
if retry_after_header:
65+
with contextlib.suppress(ValueError):
66+
retry_after = int(retry_after_header)
67+
68+
raise_for_status(
69+
status_code,
70+
message,
71+
request_id=request_id,
72+
error_code=error_code,
73+
details=details,
74+
retry_after=retry_after,
75+
)
76+
77+
return data # type: ignore[return-value]
78+
79+
2080
class Request(Generic[T]):
2181
"""Handles API request execution and response parsing."""
2282

@@ -50,50 +110,8 @@ def _parse_response(
50110
status_code: int,
51111
headers: Mapping[str, str],
52112
) -> T:
53-
"""Parse API response and handle errors."""
54-
request_id = headers.get("X-Request-Id") or headers.get("x-request-id")
55-
56-
try:
57-
data = json.loads(content) if content else {}
58-
except json.JSONDecodeError as exc:
59-
if status_code >= 400:
60-
raise_for_status(
61-
status_code,
62-
f"Request failed with status {status_code}",
63-
request_id=request_id,
64-
)
65-
raise OMOPHubError(
66-
f"Invalid JSON response: {content[:200].decode(errors='replace')}"
67-
) from exc
68-
69-
# Handle error responses
70-
if status_code >= 400:
71-
error_response: ErrorResponse = data # type: ignore[assignment]
72-
error = error_response.get("error", {})
73-
message = error.get("message", f"Request failed with status {status_code}")
74-
error_code = error.get("code")
75-
details = error.get("details")
76-
77-
# Check for rate limit retry-after
78-
retry_after = None
79-
if status_code == 429:
80-
retry_after_header = headers.get("Retry-After") or headers.get(
81-
"retry-after"
82-
)
83-
if retry_after_header:
84-
with contextlib.suppress(ValueError):
85-
retry_after = int(retry_after_header)
86-
87-
raise_for_status(
88-
status_code,
89-
message,
90-
request_id=request_id,
91-
error_code=error_code,
92-
details=details,
93-
retry_after=retry_after,
94-
)
95-
96-
# Return successful response data
113+
"""Parse API response, raise on errors, return the ``data`` field."""
114+
data = _parse_and_raise(content, status_code, headers)
97115
response: APIResponse = data # type: ignore[assignment]
98116
return response.get("data", data)
99117

@@ -103,55 +121,8 @@ def _parse_response_raw(
103121
status_code: int,
104122
headers: Mapping[str, str],
105123
) -> 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
124+
"""Parse API response, raise on errors, return the full dict with ``meta``."""
125+
return _parse_and_raise(content, status_code, headers)
155126

156127
def get(
157128
self,
@@ -238,50 +209,8 @@ def _parse_response(
238209
status_code: int,
239210
headers: Mapping[str, str],
240211
) -> T:
241-
"""Parse API response and handle errors."""
242-
request_id = headers.get("X-Request-Id") or headers.get("x-request-id")
243-
244-
try:
245-
data = json.loads(content) if content else {}
246-
except json.JSONDecodeError as exc:
247-
if status_code >= 400:
248-
raise_for_status(
249-
status_code,
250-
f"Request failed with status {status_code}",
251-
request_id=request_id,
252-
)
253-
raise OMOPHubError(
254-
f"Invalid JSON response: {content[:200].decode(errors='replace')}"
255-
) from exc
256-
257-
# Handle error responses
258-
if status_code >= 400:
259-
error_response: ErrorResponse = data # type: ignore[assignment]
260-
error = error_response.get("error", {})
261-
message = error.get("message", f"Request failed with status {status_code}")
262-
error_code = error.get("code")
263-
details = error.get("details")
264-
265-
# Check for rate limit retry-after
266-
retry_after = None
267-
if status_code == 429:
268-
retry_after_header = headers.get("Retry-After") or headers.get(
269-
"retry-after"
270-
)
271-
if retry_after_header:
272-
with contextlib.suppress(ValueError):
273-
retry_after = int(retry_after_header)
274-
275-
raise_for_status(
276-
status_code,
277-
message,
278-
request_id=request_id,
279-
error_code=error_code,
280-
details=details,
281-
retry_after=retry_after,
282-
)
283-
284-
# Return successful response data
212+
"""Parse API response, raise on errors, return the ``data`` field."""
213+
data = _parse_and_raise(content, status_code, headers)
285214
response: APIResponse = data # type: ignore[assignment]
286215
return response.get("data", data)
287216

@@ -291,55 +220,8 @@ def _parse_response_raw(
291220
status_code: int,
292221
headers: Mapping[str, str],
293222
) -> 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
223+
"""Parse API response, raise on errors, return the full dict with ``meta``."""
224+
return _parse_and_raise(content, status_code, headers)
343225

344226
async def get(
345227
self,

src/omophub/resources/search.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from typing import TYPE_CHECKING, Any, Literal, TypedDict
66

7-
from .._pagination import DEFAULT_PAGE_SIZE, paginate_sync
7+
from .._pagination import DEFAULT_PAGE_SIZE, paginate_async, paginate_sync
88

99
if TYPE_CHECKING:
1010
from collections.abc import AsyncIterator, Iterator
@@ -670,13 +670,14 @@ async def semantic_iter(
670670
page_size: int = DEFAULT_PAGE_SIZE,
671671
) -> AsyncIterator[SemanticSearchResult]:
672672
"""Iterate through all semantic search results with auto-pagination."""
673-
page = 1
674673

675-
while True:
674+
async def fetch_page(
675+
page: int, size: int
676+
) -> tuple[list[SemanticSearchResult], PaginationMeta | None]:
676677
params: dict[str, Any] = {
677678
"query": query,
678679
"page": page,
679-
"page_size": page_size,
680+
"page_size": size,
680681
}
681682
if vocabulary_ids:
682683
params["vocabulary_ids"] = ",".join(vocabulary_ids)
@@ -694,18 +695,12 @@ async def semantic_iter(
694695
)
695696

696697
data = result.get("data", [])
697-
results: list[SemanticSearchResult] = (
698-
data.get("results", data) if isinstance(data, dict) else data
699-
)
700-
meta: PaginationMeta | None = result.get("meta", {}).get("pagination")
701-
702-
for item in results:
703-
yield item
704-
705-
if meta is None or not meta.get("has_next", False):
706-
break
698+
results = data.get("results", data) if isinstance(data, dict) else data
699+
meta = result.get("meta", {}).get("pagination")
700+
return results, meta
707701

708-
page += 1
702+
async for item in paginate_async(fetch_page, page_size):
703+
yield item
709704

710705
async def bulk_basic(
711706
self,

src/omophub/types/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@
107107
"QueryEnhancement",
108108
"RecommendedConceptOutput",
109109
"RelatedConcept",
110-
"ResolvedConcept",
111110
"Relationship",
112111
"RelationshipSummary",
113112
"RelationshipType",
113+
"ResolvedConcept",
114114
"ResponseMeta",
115115
"SearchFacet",
116116
"SearchFacets",

0 commit comments

Comments
 (0)