From 79bfeb893ebe0a74180b1048ccf4f739d3970840 Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Tue, 24 Feb 2026 23:07:33 +0100 Subject: [PATCH 1/4] feat: improve support for fhirpy client --- .../python/fhirpy_base_model_camel_case.py | 23 +++++++ examples/python-fhirpy/README.md | 66 ++++++++++--------- examples/python-fhirpy/client.py | 45 +++---------- examples/python-fhirpy/generate.ts | 2 +- src/api/writer-generator/python.ts | 23 ++++++- 5 files changed, 88 insertions(+), 71 deletions(-) create mode 100644 assets/api/writer-generator/python/fhirpy_base_model_camel_case.py diff --git a/assets/api/writer-generator/python/fhirpy_base_model_camel_case.py b/assets/api/writer-generator/python/fhirpy_base_model_camel_case.py new file mode 100644 index 00000000..8b48a422 --- /dev/null +++ b/assets/api/writer-generator/python/fhirpy_base_model_camel_case.py @@ -0,0 +1,23 @@ +from typing import Any, Union, Optional, Iterator, Tuple, Dict +from pydantic import BaseModel, Field +from typing import Protocol + + +class ResourceProtocol(Protocol): + resourceType: Any + id: Union[str, None] + + +class FhirpyBaseModel(BaseModel): + """ + This class satisfies ResourceProtocol + """ + id: Optional[str] = Field(None, alias="id") + + def __iter__(self) -> Iterator[Tuple[str, Any]]: # type: ignore[override] + data = self.model_dump(mode='json', by_alias=True, exclude_none=True) + return iter(data.items()) + + def serialize(self) -> Dict[str, Any]: + """Serialize to dict (compatible with fhirpy's serialize method)""" + return self.model_dump(mode='json', by_alias=True, exclude_none=True) diff --git a/examples/python-fhirpy/README.md b/examples/python-fhirpy/README.md index 98d8fa72..5e4c102c 100644 --- a/examples/python-fhirpy/README.md +++ b/examples/python-fhirpy/README.md @@ -72,54 +72,60 @@ The `fhirpyClient: true` option generates models that inherit from a base class ```python import asyncio +import base64 +import json from fhirpy import AsyncFHIRClient from fhir_types.hl7_fhir_r4_core import HumanName from fhir_types.hl7_fhir_r4_core.patient import Patient +from fhir_types.hl7_fhir_r4_core.organization import Organization + +FHIR_SERVER_URL = "http://localhost:8080/fhir" +USERNAME = "root" +PASSWORD = "" +TOKEN = base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode() + + +async def main() -> None: + """ + Demonstrates usage of fhirpy AsyncFHIRClient to create and fetch FHIR resources. + Both Client and Resource APIs are showcased. + """ -async def main(): client = AsyncFHIRClient( - "http://localhost:8080/fhir", - authorization="Basic ", + FHIR_SERVER_URL, + authorization=f"Basic {TOKEN}", + dump_resource=lambda x: x.model_dump(exclude_none=True), ) - # Create a patient using typed models patient = Patient( - name=[HumanName(given=["John"], family="Doe")], - gender="male", - birthDate="1980-01-01", + name=[HumanName(given=["Bob"], family="Cool2")], + gender="female", + birth_date="1980-01-01", ) - # Use fhirpy's client API with the typed model - created = await client.create(patient) - print(f"Created patient: {created.id}") - - # Search for patients - patients = await client.resources("Patient").fetch() - for pat in patients: - print(f"Found: {pat.get('name', [{}])[0].get('family', 'N/A')}") + created_patient = await client.create(patient) -asyncio.run(main()) -``` + print(f"Created patient: {created_patient.id}") + print(json.dumps(created_patient.model_dump(exclude_none=True), indent=2)) -### Resource API + organization = Organization( + name="Beda Software", + active=True + ) + created_organization = await client.create(organization) -```python -from fhir_types.hl7_fhir_r4_core.organization import Organization + print(f"Created organization: {created_organization.id}") -organization = Organization( - name="My Organization", - active=True -) + patients = await client.resources(Patient).fetch() + for pat in patients: + print(f"Found: {pat.name[0].family}") -# Use fhirpy's resource API -org_resource = await client.resource( - "Organization", - **organization.model_dump(exclude_none=True) -).save() -print(f"Created organization: {org_resource.id}") +if __name__ == "__main__": + asyncio.run(main()) ``` + ## Type Checking ### MyPy Integration diff --git a/examples/python-fhirpy/client.py b/examples/python-fhirpy/client.py index 3bd14138..a17fa9f2 100644 --- a/examples/python-fhirpy/client.py +++ b/examples/python-fhirpy/client.py @@ -1,44 +1,19 @@ import asyncio import base64 import json -from typing import TypeVar, Dict, Any, Tuple -from pydantic import BaseModel from fhirpy import AsyncFHIRClient from fhir_types.hl7_fhir_r4_core import HumanName from fhir_types.hl7_fhir_r4_core.patient import Patient from fhir_types.hl7_fhir_r4_core.organization import Organization -T = TypeVar('T', bound=BaseModel) FHIR_SERVER_URL = "http://localhost:8080/fhir" USERNAME = "root" -PASSWORD = "" +PASSWORD = "secret" TOKEN = base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode() -def get_resource_components(model: T) -> Tuple[str, Dict[str, Any]]: - """ - Extracts the FHIR resource type and the serialized resource body - from a Pydantic FHIR model. - """ - - resource_dict: Dict[str, Any] = model.model_dump( - mode='json', - by_alias=True, - exclude_none=True - ) - - resource_type = resource_dict.pop('resourceType', None) - if not resource_type and hasattr(model, 'resource_type'): - resource_type = model.resource_type - - if not resource_type: - raise ValueError("Cannot determine resource type from model") - - return resource_type, resource_dict - - async def main() -> None: """ Demonstrates usage of fhirpy AsyncFHIRClient to create and fetch FHIR resources. @@ -48,36 +23,32 @@ async def main() -> None: client = AsyncFHIRClient( FHIR_SERVER_URL, authorization=f"Basic {TOKEN}", + dump_resource=lambda x: x.model_dump(exclude_none=True), ) patient = Patient( name=[HumanName(given=["Bob"], family="Cool2")], gender="female", - birth_date="1980-01-01", + birthDate="1980-01-01", ) # Create the Patient using fhirpy's client API created_patient = await client.create(patient) print(f"Created patient: {created_patient.id}") - print(json.dumps(created_patient.serialize(), indent=2)) + print(json.dumps(created_patient.model_dump(exclude_none=True), indent=2)) organization = Organization( name="Beda Software", active=True ) + created_organization = await client.create(organization) - # Save the Organization using fhirpy's resource API - organization_resource = await client.resource( - "Organization", - **organization.model_dump(exclude_none=True) - ).save() - - print(f"Created organization: {organization_resource.id}") + print(f"Created organization: {created_organization.id}") - patients = await client.resources("Patient").fetch() + patients = await client.resources(Patient).fetch() for pat in patients: - print(f"Found: {pat.get('name', [{}])[0].get('family', 'N/A')}") # type: ignore[no-untyped-call] + print(f"Found: {pat.name[0].family}") if __name__ == "__main__": diff --git a/examples/python-fhirpy/generate.ts b/examples/python-fhirpy/generate.ts index 8bbd17cf..c246be40 100644 --- a/examples/python-fhirpy/generate.ts +++ b/examples/python-fhirpy/generate.ts @@ -8,7 +8,7 @@ if (require.main === module) { .fromPackage("hl7.fhir.r4.core", "4.0.1") .python({ allowExtraFields: false, - fieldFormat: "snake_case", + fieldFormat: "camelCase", fhirpyClient: true, }) .outputTo("./examples/python-fhirpy/fhir_types") diff --git a/src/api/writer-generator/python.ts b/src/api/writer-generator/python.ts index b14b1a84..cad0b45e 100644 --- a/src/api/writer-generator/python.ts +++ b/src/api/writer-generator/python.ts @@ -146,11 +146,13 @@ export class Python extends Writer { private readonly nameFormatFunction: (name: string) => string; private tsIndex: TypeSchemaIndex | undefined; private readonly forFhirpyClient: boolean; + private readonly fieldFormat: StringFormatKey; constructor(options: PythonGeneratorOptions) { super({ ...options, resolveAssets: options.resolveAssets ?? resolvePyAssets }); this.nameFormatFunction = this.getFieldFormatFunction(options.fieldFormat); this.forFhirpyClient = options.fhirpyClient ?? false; + this.fieldFormat = options.fieldFormat; } override async generate(tsIndex: TypeSchemaIndex): Promise { @@ -165,7 +167,13 @@ export class Python extends Writer { private generateRootPackages(groups: TypeSchemaPackageGroups): void { this.generateRootInitFile(groups); - if (this.forFhirpyClient) this.copyAssets(resolvePyAssets("fhirpy_base_model.py"), "fhirpy_base_model.py"); + if (this.forFhirpyClient) { + if (this.fieldFormat === "camelCase") { + this.copyAssets(resolvePyAssets("fhirpy_base_model_camel_case.py"), "fhirpy_base_model.py"); + } else { + this.copyAssets(resolvePyAssets("fhirpy_base_model.py"), "fhirpy_base_model.py"); + } + } this.copyAssets(resolvePyAssets("requirements.txt"), "requirements.txt"); } @@ -422,12 +430,21 @@ export class Python extends Writer { } private generateResourceTypeField(schema: RegularTypeSchema): void { - this.line(`${this.nameFormatFunction("resourceType")}: str = Field(`); + const hasChildren = this.tsIndex.resourceChildren(schema.identifier).length > 0; + + if (hasChildren) { + this.line(`${this.nameFormatFunction("resourceType")}: str = Field(`); + } else { + this.line(`${this.nameFormatFunction("resourceType")}: Literal["${schema.identifier.name}"] = Field(`); + } this.indentBlock(() => { this.line(`default='${schema.identifier.name}',`); this.line(`alias='resourceType',`); this.line(`serialization_alias='resourceType',`); - this.line("frozen=True,"); + if (!this.forFhirpyClient) { + // fhirpy client resource protocol expects the resourceType field not to be frozen + this.line("frozen=True,"); + } this.line(`pattern='${schema.identifier.name}'`); }); this.line(")"); From 5d04bc5aceb8b3b2a3e758d2911afc9a6293114f Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Tue, 24 Feb 2026 23:23:29 +0100 Subject: [PATCH 2/4] chore: adjust non camel case base fhirpy model to expose right class resourceType --- .../python/fhirpy_base_model.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/assets/api/writer-generator/python/fhirpy_base_model.py b/assets/api/writer-generator/python/fhirpy_base_model.py index e14be7fb..88027014 100644 --- a/assets/api/writer-generator/python/fhirpy_base_model.py +++ b/assets/api/writer-generator/python/fhirpy_base_model.py @@ -1,4 +1,4 @@ -from typing import Any, Union, Optional, Iterator, Tuple, Dict +from typing import Any, ClassVar, Type, Union, Optional, Iterator, Tuple, Dict from pydantic import BaseModel, Field from typing import Protocol @@ -8,20 +8,23 @@ class ResourceProtocol(Protocol): id: Union[str, None] +class ResourceTypeDescriptor: + def __get__(self, instance: Optional[BaseModel], owner: Type[BaseModel]) -> str: + field = owner.model_fields.get("resource_type") + if field is None: + raise ValueError("resource_type field not found") + if field.default is None: + raise ValueError("resource_type field default value is not set") + return str(field.default) + + class FhirpyBaseModel(BaseModel): """ This class satisfies ResourceProtocol """ - resource_type: str = Field(alias="resourceType") id: Optional[str] = Field(None, alias="id") - @property - def resourceType(self) -> str: - return self.resource_type - - @resourceType.setter - def resourceType(self, value: str) -> None: - self.resource_type = value + resourceType: ClassVar[ResourceTypeDescriptor] = ResourceTypeDescriptor() def __iter__(self) -> Iterator[Tuple[str, Any]]: # type: ignore[override] data = self.model_dump(mode='json', by_alias=True, exclude_none=True) From 87548df020be9aaf7b3c261934ac7d2331b59297 Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Tue, 24 Feb 2026 23:49:55 +0100 Subject: [PATCH 3/4] test: update python snapshots --- src/api/writer-generator/python.ts | 2 +- test/api/write-generator/__snapshots__/python.test.ts.snap | 2 +- .../multi-package/__snapshots__/cda.test.ts.snap | 4 ++-- .../multi-package/__snapshots__/local-package.test.ts.snap | 4 ++-- .../multi-package/__snapshots__/sql-on-fhir.test.ts.snap | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/api/writer-generator/python.ts b/src/api/writer-generator/python.ts index cad0b45e..974bdb62 100644 --- a/src/api/writer-generator/python.ts +++ b/src/api/writer-generator/python.ts @@ -435,7 +435,7 @@ export class Python extends Writer { if (hasChildren) { this.line(`${this.nameFormatFunction("resourceType")}: str = Field(`); } else { - this.line(`${this.nameFormatFunction("resourceType")}: Literal["${schema.identifier.name}"] = Field(`); + this.line(`${this.nameFormatFunction("resourceType")}: Literal['${schema.identifier.name}'] = Field(`); } this.indentBlock(() => { this.line(`default='${schema.identifier.name}',`); diff --git a/test/api/write-generator/__snapshots__/python.test.ts.snap b/test/api/write-generator/__snapshots__/python.test.ts.snap index c41f1901..722cb71e 100644 --- a/test/api/write-generator/__snapshots__/python.test.ts.snap +++ b/test/api/write-generator/__snapshots__/python.test.ts.snap @@ -39,7 +39,7 @@ class PatientLink(BackboneElement): class Patient(DomainResource): model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid") - resource_type: str = Field( + resource_type: Literal['Patient'] = Field( default='Patient', alias='resourceType', serialization_alias='resourceType', diff --git a/test/api/write-generator/multi-package/__snapshots__/cda.test.ts.snap b/test/api/write-generator/multi-package/__snapshots__/cda.test.ts.snap index ab988c18..3973195a 100644 --- a/test/api/write-generator/multi-package/__snapshots__/cda.test.ts.snap +++ b/test/api/write-generator/multi-package/__snapshots__/cda.test.ts.snap @@ -82,7 +82,7 @@ from typing import List as PyList, Literal class ClinicalDocument(ANY): model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid") - resource_type: str = Field( + resource_type: Literal['ClinicalDocument'] = Field( default='ClinicalDocument', alias='resourceType', serialization_alias='resourceType', @@ -262,7 +262,7 @@ from typing import List as PyList, Literal class ClinicalDocument(ANY): model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid") - resource_type: str = Field( + resource_type: Literal['ClinicalDocument'] = Field( default='ClinicalDocument', alias='resourceType', serialization_alias='resourceType', diff --git a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap index bab48675..e292d36e 100644 --- a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap +++ b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap @@ -44,7 +44,7 @@ from fhir_types.hl7_fhir_r4_core.resource_families import DomainResourceFamily class ExampleNotebook(DomainResource): model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid") - resource_type: str = Field( + resource_type: Literal['ExampleNotebook'] = Field( default='ExampleNotebook', alias='resourceType', serialization_alias='resourceType', @@ -144,7 +144,7 @@ class PatientLink(BackboneElement): class Patient(DomainResource): model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid") - resource_type: str = Field( + resource_type: Literal['Patient'] = Field( default='Patient', alias='resourceType', serialization_alias='resourceType', diff --git a/test/api/write-generator/multi-package/__snapshots__/sql-on-fhir.test.ts.snap b/test/api/write-generator/multi-package/__snapshots__/sql-on-fhir.test.ts.snap index 082dacb2..14058f20 100644 --- a/test/api/write-generator/multi-package/__snapshots__/sql-on-fhir.test.ts.snap +++ b/test/api/write-generator/multi-package/__snapshots__/sql-on-fhir.test.ts.snap @@ -147,7 +147,7 @@ class ViewDefinitionWhere(BackboneElement): class ViewDefinition(CanonicalResource): model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid") - resource_type: str = Field( + resource_type: Literal['ViewDefinition'] = Field( default='ViewDefinition', alias='resourceType', serialization_alias='resourceType', From b3f995e85b3c3991dcba6b09a6fc76cd2d3872d4 Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Wed, 25 Feb 2026 12:20:20 +0100 Subject: [PATCH 4/4] chore: Try to fix tests --- src/api/writer-generator/python.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/writer-generator/python.ts b/src/api/writer-generator/python.ts index 974bdb62..adacf9c4 100644 --- a/src/api/writer-generator/python.ts +++ b/src/api/writer-generator/python.ts @@ -430,6 +430,7 @@ export class Python extends Writer { } private generateResourceTypeField(schema: RegularTypeSchema): void { + assert(this.tsIndex !== undefined); const hasChildren = this.tsIndex.resourceChildren(schema.identifier).length > 0; if (hasChildren) {