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
41 changes: 34 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ 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.0] - 2026-08-11
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

### Added

- `Mappings.get()` / `AsyncMappings.get()` accept `page` and `page_size`.
`GET /v1/concepts/{id}/mappings` became paginated; before that
it applied a fixed `LIMIT 100` server-side with no total and no `has_next`, so
a concept with 1,500 mappings returned 100 of them and nothing in the response
said so. `page_size` defaults to 100, matching the old cap, so an existing
call returns exactly the page it returned before.
- `Mappings.get_iter()` / `AsyncMappings.get_iter()` walk every page and yield
each mapping. Prefer these when building a code list: `get()` returns the
`data` field only, so the `meta.pagination` that would tell you the set was
truncated is not part of what you get back.

### Fixed

- `include_invalid=False` now reaches the server on `Mappings.get()` /
`get_iter()` and their async counterparts. The parameter was only sent when
truthy, and this endpoint defaults to *including* deprecated mappings, so
asking to exclude them did nothing. It is now `bool | None`: omit it for the
server default, pass `False` to exclude. Omitting it behaves exactly as
before, so only callers who explicitly passed `False` - and were being

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 Fixed entry ends with a truncated, grammatically incomplete sentence: "Omitting it behaves exactly as before, so only callers who explicitly passed False - and were being ignored." The clause after "so" ("only callers who explicitly passed False") is never completed, so the sentence trails off with a dangling dash rather than stating the intended conclusion (that the change only affects callers who explicitly passed False and were previously being ignored). Please complete the sentence so the release note reads cleanly.

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

<comment>The new Fixed entry ends with a truncated, grammatically incomplete sentence: "Omitting it behaves exactly as before, so only callers who explicitly passed `False` - and were being ignored." The clause after "so" ("only callers who explicitly passed `False`") is never completed, so the sentence trails off with a dangling dash rather than stating the intended conclusion (that the change only affects callers who explicitly passed `False` and were previously being ignored). Please complete the sentence so the release note reads cleanly.</comment>

<file context>
@@ -20,6 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+  truthy, and this endpoint defaults to *including* deprecated mappings, so
+  asking to exclude them did nothing. It is now `bool | None`: omit it for the
+  server default, pass `False` to exclude. Omitting it behaves exactly as
+  before, so only callers who explicitly passed `False` - and were being
+  ignored.
+
</file context>

ignored.


## [1.8.1] - 2026-06-01

