Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# US Core Profiles in Python with @atomic-ehr/codegen

A small CSV-to-FHIR converter demonstrating [`@atomic-ehr/codegen`](https://github.com/atomic-ehr/codegen) profile class generation for US Core. The Python counterpart of [atomic-ehr-codegen-typescript-us-core-profiles](../atomic-ehr-codegen-typescript-us-core-profiles), generated for **Pydantic** with the **[fhirpy](https://github.com/beda-software/fhirpy)** async client enabled (`fhirpyClient: true`).
A small CSV-to-FHIR converter demonstrating [`@atomic-ehr/codegen`](https://github.com/atomic-ehr/codegen) profile class generation for US Core. The Python counterpart of [atomic-ehr-codegen-typescript-us-core-profiles](../atomic-ehr-codegen-typescript-us-core-profiles), generated for **Pydantic** with the **[fhirpy](https://github.com/beda-software/fhirpy)** async client enabled (`client: "fhirpy"`).

The example:

Expand Down Expand Up @@ -73,7 +73,7 @@ python post.py
## Notes on the Code

- **The generator is a Node tool; the output is Python.** `generate.ts` runs once to emit `fhir_types/`. After that you only need Python + Pydantic (and fhirpy for `post.py`).
- **`fhirpyClient: true`** makes the generated resources extend `FhirpyBaseModel`: they expose `resourceType` at class level and serialize via `model_dump`, which is everything fhirpy's typed client needs to `create` / `search` / `fetch` them.
- **`client: "fhirpy"`** makes the generated resources extend `FhirpyBaseModel`: they expose `resourceType` at class level and serialize via `model_dump`, which is everything fhirpy's typed client needs to `create` / `search` / `fetch` them.
- **camelCase attributes (`fieldFormat: "camelCase"`).** Resource fields use the FHIR wire names (`resourceType`, `birthDate`, `effectiveDateTime`), so attribute names match the JSON. This is what lets `client.resources(Observation).search(...).fetch()` be statically typed: fhirpy's `ResourceProtocol` looks for a `resourceType` attribute, which only exists as a real attribute under camelCase. (The generator also supports `snake_case`, but then `resourceType` is injected only at runtime and fhirpy's typed client can't see it statically.) Profile **method** names stay snake_case (`set_systolic`, `set_race`, `from_resource`).
- **Must-support base fields** (`gender`, `birthDate`) aren't profiled further by US Core, so the profile class emits no `.set_gender()`-style setters. `load.py` sets them on the base `Patient`, then calls `UscorePatientProfile.apply()`. `validate()` warns if a must-support field is missing.
- **No `is()` type guard.** Unlike the TypeScript API, the Python classes don't ship a `.filter()`-style guard. `avg.py` selects BP observations by `resourceType` + `meta.profile`, then calls `from_resource()`.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any, Union, Optional, Iterator, Tuple, Dict
from pydantic import BaseModel, Field
from pydantic_core import PydanticUndefined
from typing import Protocol


Expand All @@ -21,7 +22,10 @@ class FhirpyBaseModel(BaseModel):
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
super().__pydantic_init_subclass__(**kwargs)
field = cls.model_fields.get("resource_type") or cls.model_fields.get("resourceType")
if field is not None and field.default is not None:
# Only concrete resources carry a default resourceType. Abstract/family base types
# (Resource, DomainResource) leave it unset, so we skip them to avoid registering a
# class attribute that concrete subclasses would shadow.
if field is not None and field.default is not None and field.default is not PydanticUndefined:
type.__setattr__(cls, "resourceType", str(field.default))

def __iter__(self) -> Iterator[Tuple[str, Any]]: # type: ignore[override]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class BundleLink(BackboneElement):

class Bundle(Resource, Generic[T1, T2]):
model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid")
resourceType: Literal['Bundle'] = Field(
resourceType: str = Field(
default='Bundle',
alias='resourceType',
serialization_alias='resourceType',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
class DomainResource(Resource, Generic[T]):
model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid")
resourceType: str = Field(
default='DomainResource',
alias='resourceType',
serialization_alias='resourceType',
pattern='DomainResource'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class ObservationReferenceRange(BackboneElement):

class Observation(DomainResource):
model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid")
resourceType: Literal['Observation'] = Field(
resourceType: str = Field(
default='Observation',
alias='resourceType',
serialization_alias='resourceType',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class OperationOutcomeIssue(BackboneElement):

class OperationOutcome(DomainResource):
model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid")
resourceType: Literal['OperationOutcome'] = Field(
resourceType: str = Field(
default='OperationOutcome',
alias='resourceType',
serialization_alias='resourceType',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class OrganizationContact(BackboneElement):

class Organization(DomainResource):
model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid")
resourceType: Literal['Organization'] = Field(
resourceType: str = Field(
default='Organization',
alias='resourceType',
serialization_alias='resourceType',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class PatientLink(BackboneElement):

class Patient(DomainResource):
model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid")
resourceType: Literal['Patient'] = Field(
resourceType: str = Field(
default='Patient',
alias='resourceType',
serialization_alias='resourceType',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
class Resource(FhirpyBaseModel):
model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid")
resourceType: str = Field(
default='Resource',
alias='resourceType',
serialization_alias='resourceType',
pattern='Resource'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"generate": "tsx generate.ts"
},
"devDependencies": {
"@atomic-ehr/codegen": "^0.0.16",
"@atomic-ehr/codegen": "^0.0.16-canary.20260626094300.e87ff88",
"@types/node": "^25.6.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

FHIR_URL=http://localhost:8090/fhir python post.py

fhirpy is wired in because the types were generated with `fhirpyClient: true`:
fhirpy is wired in because the types were generated with `client: "fhirpy"`:
the generated resources extend FhirpyBaseModel, expose `resourceType` at class
level, and serialize via `model_dump`, which is everything fhirpy's typed client
needs to create/search/deserialize them.
Expand Down