### Changed
Expand All @@ -22,7 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- `FhirResolution` now types the `value_as_concept` and `value_target_field`
fields the resolver returns when a composite concept is decomposed via the
`Maps to value` relationship (HL7 FHIR-to-OMOP IG Value-as-Concept pattern
`Maps to value` relationship (HL7 FHIR-to-OMOP IG Value-as-Concept pattern -
e.g. "Allergy to penicillin" → standard "Allergy to drug" + value
"Penicillin G"), plus `concept_map_id` / `mapping_note` for FHIR
administrative-code resolutions. These were already passed through; they are
Expand All @@ -38,7 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [1.7.1] - 2026-05-20

Maintenance release. Dependency and lock-file updates only there are no
Maintenance release. Dependency and lock-file updates only - there are no
source code or public API changes. The published runtime dependencies
(`httpx`, `typing_extensions`) are unchanged; the updates below affect the
pinned development, testing, and optional-extra dependencies in `uv.lock`
Expand All @@ -49,12 +75,12 @@ and are not shipped in the wheel/sdist.
- Updated pinned development and transitive dependencies in `uv.lock` to
resolve reported advisories. None of these affect the published runtime
dependencies:
- `idna` 3.11 → 3.15 fixes a bypass of the CVE-2024-3651 mitigation in
- `idna` 3.11 → 3.15 - fixes a bypass of the CVE-2024-3651 mitigation in
`idna.encode()` (transitive via `httpx`/`anyio`).
- `pytest` 9.0.2 → 9.0.3 fixes vulnerable tmpdir handling (dev only).
- `python-dotenv` 1.2.1 → 1.2.2 fixes symlink following in `set_key`
- `pytest` 9.0.2 → 9.0.3 - fixes vulnerable tmpdir handling (dev only).
- `python-dotenv` 1.2.1 → 1.2.2 - fixes symlink following in `set_key`
that allowed arbitrary file overwrite (dev only).
- `pygments` 2.19.2 → 2.20.0 fixes a ReDoS in GUID matching
- `pygments` 2.19.2 → 2.20.0 - fixes a ReDoS in GUID matching
(transitive, dev only).

### Changed
Expand Down Expand Up @@ -275,7 +301,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.8.1...HEAD
[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.9.0...HEAD
[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
[1.7.1]: https://github.com/omopHub/omophub-python/compare/v1.7.0...v1.7.1
Expand Down
172 changes: 160 additions & 12 deletions src/omophub/resources/mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@

from typing import TYPE_CHECKING, Any

from .._pagination import paginate_async, paginate_sync

if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator

from .._request import AsyncRequest, Request
from .._types import PaginationMeta


class Mappings:
Expand All @@ -19,32 +24,103 @@ def get(
concept_id: int,
*,
target_vocabulary: str | None = None,
include_invalid: bool = False,
include_invalid: bool | None = None,
page: int = 1,
page_size: int = 100,
vocab_release: str | None = None,
) -> dict[str, Any]:
"""Get mappings for a concept.

A concept can have more mappings than one page carries, and this
method returns the ``data`` field only — the pagination metadata that
would tell you so is not part of what you get back. Use
:meth:`get_iter` when you need every mapping, and treat a full page here
as "there is probably more" rather than as the complete set.

Args:
concept_id: The concept ID
target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM")
include_invalid: Include invalid/deprecated mappings
include_invalid: Whether to return mappings whose relationship or
target concept is deprecated. Omit to take the server default,
which for this endpoint is to **include** them; pass ``False``
to exclude them. The source concept is never filtered, so a
deprecated concept still returns what it maps to.
page: Page number, 1-based (default 1)
page_size: Mappings per page (default 100). The server clamps this to
200 on this endpoint and does not report having done so, so a
larger value silently yields a smaller page.
vocab_release: Specific vocabulary release version (e.g., "2025.1")

Returns:
Mappings for the concept
The response ``data`` field only. ``meta.pagination`` is **not**
part of it, so nothing in this return value tells you whether the
mappings were truncated -- use :meth:`get_iter` when that matters.
"""
params: dict[str, Any] = {}
params: dict[str, Any] = {"page": page, "page_size": page_size}
if target_vocabulary:
params["target_vocabulary"] = target_vocabulary
if include_invalid:
params["include_invalid"] = "true"
if include_invalid is not None:
params["include_invalid"] = "true" if include_invalid else "false"
if vocab_release:
params["vocab_release"] = vocab_release

return self._request.get(
f"/concepts/{concept_id}/mappings", params=params or None
)

def get_iter(
self,
concept_id: int,
*,
target_vocabulary: str | None = None,
include_invalid: bool | None = None,
page_size: int = 100,
vocab_release: str | None = None,
) -> Iterator[dict[str, Any]]:
"""Iterate over every mapping for a concept, across all pages.

Prefer this to :meth:`get` when you are building a code list. A single
page is capped server-side, so a concept with more mappings than the
page size yields a subset that looks exactly like a complete answer.

Args:
concept_id: The concept ID
target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM")
include_invalid: Whether to return mappings whose relationship or
target concept is deprecated. Omit to take the server default,
which for this endpoint is to **include** them; pass ``False``
to exclude them. The source concept is never filtered, so a
deprecated concept still returns what it maps to.
page_size: Mappings fetched per request (default 100, server max 200)
vocab_release: Specific vocabulary release version (e.g., "2025.1")

Yields:
Individual mappings from all pages
"""

def fetch_page(
page: int, size: int
) -> tuple[list[dict[str, Any]], PaginationMeta | None]:
params: dict[str, Any] = {"page": page, "page_size": size}
if target_vocabulary:
params["target_vocabulary"] = target_vocabulary
if include_invalid is not None:
params["include_invalid"] = "true" if include_invalid else "false"
if vocab_release:
params["vocab_release"] = vocab_release

# get_raw() rather than get(): the pagination meta is the entire
# point here and get() discards it.
result = self._request.get_raw(
f"/concepts/{concept_id}/mappings", params=params
)
data = result.get("data") or {}
mappings = data.get("mappings", []) if isinstance(data, dict) else data
meta = result.get("meta", {}).get("pagination")
return mappings, meta

yield from paginate_sync(fetch_page, page_size)

def map(
self,
target_vocabulary: str,
Expand Down Expand Up @@ -116,32 +192,104 @@ async def get(
concept_id: int,
*,
target_vocabulary: str | None = None,
include_invalid: bool = False,
include_invalid: bool | None = None,
page: int = 1,
page_size: int = 100,
vocab_release: str | None = None,
) -> dict[str, Any]:
"""Get mappings for a concept.

A concept can have more mappings than one page carries, and this
method returns the ``data`` field only — the pagination metadata that
would tell you so is not part of what you get back. Use
:meth:`get_iter` when you need every mapping, and treat a full page here
as "there is probably more" rather than as the complete set.

Args:
concept_id: The concept ID
target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM")
include_invalid: Include invalid/deprecated mappings
include_invalid: Whether to return mappings whose relationship or
target concept is deprecated. Omit to take the server default,
which for this endpoint is to **include** them; pass ``False``
to exclude them. The source concept is never filtered, so a
deprecated concept still returns what it maps to.
page: Page number, 1-based (default 1)
page_size: Mappings per page (default 100). The server clamps this to
200 on this endpoint and does not report having done so, so a
larger value silently yields a smaller page.
vocab_release: Specific vocabulary release version (e.g., "2025.1")

Returns:
Mappings for the concept
The response ``data`` field only. ``meta.pagination`` is **not**
part of it, so nothing in this return value tells you whether the
mappings were truncated -- use :meth:`get_iter` when that matters.
"""
params: dict[str, Any] = {}
params: dict[str, Any] = {"page": page, "page_size": page_size}
if target_vocabulary:
params["target_vocabulary"] = target_vocabulary
if include_invalid:
params["include_invalid"] = "true"
if include_invalid is not None:
params["include_invalid"] = "true" if include_invalid else "false"
if vocab_release:
params["vocab_release"] = vocab_release

return await self._request.get(
f"/concepts/{concept_id}/mappings", params=params or None
)

async def get_iter(
self,
concept_id: int,
*,
target_vocabulary: str | None = None,
include_invalid: bool | None = None,
page_size: int = 100,
vocab_release: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
"""Iterate over every mapping for a concept, across all pages.

Prefer this to :meth:`get` when you are building a code list. A single
page is capped server-side, so a concept with more mappings than the
page size yields a subset that looks exactly like a complete answer.

Args:
concept_id: The concept ID
target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM")
include_invalid: Whether to return mappings whose relationship or
target concept is deprecated. Omit to take the server default,
which for this endpoint is to **include** them; pass ``False``
to exclude them. The source concept is never filtered, so a
deprecated concept still returns what it maps to.
page_size: Mappings fetched per request (default 100, server max 200)
vocab_release: Specific vocabulary release version (e.g., "2025.1")

Yields:
Individual mappings from all pages
"""

async def fetch_page(
page: int, size: int
) -> tuple[list[dict[str, Any]], PaginationMeta | None]:
params: dict[str, Any] = {"page": page, "page_size": size}
if target_vocabulary:
params["target_vocabulary"] = target_vocabulary
if include_invalid is not None:
params["include_invalid"] = "true" if include_invalid else "false"
if vocab_release:
params["vocab_release"] = vocab_release

# get_raw() rather than get(): the pagination meta is the entire
# point here and get() discards it.
result = await self._request.get_raw(
f"/concepts/{concept_id}/mappings", params=params
)
data = result.get("data") or {}
mappings = data.get("mappings", []) if isinstance(data, dict) else data
meta = result.get("meta", {}).get("pagination")
return mappings, meta

async for item in paginate_async(fetch_page, page_size):
yield item

async def map(
self,
target_vocabulary: str,
Expand Down
Loading
Loading