diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 35eed1b1d..5a15c0280 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -91,7 +91,7 @@ jobs: export AIDBOX_LICENSE_ID="${{ secrets.AIDBOX_LICENSE_ID }}" make test-python-sdk - test-python-fhirpy-sdk: + test-python-r4-example: runs-on: ubuntu-latest steps: @@ -110,18 +110,18 @@ jobs: - name: Test Python fhirpy SDK run: | export AIDBOX_LICENSE_ID="${{ secrets.AIDBOX_LICENSE_ID }}" - make test-python-fhirpy-sdk + make test-python-r4-example - - name: Repository contains actual python-fhirpy-sdk version + - name: Repository contains actual python-r4 version run: | - diff_result=$(git diff --exit-code --name-only examples/python-fhirpy/fhir_types || true) + diff_result=$(git diff --exit-code --name-only examples/python-r4/fhir_types || true) if [ -z "$diff_result" ]; then echo "✅ Generated SDK is identical to the one stored in repository." else echo "❌ Generated SDK differs from the one stored in repository." echo "Differences:" - git diff examples/python-fhirpy/fhir_types + git diff examples/python-r4/fhir_types exit 1 fi diff --git a/CLAUDE.md b/CLAUDE.md index e7e1c7aa8..f6311428c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,9 @@ FHIR Package → TypeSchema Generator → TypeSchema Format → Code Generators - Prefer single-line guard clauses without braces: `if (!x) throw new Error("...");` instead of wrapping in `{ }` - Do not check `kind` of `Identifier`/`TypeIdentifier`/`TypeSchema` by manually comparing the `kind` field. Use dedicated predicates (`isPrimitiveIdentifier`, `isSpecializationTypeSchema`, etc.) +### Documentation +- Don't hard-wrap paragraphs in Markdown files (`README.md`, `docs/`). Write each paragraph as a single physical line and let it soft-wrap; reserve line breaks for list items and intentional structure. + ### Testing Strategy - Uses Bun's built-in test runner - Unit tests for core functionality (transformers, builders) diff --git a/Makefile b/Makefile index 617acefee..393243de4 100644 --- a/Makefile +++ b/Makefile @@ -10,9 +10,9 @@ VERSION = $(shell cat package.json | grep version | sed -E 's/ *"version": "//' test-on-the-fly-example test-on-the-fly-norge-r4 test-on-the-fly-kbv-r4 test-on-the-fly-ccda \ test-typescript-r4-us-core-example test-typescript-custom-packages-example \ test-mustache-java-r4-example \ - test-csharp-sdk generate-python-r4-us-core-sdk generate-python-sdk-fhirpy \ - python-r4-us-core-test-setup python-fhirpy-test-setup \ - test-python-sdk test-python-fhirpy-sdk test-python-r4-us-core-example + test-csharp-sdk generate-python-r4-us-core-sdk generate-python-r4-sdk \ + python-r4-us-core-test-setup python-r4-test-setup \ + test-python-sdk test-python-r4-example test-python-r4-us-core-example all: test test-multi-package test-typescript-r4-us-core-example test-typescript-custom-packages-example lint-unsafe test-all-example-generation @@ -57,7 +57,7 @@ test-all-example-generation: test-other-example-generation bun run examples/typescript-custom-packages/generate.ts bun run examples/mustache/mustache-java-r4-gen.ts bun run examples/python-r4-us-core/generate.ts - bun run examples/python-fhirpy/generate.ts + bun run examples/python-r4/generate.ts bun run examples/typescript-r4-us-core/generate.ts test-other-example-generation: test-on-the-fly-example @@ -102,15 +102,15 @@ test-csharp-sdk: typecheck prepare-aidbox-runme PYTHON=python3.13 PYTHON_R4_US_CORE_EXAMPLE=./examples/python-r4-us-core -PYTHON_FHIRPY_EXAMPLE=./examples/python-fhirpy +PYTHON_R4_EXAMPLE=./examples/python-r4 generate-python-r4-us-core-sdk: $(TYPECHECK) --project examples/python-r4-us-core/tsconfig.json bun run examples/python-r4-us-core/generate.ts -generate-python-sdk-fhirpy: - $(TYPECHECK) --project examples/python-fhirpy/tsconfig.json - bun run examples/python-fhirpy/generate.ts +generate-python-r4-sdk: + $(TYPECHECK) --project examples/python-r4/tsconfig.json + bun run examples/python-r4/generate.ts python-r4-us-core-test-setup: @if [ ! -d "$(PYTHON_R4_US_CORE_EXAMPLE)/venv" ]; then \ @@ -120,9 +120,9 @@ python-r4-us-core-test-setup: pip install -r fhir_types/requirements.txt; \ fi -python-fhirpy-test-setup: - @if [ ! -d "$(PYTHON_FHIRPY_EXAMPLE)/venv" ]; then \ - cd $(PYTHON_FHIRPY_EXAMPLE) && \ +python-r4-test-setup: + @if [ ! -d "$(PYTHON_R4_EXAMPLE)/venv" ]; then \ + cd $(PYTHON_R4_EXAMPLE) && \ $(PYTHON) -m venv venv && \ . venv/bin/activate && \ pip install -r fhir_types/requirements.txt && \ @@ -145,7 +145,7 @@ test-python-sdk: typecheck prepare-aidbox-runme generate-python-r4-us-core-sdk p . venv/bin/activate && \ python -m pytest test_sdk.py -v -test-python-fhirpy-sdk: typecheck prepare-aidbox-runme generate-python-sdk-fhirpy python-fhirpy-test-setup - cd $(PYTHON_FHIRPY_EXAMPLE) && \ +test-python-r4-example: typecheck prepare-aidbox-runme generate-python-r4-sdk python-r4-test-setup + cd $(PYTHON_R4_EXAMPLE) && \ . venv/bin/activate && \ mypy . diff --git a/README.md b/README.md index 136ffa45b..5014a2766 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,8 @@ See the [examples/](examples/) directory for working demonstrations: - **[typescript-r4-us-core/](examples/typescript-r4-us-core/)** - FHIR R4 core + US Core type generation with resource creation, profiles, and extensions - **[on-the-fly/ccda/](examples/on-the-fly/ccda/)** - C-CDA on FHIR type generation (logical models, generated on the fly) -- **[python-r4-us-core/](examples/python-r4-us-core/)** - Python/Pydantic models for FHIR R4 core + US Core profiles, with a requests-based client -- **[python-fhirpy/](examples/python-fhirpy/)** - Python/Pydantic model generation with fhirpy async client +- **[python-r4-us-core/](examples/python-r4-us-core/)** - Python/Pydantic models for FHIR R4 core + US Core profiles, with the default fhirpy async client +- **[python-r4/](examples/python-r4/)** - Python/Pydantic model generation with the simple requests-based client - **[csharp/](examples/csharp/)** - C# class generation with namespace configuration - **[mustache/](examples/mustache/)** - Java generation with Mustache templates and post-generation hooks - **[typescript-custom-packages/](examples/typescript-custom-packages/)** - Loading packages from local folders or remote TGZ URLs (SQL-on-FHIR) diff --git a/examples/README.md b/examples/README.md index 26e66ffe0..e69d6a6c1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,14 +18,13 @@ This directory contains working examples demonstrating the capabilities of Atomi ### Python Generation - **[python-r4-us-core/](python-r4-us-core/)** - Python/Pydantic models for FHIR R4 core + US Core profiles - - `generate.ts` - Generates R4 models and US Core profiles in one tree; configurable `snake_case`/`camelCase` field naming and extra-field validation + - `generate.ts` - Generates R4 models and US Core profiles in one tree with the default `fhirpy` async client - US Core profile classes (Patient, blood pressure, body weight) with typed accessors and race/ethnicity/birth-sex extensions - - Simple `requests`-based FHIR client ([python-r4-us-core/client.py](python-r4-us-core/client.py)), exercised by `test_sdk.py` + - `fhirpy` `AsyncFHIRClient` demo ([python-r4-us-core/demo.py](python-r4-us-core/demo.py)), exercised by `test_sdk.py` -- **[python-fhirpy/](python-fhirpy/)** - Python/Pydantic models with fhirpy async client - - `generate.ts` - Generates Python models with fhirpy integration - - Uses `fhirpyClient: true` for async FHIR client support - - Client implementation example: [python-fhirpy/client.py](python-fhirpy/client.py) +- **[python-r4/](python-r4/)** - Python/Pydantic models with the simple `requests`-based client + - `generate.ts` - Generates R4 models with `client: "none"`, `snake_case` + - Reusable `Client` class with basic auth and CRUD/search ([python-r4/client.py](python-r4/client.py)) ### C# Generation diff --git a/examples/python-fhirpy/README.md b/examples/python-fhirpy/README.md deleted file mode 100644 index 5e4c102cb..000000000 --- a/examples/python-fhirpy/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# Python fhirpy Example - -FHIR R4 type generation with Pydantic models integrated with the [fhirpy](https://github.com/beda-software/fhir-py) async client library. - -## Overview - -This example demonstrates how to use generated Python/Pydantic models with the `fhirpy` async FHIR client. It includes: - -- Full FHIR R4 resource type definitions as Pydantic models -- Integration with `fhirpy` AsyncFHIRClient -- Automatic validation and serialization -- Async/await patterns for FHIR operations - -For a simpler example using `requests`, see [python/](../python/). - -## Setup - -### Python Environment - -1. Create virtual environment: - -```bash -cd examples/python-fhirpy -python3 -m venv venv - -# On macOS/Linux: -source venv/bin/activate -# On Windows: -venv\Scripts\activate -``` - -2. Install Python dependencies: - -```bash -pip install -r fhir_types/requirements.txt -pip install fhirpy -``` - -3. Check Python version: - -```bash -python --version # Should be 3.10 or higher -``` - -## Generating Types - -To generate Python/Pydantic types for FHIR R4 with fhirpy support: - -```bash -bun run examples/python-fhirpy/generate.ts -``` - -This will output to `./examples/python-fhirpy/fhir_types/` - -## Configuration - -Edit `generate.ts` to customize: - -```typescript -.python({ - allowExtraFields: false, // Reject unknown fields in models - fieldFormat: "snake_case", // or "camelCase" - fhirpyClient: true // Enable fhirpy integration -}) -``` - -The `fhirpyClient: true` option generates models that inherit from a base class compatible with fhirpy's client API. - -## Using with fhirpy - -### Basic Usage - -```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. - """ - - 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", - ) - - created_patient = await client.create(patient) - - print(f"Created patient: {created_patient.id}") - 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) - - print(f"Created organization: {created_organization.id}") - - patients = await client.resources(Patient).fetch() - for pat in patients: - print(f"Found: {pat.name[0].family}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - - -## Type Checking - -### MyPy Integration - -Verify type safety with MyPy: - -```bash -pip install mypy -mypy fhir_types/ -``` - -## Running the Demo - -Start a FHIR server (e.g., using the docker-compose in examples/), then run: - -```bash -python client.py -``` - -## Next Steps - -- See [python-simple/](../python-simple/) for basic requests-based example -- See [examples/](../) overview for other language examples -- Check [../../CLAUDE.md](../../CLAUDE.md) for architecture details -- Learn more about [fhirpy](https://github.com/beda-software/fhir-py) diff --git a/examples/python-fhirpy/test_sdk.py b/examples/python-fhirpy/test_sdk.py deleted file mode 100644 index 89d4e636c..000000000 --- a/examples/python-fhirpy/test_sdk.py +++ /dev/null @@ -1,185 +0,0 @@ -import asyncio -import base64 -from typing import AsyncIterator - -import pytest -import pytest_asyncio -from fhirpy import AsyncFHIRClient - -from fhir_types.hl7_fhir_r4_core import HumanName -from fhir_types.hl7_fhir_r4_core.bundle import Bundle -from fhir_types.hl7_fhir_r4_core.observation import Observation -from fhir_types.hl7_fhir_r4_core.patient import Patient -from fhir_types.hl7_fhir_r4_core.organization import Organization -from pydantic import ValidationError - -FHIR_SERVER_URL = "http://localhost:8080/fhir" -USERNAME = "root" -PASSWORD = ( - "" -) -TOKEN = base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode() - - -@pytest.fixture(scope="module") -def client() -> AsyncFHIRClient: - return AsyncFHIRClient( - FHIR_SERVER_URL, - authorization=f"Basic {TOKEN}", - dump_resource=lambda x: x.model_dump(exclude_none=True), - ) - - -@pytest_asyncio.fixture -async def created_patient(client: AsyncFHIRClient) -> AsyncIterator[Patient]: - patient = Patient( - name=[HumanName(given=["Test"], family="FhirpyPatient")], - gender="female", - birthDate="1980-01-01", - ) - created = await client.create(patient) - yield created - try: - if created.id is not None: - await client.delete(f"Patient/{created.id}") - except Exception: - pass - - -@pytest.mark.asyncio -async def test_create_patient(client: AsyncFHIRClient) -> None: - patient = Patient( - name=[HumanName(given=["Create"], family="Test")], - gender="female", - birthDate="1980-01-01", - ) - - created = await client.create(patient) - assert created.id is not None - assert created.name is not None - assert created.name[0].family == "Test" - assert created.gender == "female" - - await client.delete(f"Patient/{created.id}") - - -@pytest.mark.asyncio -async def test_search_patients(client: AsyncFHIRClient, created_patient: Patient) -> None: - """Test client.resources(Patient).fetch() — requires resourceType class-level access""" - patients = await client.resources(Patient).fetch() - assert len(patients) > 0 - - found = None - for p in patients: - if p.id == created_patient.id: - found = p - break - assert found is not None, f"Patient {created_patient.id} not found in search results" - - -@pytest.mark.asyncio -async def test_search_with_filters(client: AsyncFHIRClient, created_patient: Patient) -> None: - """Test client.resources(Patient).search(family='FhirpyPatient').fetch()""" - patients = await client.resources(Patient).search(family="FhirpyPatient").fetch() - assert len(patients) > 0 - - ids = [p.id for p in patients] - assert created_patient.id in ids - - -@pytest.mark.asyncio -async def test_search_returns_typed_resources(client: AsyncFHIRClient, created_patient: Patient) -> None: - """Verify that fetched resources are deserialized into our generated Patient class""" - patients = await client.resources(Patient).fetch() - for p in patients: - assert isinstance(p, Patient) - assert p.resourceType == "Patient" - - -@pytest.mark.asyncio -async def test_update_patient(client: AsyncFHIRClient, created_patient: Patient) -> None: - assert created_patient.id is not None - - created_patient.name = [HumanName(given=["Updated"], family="FhirpyPatient")] - created_patient.gender = "male" - updated = await client.update(created_patient) - - assert updated.id == created_patient.id - assert updated.gender == "male" - assert updated.name is not None - assert updated.name[0].given == ["Updated"] - - -def test_wrong_resource_type() -> None: - json = """ - { - "resourceType" : "Bundle", - "id" : "bundle-example", - "type" : "searchset", - "total" : 3, - "link" : [{ - "relation" : "self", - "url" : "https://example.com/base/MedicationRequest?patient=347" - }], - "entry" : [{ - "fullUrl" : "https://example.com/base/Patient/3123", - "resource" : { - "resourceType" : "Weird_Patient", - "id" : "3123" - }, - "search" : { - "mode" : "match", - "score" : 1 - } - }] - } - """ - with pytest.raises(ValidationError): - Bundle.from_json(json) - - -def test_wrong_fields() -> None: - json = """ - { - "resourceType" : "Bundle", - "id" : "bundle-example", - "type" : "searchset", - "total" : 3, - "link" : [{ - "relation" : "self", - "url" : "https://example.com/base/MedicationRequest?patient=347" - }], - "entry" : [{ - "fullUrl" : "https://example.com/base/Patient/3123", - "resource" : { - "resourceType" : "Patient", - "id" : "3123", - "very_wrong_field" : "WRONG" - }, - "search" : { - "mode" : "match", - "score" : 1 - } - }] - } - """ - with pytest.raises(ValidationError): - Bundle.from_json(json) - - -def test_to_from_json() -> None: - p = Patient( - name=[HumanName(given=["Test"], family="Patient")], - gender="female", - birthDate="1980-01-01", - ) - json = p.to_json(indent=2) - p2 = Patient.from_json(json) - assert p == p2 - - -def test_resource_type_class_access() -> None: - """Verify that resourceType is accessible at class level (needed for fhirpy search)""" - assert Patient.resourceType == "Patient" - assert Observation.resourceType == "Observation" - assert Bundle.resourceType == "Bundle" diff --git a/examples/python-r4-us-core/README.md b/examples/python-r4-us-core/README.md index 0f21a434d..b215773b5 100644 --- a/examples/python-r4-us-core/README.md +++ b/examples/python-r4-us-core/README.md @@ -1,32 +1,27 @@ # Python Example — FHIR R4 Core + US Core -Python/Pydantic model generation for FHIR R4 core **and** US Core profiles, with -configurable field formats, validation, and a simple `requests`-based FHIR client. +Python/Pydantic model generation for FHIR R4 core **and** US Core profiles, with configurable field formats, validation, and a [fhirpy](https://github.com/beda-software/fhir-py) async FHIR client (the default client). -A single `generate.ts` pulls US Core 8.0.1 (which depends on R4 core) plus a local -`ExampleTypedBundle` StructureDefinition, so one `fhir_types/` tree contains the base -R4 models/profiles (`hl7_fhir_r4_core`), the US Core profiles (`hl7_fhir_us_core`), and -the local typed-bundle profile (`example_folder_structures`). +A single `generate.ts` pulls US Core 8.0.1 (which depends on R4 core) plus a local `ExampleTypedBundle` StructureDefinition, so one `fhir_types/` tree contains the base R4 models/profiles (`hl7_fhir_r4_core`), the US Core profiles (`hl7_fhir_us_core`), and the local typed-bundle profile (`example_folder_structures`). ## Overview -This example demonstrates how to generate Python/Pydantic models using the Atomic EHR -Codegen toolkit. It includes: +This example demonstrates how to generate Python/Pydantic models using the Atomic EHR Codegen toolkit. It includes: - FHIR R4 resource type definitions as Pydantic models, plus base R4 profiles (bodyweight) - US Core profiles (Patient, blood pressure, body weight) with typed accessors and race/ethnicity/birth-sex extensions - Automatic validation and serialization - Configurable field naming conventions (snake_case or camelCase) - Integration with Python type checking (mypy) and IDE support -- A simple FHIR server client example using `requests` (`client.py`, exercised by `test_sdk.py`) +- A `fhirpy` `AsyncFHIRClient` demo (`demo.py`, exercised by `test_sdk.py`); the models carry FHIR camelCase aliases and the client serializes via the generated `serialize()` helper -For an example using the `fhirpy` async client library, see [python-fhirpy/](../python-fhirpy/). +For a simpler `requests`-based client example, see [python-r4/](../python-r4/). ## Tests - `test_profile_*.py` — US Core profile API (offline: no server required) - `test_bundle.py` / `test_raw_extension.py` — generic Bundle and extension handling (offline) -- `test_sdk.py` — live CRUD against a FHIR server via `client.py` (requires Aidbox) +- `test_sdk.py` — live CRUD against a FHIR server via the fhirpy `AsyncFHIRClient` (requires Aidbox) ## Setup @@ -216,6 +211,6 @@ pytest test_sdk.py -v ## Next Steps -- See [python-fhirpy/](../python-fhirpy/) for fhirpy async client example +- See [python-r4/](../python-r4/) for a simpler `requests`-based client example - See [examples/](../) overview for other language examples - Check [../../CLAUDE.md](../../CLAUDE.md) for architecture details diff --git a/examples/python-fhirpy/client.py b/examples/python-r4-us-core/demo.py similarity index 83% rename from examples/python-fhirpy/client.py rename to examples/python-r4-us-core/demo.py index 876b0cf1f..0079bb6a1 100644 --- a/examples/python-fhirpy/client.py +++ b/examples/python-r4-us-core/demo.py @@ -1,12 +1,12 @@ 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 - +from fhir_types.hl7_fhir_r4_core.patient import Patient FHIR_SERVER_URL = "http://localhost:8080/fhir" USERNAME = "root" @@ -16,31 +16,31 @@ async def main() -> None: """ - Demonstrates usage of fhirpy AsyncFHIRClient with generated FHIR types. + Demonstrates usage of fhirpy AsyncFHIRClient with the generated FHIR types. Shows create, search, fetch, and update operations. + + The generated models use snake_case field names with FHIR camelCase aliases, so + `dump_resource` serializes with `by_alias=True` to produce valid FHIR JSON. """ client = AsyncFHIRClient( FHIR_SERVER_URL, authorization=f"Basic {TOKEN}", - dump_resource=lambda x: x.model_dump(exclude_none=True), + dump_resource=lambda x: x.serialize(), ) # Create a Patient patient = Patient( name=[HumanName(given=["Bob"], family="Cool2")], gender="female", - birthDate="1980-01-01", + birth_date="1980-01-01", ) created_patient = await client.create(patient) print(f"Created patient: {created_patient.id}") - print(json.dumps(created_patient.model_dump(exclude_none=True), indent=2)) + print(json.dumps(created_patient.serialize(), indent=2)) # Create an Organization - organization = Organization( - name="Beda Software", - active=True - ) + organization = Organization(name="Beda Software", active=True) created_organization = await client.create(organization) print(f"Created organization: {created_organization.id}") diff --git a/examples/python-r4-us-core/fhir_types/fhirpy_base_model.py b/examples/python-r4-us-core/fhir_types/fhirpy_base_model.py new file mode 100644 index 000000000..24577ea49 --- /dev/null +++ b/examples/python-r4-us-core/fhir_types/fhirpy_base_model.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING, 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. + Uses __pydantic_init_subclass__ to set resourceType as a class-level attribute + after Pydantic finishes model construction, so that fhirpy can detect it + via cls.resourceType for search/fetch operations. + """ + + if TYPE_CHECKING: + # Set at runtime per-subclass by __pydantic_init_subclass__ below. Declared here + # so static type-checkers see it: fhirpy's ResourceProtocol requires a settable + # `resourceType`, and snake_case models expose the field as `resource_type` + # (alias "resourceType"). A settable instance attr (not ClassVar) satisfies the + # protocol's mutable member; the default keeps it optional for the pydantic mypy plugin. + resourceType: str = "" + + id: Optional[str] = Field(None, alias="id") + + @classmethod + 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: + type.__setattr__(cls, "resourceType", str(field.default)) + + 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-r4-us-core/fhir_types/hl7_fhir_r4_core/bundle.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/bundle.py index 1f7496db6..edd889988 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/bundle.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/bundle.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator from typing import Any, Generic, List as PyList, Literal from typing_extensions import Self, TypeVar +from fhir_types.fhirpy_base_model import FhirpyBaseModel from fhir_types.hl7_fhir_r4_core.base import BackboneElement, Identifier, Signature from fhir_types.hl7_fhir_r4_core.resource import Resource @@ -74,7 +75,6 @@ class Bundle(Resource, Generic[T1, T2]): default='Bundle', alias='resourceType', serialization_alias='resourceType', - frozen=True, pattern='Bundle' ) entry: PyList[BundleEntry[T1, T2]] | None = Field(None, alias="entry", serialization_alias="entry") diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/domain_resource.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/domain_resource.py index 3d9310af9..f6d993f6b 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/domain_resource.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/domain_resource.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator from typing import Any, Generic, List as PyList, Literal from typing_extensions import Self, TypeVar +from fhir_types.fhirpy_base_model import FhirpyBaseModel from fhir_types.hl7_fhir_r4_core.base import Extension, Narrative from fhir_types.hl7_fhir_r4_core.resource import Resource @@ -20,7 +21,6 @@ class DomainResource(Resource, Generic[T]): default='DomainResource', alias='resourceType', serialization_alias='resourceType', - frozen=True, pattern='DomainResource' ) contained: PyList[T] | None = Field(None, alias="contained", serialization_alias="contained") diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/observation.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/observation.py index 72528d21a..31eb5d11f 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/observation.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/observation.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, PositiveInt from typing import Any, List as PyList, Literal from typing_extensions import Self +from fhir_types.fhirpy_base_model import FhirpyBaseModel from fhir_types.hl7_fhir_r4_core.base import ( Annotation, BackboneElement, CodeableConcept, Identifier, Period, Quantity, Range, Ratio, Reference, SampledData, \ @@ -49,7 +50,6 @@ class Observation(DomainResource): default='Observation', alias='resourceType', serialization_alias='resourceType', - frozen=True, pattern='Observation' ) based_on: PyList[Reference] | None = Field(None, alias="basedOn", serialization_alias="basedOn") diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/operation_outcome.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/operation_outcome.py index af0c3a9f3..7b0165ae0 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/operation_outcome.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/operation_outcome.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, PositiveInt from typing import Any, List as PyList, Literal from typing_extensions import Self +from fhir_types.fhirpy_base_model import FhirpyBaseModel from fhir_types.hl7_fhir_r4_core.base import BackboneElement, CodeableConcept from fhir_types.hl7_fhir_r4_core.domain_resource import DomainResource @@ -27,7 +28,6 @@ class OperationOutcome(DomainResource): default='OperationOutcome', alias='resourceType', serialization_alias='resourceType', - frozen=True, pattern='OperationOutcome' ) issue: PyList[OperationOutcomeIssue] = Field(alias="issue", serialization_alias="issue") diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/organization.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/organization.py index ceee78c61..56fe8b3dc 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/organization.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/organization.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, PositiveInt from typing import Any, List as PyList, Literal from typing_extensions import Self +from fhir_types.fhirpy_base_model import FhirpyBaseModel from fhir_types.hl7_fhir_r4_core.base import ( Address, BackboneElement, CodeableConcept, ContactPoint, HumanName, Identifier, Reference @@ -28,7 +29,6 @@ class Organization(DomainResource): default='Organization', alias='resourceType', serialization_alias='resourceType', - frozen=True, pattern='Organization' ) active: bool | None = Field(None, alias="active", serialization_alias="active") diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/patient.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/patient.py index 99d1bd6fb..b674889dd 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/patient.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/patient.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, PositiveInt from typing import Any, List as PyList, Literal from typing_extensions import Self +from fhir_types.fhirpy_base_model import FhirpyBaseModel from fhir_types.hl7_fhir_r4_core.base import ( Address, Attachment, BackboneElement, CodeableConcept, ContactPoint, HumanName, Identifier, Period, Reference @@ -41,7 +42,6 @@ class Patient(DomainResource): default='Patient', alias='resourceType', serialization_alias='resourceType', - frozen=True, pattern='Patient' ) active: bool | None = Field(None, alias="active", serialization_alias="active") diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/resource.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/resource.py index ce1b4b216..cf9ed8bfb 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/resource.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/resource.py @@ -6,18 +6,18 @@ from pydantic import BaseModel, ConfigDict, Field, PositiveInt from typing import Any, List as PyList, Literal from typing_extensions import Self +from fhir_types.fhirpy_base_model import FhirpyBaseModel from fhir_types.hl7_fhir_r4_core.base import Meta from fhir_types.hl7_fhir_r4_core.base import Element -class Resource(BaseModel): +class Resource(FhirpyBaseModel): model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid") resource_type: str = Field( default='Resource', alias='resourceType', serialization_alias='resourceType', - frozen=True, pattern='Resource' ) id: str | None = Field(None, alias="id", serialization_alias="id") diff --git a/examples/python-r4-us-core/generate.ts b/examples/python-r4-us-core/generate.ts index 423746ce4..7ae138b94 100644 --- a/examples/python-r4-us-core/generate.ts +++ b/examples/python-r4-us-core/generate.ts @@ -26,7 +26,7 @@ const builder = new APIBuilder({ logger }) allowExtraFields: false, primitiveTypeExtension: true, generateProfile: true, - fhirpyClient: false, + // client defaults to "fhirpy" fieldFormat: "snake_case", }) .typeSchema({ diff --git a/examples/python-r4-us-core/test_sdk.py b/examples/python-r4-us-core/test_sdk.py index 78cb6007b..325ac1fd7 100644 --- a/examples/python-r4-us-core/test_sdk.py +++ b/examples/python-r4-us-core/test_sdk.py @@ -1,159 +1,127 @@ -from typing import Iterator +import base64 +from typing import AsyncIterator import pytest -from client import Auth, AuthCredentials, Client +import pytest_asyncio +from fhirpy import AsyncFHIRClient +from pydantic import ValidationError + from fhir_types.hl7_fhir_r4_core import HumanName from fhir_types.hl7_fhir_r4_core.bundle import Bundle +from fhir_types.hl7_fhir_r4_core.observation import Observation from fhir_types.hl7_fhir_r4_core.patient import Patient -from pydantic import ValidationError FHIR_SERVER_URL = "http://localhost:8080/fhir" USERNAME = "root" -PASSWORD = ( - "" # get actual value from docker-compose.yaml: BOX_ROOT_CLIENT_SECRET -) +PASSWORD = "" # get actual value from docker-compose.yaml: BOX_ROOT_CLIENT_SECRET +TOKEN = base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode() @pytest.fixture(scope="module") -def client() -> Client: - return Client( - base_url=FHIR_SERVER_URL, - auth=Auth( - method="basic", - credentials=AuthCredentials( - username=USERNAME, - password=PASSWORD, - ), - ), +def client() -> AsyncFHIRClient: + # Generated models are snake_case with FHIR camelCase aliases; serialize() dumps by alias. + return AsyncFHIRClient( + FHIR_SERVER_URL, + authorization=f"Basic {TOKEN}", + dump_resource=lambda x: x.serialize(), ) -@pytest.fixture -def created_patient(client: Client) -> Iterator[Patient]: - patient = client.create( - Patient( - name=[HumanName(given=["Test"], family="Patient")], - gender="female", - birth_date="1980-01-01", - ) +@pytest_asyncio.fixture +async def created_patient(client: AsyncFHIRClient) -> AsyncIterator[Patient]: + patient = Patient( + name=[HumanName(given=["Test"], family="FhirpyPatient")], + gender="female", + birth_date="1980-01-01", ) - # This fixture has module scope, so we yield the result for all tests to use - yield patient + created = await client.create(patient) + yield created try: - if patient.id is not None: - client.delete("Patient", patient.id) + if created.id is not None: + await client.delete(f"Patient/{created.id}") except Exception: pass -def test_create_patient(client: Client) -> None: - new_patient = Patient( +@pytest.mark.asyncio +async def test_create_patient(client: AsyncFHIRClient) -> None: + patient = Patient( name=[HumanName(given=["Create"], family="Test")], gender="female", birth_date="1980-01-01", ) - created = client.create(new_patient) + created = await client.create(patient) assert created.id is not None assert created.name is not None assert created.name[0].family == "Test" assert created.gender == "female" assert created.birth_date == "1980-01-01" - client.delete("Patient", created.id) + await client.delete(f"Patient/{created.id}") -def test_read_patient(client: Client, created_patient: Patient) -> None: - assert created_patient.name is not None - assert created_patient.id is not None - read_patient = client.read(Patient, created_patient.id) +@pytest.mark.asyncio +async def test_search_patients(client: AsyncFHIRClient, created_patient: Patient) -> None: + """client.resources(Patient).fetch() — requires class-level resourceType access""" + patients = await client.resources(Patient).fetch() + assert len(patients) > 0 - assert read_patient.id == created_patient.id - assert read_patient.name is not None - assert read_patient.name[0].family == created_patient.name[0].family - assert read_patient.gender == created_patient.gender + found = None + for p in patients: + if p.id == created_patient.id: + found = p + break + assert found is not None, f"Patient {created_patient.id} not found in search results" -def test_update_patient(client: Client, created_patient: Patient) -> None: - assert created_patient.id is not None - patient_to_update = client.read(Patient, created_patient.id) +@pytest.mark.asyncio +async def test_search_with_filters(client: AsyncFHIRClient, created_patient: Patient) -> None: + patients = await client.resources(Patient).search(family="FhirpyPatient").fetch() + assert len(patients) > 0 - assert patient_to_update.id == created_patient.id - assert patient_to_update.gender == "female" - assert patient_to_update.name is not None - assert patient_to_update.name[0].family == "Patient" + ids = [p.id for p in patients] + assert created_patient.id in ids - patient_to_update.name[0].family = "UpdatedFamily" - patient_to_update.name[0].given = ["UpdatedGiven"] - patient_to_update.gender = "male" - updated_patient = client.update(patient_to_update) - assert updated_patient.id == created_patient.id # ID should not change - assert updated_patient.gender == "male" # Gender should be updated - assert updated_patient.name is not None - assert ( - updated_patient.name[0].family == "UpdatedFamily" - ) # Family name should be updated - assert updated_patient.name[0].given == [ - "UpdatedGiven" - ] # Given name should be updated +@pytest.mark.asyncio +async def test_search_returns_typed_resources(client: AsyncFHIRClient, created_patient: Patient) -> None: + """Verify fetched resources deserialize into the generated Patient class.""" + patients = await client.resources(Patient).fetch() + for p in patients: + assert isinstance(p, Patient) + assert p.resource_type == "Patient" + +@pytest.mark.asyncio +async def test_update_patient(client: AsyncFHIRClient, created_patient: Patient) -> None: assert created_patient.id is not None - re_read_patient = client.read(Patient, created_patient.id) - assert re_read_patient.gender == "male" - assert re_read_patient.name is not None - assert re_read_patient.name[0].family == "UpdatedFamily" - assert re_read_patient.name[0].given == ["UpdatedGiven"] + created_patient.name = [HumanName(given=["Updated"], family="FhirpyPatient")] + created_patient.gender = "male" + updated = await client.update(created_patient) -def test_search_patient(client: Client, created_patient: Patient) -> None: - search_params = {"name": "Patient"} - result_bundle = client.search(Patient, search_params) - assert result_bundle is not None - assert result_bundle.total is not None - assert result_bundle.total > 0, "No patients found in search" + assert updated.id == created_patient.id + assert updated.gender == "male" + assert updated.name is not None + assert updated.name[0].given == ["Updated"] - assert result_bundle.entry is not None - foundResource = None - for entry in result_bundle.entry or []: - assert entry.resource is not None - if entry.resource.id == created_patient.id: - foundResource = entry.resource - break - assert foundResource is not None, ( - f"Patient with ID {created_patient.id} not found in search results" - ) - assert type(foundResource) is Patient - assert foundResource.gender == created_patient.gender + +def test_resource_type_class_access() -> None: + """resourceType is exposed at class level (needed for fhirpy search/fetch).""" + assert Patient.resourceType == "Patient" + assert Observation.resourceType == "Observation" + assert Bundle.resourceType == "Bundle" def test_wrong_resource_type() -> None: json = """ { "resourceType" : "Bundle", - "id" : "bundle-example", "type" : "searchset", - "total" : 3, - "link" : [{ - "relation" : "self", - "url" : "https://example.com/base/MedicationRequest?patient=347&_include=MedicationRequest.medication&_count=2" - }, - { - "relation" : "next", - "url" : "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" - }], "entry" : [{ - "fullUrl" : "https://example.com/base/MedicationRequest/3123", - "resource" : { - "resourceType" : "Weird_Patient", - "id" : "3123" - }, - "search" : { - "mode" : "match", - "score" : 1 - } - } - ] + "resource" : { "resourceType" : "Weird_Patient", "id" : "3123" } + }] } """ with pytest.raises(ValidationError): @@ -164,49 +132,16 @@ def test_wrong_fields() -> None: json = """ { "resourceType" : "Bundle", - "id" : "bundle-example", "type" : "searchset", - "total" : 3, - "link" : [{ - "relation" : "self", - "url" : "https://example.com/base/MedicationRequest?patient=347&_include=MedicationRequest.medication&_count=2" - }, - { - "relation" : "next", - "url" : "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" - }], "entry" : [{ - "fullUrl" : "https://example.com/base/MedicationRequest/3123", - "resource" : { - "resourceType" : "Patient", - "id" : "3123", - "very_wrong_field" : "WRONG" - }, - "search" : { - "mode" : "match", - "score" : 1 - } - } - ] + "resource" : { "resourceType" : "Patient", "id" : "3123", "very_wrong_field" : "WRONG" } + }] } """ with pytest.raises(ValidationError): Bundle.from_json(json) -def test_delete_patient(client: Client) -> None: - delete_patient = Patient( - name=[HumanName(given=["Delete"], family="Test")], - gender="other", - ) - - created = client.create(delete_patient) - assert created.id is not None - client.delete("Patient", created.id) - with pytest.raises(Exception) as _excinfo: - client.read(Patient, created.id) - - def test_to_from_json() -> None: p = Patient( name=[HumanName(given=["Test"], family="Patient")], @@ -216,49 +151,3 @@ def test_to_from_json() -> None: json = p.to_json(indent=2) p2 = Patient.from_json(json) assert p == p2 - - -def test_bundle_from_json() -> None: - json = """{ - "resourceType": "Bundle", - "type": "searchset", - "total": 1, - "entry": [{ - "resource": { - "resourceType": "Patient", - "id": "p-1", - "gender": "female" - } - }] - }""" - bundle = Bundle.from_json(json) - assert bundle.entry is not None - assert len(bundle.entry) == 1 - resource = bundle.entry[0].resource - assert resource is not None - assert resource.id == "p-1" - assert type(resource) is Patient - - -def test_to_json_shape() -> None: - import json as json_mod - - p = Patient( - name=[HumanName(given=["Test"], family="Patient")], - gender="female", - birth_date="1980-01-01", - ) - data = json_mod.loads(p.to_json()) - - # Uses FHIR camelCase keys, not Python snake_case; includes resourceType - assert data == { - "resourceType": "Patient", - "name": [{"given": ["Test"], "family": "Patient"}], - "gender": "female", - "birthDate": "1980-01-01", - } - - # Unset fields are omitted - assert "id" not in data - assert "address" not in data - assert "telecom" not in data diff --git a/examples/python-fhirpy/.gitignore b/examples/python-r4/.gitignore similarity index 100% rename from examples/python-fhirpy/.gitignore rename to examples/python-r4/.gitignore diff --git a/examples/python-r4/README.md b/examples/python-r4/README.md new file mode 100644 index 000000000..a2d368e42 --- /dev/null +++ b/examples/python-r4/README.md @@ -0,0 +1,60 @@ +# Python Simple Client Example + +FHIR R4 type generation with Pydantic models and a minimal **`requests`-based** FHIR client. + +> Note: this example hosts the simple synchronous `requests` client. The default +> [`fhirpy`](https://github.com/beda-software/fhir-py) async client is demonstrated in +> [python-r4-us-core/](../python-r4-us-core/). + +## Overview + +This example demonstrates the generated Python/Pydantic models with a small, dependency-light +FHIR client built on `requests`. It includes: + +- FHIR R4 resource type definitions as Pydantic models (snake_case fields, FHIR camelCase aliases) +- A reusable `Client` class (`client.py`) with basic auth and create/read/update/delete/search +- Automatic validation and serialization (`to_json` / `from_json`) + +Generated with `client: "none"` (no client-specific code) and `fieldFormat: "snake_case"`. + +## Setup + +```bash +cd examples/python-r4 +python3 -m venv venv +source venv/bin/activate +pip install -r fhir_types/requirements.txt +``` + +## Generating Types + +```bash +bun run examples/python-r4/generate.ts +``` + +Outputs to `./examples/python-r4/fhir_types/`. + +## Using the Client + +```python +from client import Auth, AuthCredentials, Client +from fhir_types.hl7_fhir_r4_core import HumanName +from fhir_types.hl7_fhir_r4_core.patient import Patient + +client = Client( + base_url="http://localhost:8080/fhir", + auth=Auth(method="basic", credentials=AuthCredentials(username="root", password="secret")), +) + +created = client.create(Patient(name=[HumanName(given=["Bob"], family="Cool")], birth_date="1980-01-01")) +fetched = client.read(Patient, created.id) +``` + +## Tests + +- `test_sdk.py` — live CRUD against a FHIR server via the `requests` `Client` (requires Aidbox) + +## Next Steps + +- See [python-r4-us-core/](../python-r4-us-core/) for the default `fhirpy` async client and US Core profiles +- See [examples/](../) for other language examples diff --git a/examples/python-r4-us-core/client.py b/examples/python-r4/client.py similarity index 100% rename from examples/python-r4-us-core/client.py rename to examples/python-r4/client.py diff --git a/examples/python-fhirpy/generate.ts b/examples/python-r4/generate.ts similarity index 68% rename from examples/python-fhirpy/generate.ts rename to examples/python-r4/generate.ts index c246be40c..f89d7317c 100644 --- a/examples/python-fhirpy/generate.ts +++ b/examples/python-r4/generate.ts @@ -1,17 +1,17 @@ import { APIBuilder, prettyReport } from "../../src"; if (require.main === module) { - console.log("📦 Generating FHIR R4 Core Types with fhirpy support..."); + console.log("📦 Generating FHIR R4 Core Types with simple requests client..."); const builder = new APIBuilder() .throwException() .fromPackage("hl7.fhir.r4.core", "4.0.1") .python({ allowExtraFields: false, - fieldFormat: "camelCase", - fhirpyClient: true, + fieldFormat: "snake_case", + client: "none", }) - .outputTo("./examples/python-fhirpy/fhir_types") + .outputTo("./examples/python-r4/fhir_types") .cleanOutput(true); const report = await builder.generate(); diff --git a/examples/python-fhirpy/mypy.ini b/examples/python-r4/mypy.ini similarity index 100% rename from examples/python-fhirpy/mypy.ini rename to examples/python-r4/mypy.ini diff --git a/examples/python-r4/test_sdk.py b/examples/python-r4/test_sdk.py new file mode 100644 index 000000000..78cb6007b --- /dev/null +++ b/examples/python-r4/test_sdk.py @@ -0,0 +1,264 @@ +from typing import Iterator + +import pytest +from client import Auth, AuthCredentials, Client +from fhir_types.hl7_fhir_r4_core import HumanName +from fhir_types.hl7_fhir_r4_core.bundle import Bundle +from fhir_types.hl7_fhir_r4_core.patient import Patient +from pydantic import ValidationError + +FHIR_SERVER_URL = "http://localhost:8080/fhir" +USERNAME = "root" +PASSWORD = ( + "" # get actual value from docker-compose.yaml: BOX_ROOT_CLIENT_SECRET +) + + +@pytest.fixture(scope="module") +def client() -> Client: + return Client( + base_url=FHIR_SERVER_URL, + auth=Auth( + method="basic", + credentials=AuthCredentials( + username=USERNAME, + password=PASSWORD, + ), + ), + ) + + +@pytest.fixture +def created_patient(client: Client) -> Iterator[Patient]: + patient = client.create( + Patient( + name=[HumanName(given=["Test"], family="Patient")], + gender="female", + birth_date="1980-01-01", + ) + ) + # This fixture has module scope, so we yield the result for all tests to use + yield patient + try: + if patient.id is not None: + client.delete("Patient", patient.id) + except Exception: + pass + + +def test_create_patient(client: Client) -> None: + new_patient = Patient( + name=[HumanName(given=["Create"], family="Test")], + gender="female", + birth_date="1980-01-01", + ) + + created = client.create(new_patient) + assert created.id is not None + assert created.name is not None + assert created.name[0].family == "Test" + assert created.gender == "female" + assert created.birth_date == "1980-01-01" + + client.delete("Patient", created.id) + + +def test_read_patient(client: Client, created_patient: Patient) -> None: + assert created_patient.name is not None + assert created_patient.id is not None + read_patient = client.read(Patient, created_patient.id) + + assert read_patient.id == created_patient.id + assert read_patient.name is not None + assert read_patient.name[0].family == created_patient.name[0].family + assert read_patient.gender == created_patient.gender + + +def test_update_patient(client: Client, created_patient: Patient) -> None: + assert created_patient.id is not None + patient_to_update = client.read(Patient, created_patient.id) + + assert patient_to_update.id == created_patient.id + assert patient_to_update.gender == "female" + assert patient_to_update.name is not None + assert patient_to_update.name[0].family == "Patient" + + patient_to_update.name[0].family = "UpdatedFamily" + patient_to_update.name[0].given = ["UpdatedGiven"] + patient_to_update.gender = "male" + updated_patient = client.update(patient_to_update) + + assert updated_patient.id == created_patient.id # ID should not change + assert updated_patient.gender == "male" # Gender should be updated + assert updated_patient.name is not None + assert ( + updated_patient.name[0].family == "UpdatedFamily" + ) # Family name should be updated + assert updated_patient.name[0].given == [ + "UpdatedGiven" + ] # Given name should be updated + + assert created_patient.id is not None + re_read_patient = client.read(Patient, created_patient.id) + assert re_read_patient.gender == "male" + assert re_read_patient.name is not None + assert re_read_patient.name[0].family == "UpdatedFamily" + assert re_read_patient.name[0].given == ["UpdatedGiven"] + + +def test_search_patient(client: Client, created_patient: Patient) -> None: + search_params = {"name": "Patient"} + result_bundle = client.search(Patient, search_params) + assert result_bundle is not None + assert result_bundle.total is not None + assert result_bundle.total > 0, "No patients found in search" + + assert result_bundle.entry is not None + foundResource = None + for entry in result_bundle.entry or []: + assert entry.resource is not None + if entry.resource.id == created_patient.id: + foundResource = entry.resource + break + assert foundResource is not None, ( + f"Patient with ID {created_patient.id} not found in search results" + ) + assert type(foundResource) is Patient + assert foundResource.gender == created_patient.gender + + +def test_wrong_resource_type() -> None: + json = """ + { + "resourceType" : "Bundle", + "id" : "bundle-example", + "type" : "searchset", + "total" : 3, + "link" : [{ + "relation" : "self", + "url" : "https://example.com/base/MedicationRequest?patient=347&_include=MedicationRequest.medication&_count=2" + }, + { + "relation" : "next", + "url" : "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" + }], + "entry" : [{ + "fullUrl" : "https://example.com/base/MedicationRequest/3123", + "resource" : { + "resourceType" : "Weird_Patient", + "id" : "3123" + }, + "search" : { + "mode" : "match", + "score" : 1 + } + } + ] + } + """ + with pytest.raises(ValidationError): + Bundle.from_json(json) + + +def test_wrong_fields() -> None: + json = """ + { + "resourceType" : "Bundle", + "id" : "bundle-example", + "type" : "searchset", + "total" : 3, + "link" : [{ + "relation" : "self", + "url" : "https://example.com/base/MedicationRequest?patient=347&_include=MedicationRequest.medication&_count=2" + }, + { + "relation" : "next", + "url" : "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" + }], + "entry" : [{ + "fullUrl" : "https://example.com/base/MedicationRequest/3123", + "resource" : { + "resourceType" : "Patient", + "id" : "3123", + "very_wrong_field" : "WRONG" + }, + "search" : { + "mode" : "match", + "score" : 1 + } + } + ] + } + """ + with pytest.raises(ValidationError): + Bundle.from_json(json) + + +def test_delete_patient(client: Client) -> None: + delete_patient = Patient( + name=[HumanName(given=["Delete"], family="Test")], + gender="other", + ) + + created = client.create(delete_patient) + assert created.id is not None + client.delete("Patient", created.id) + with pytest.raises(Exception) as _excinfo: + client.read(Patient, created.id) + + +def test_to_from_json() -> None: + p = Patient( + name=[HumanName(given=["Test"], family="Patient")], + gender="female", + birth_date="1980-01-01", + ) + json = p.to_json(indent=2) + p2 = Patient.from_json(json) + assert p == p2 + + +def test_bundle_from_json() -> None: + json = """{ + "resourceType": "Bundle", + "type": "searchset", + "total": 1, + "entry": [{ + "resource": { + "resourceType": "Patient", + "id": "p-1", + "gender": "female" + } + }] + }""" + bundle = Bundle.from_json(json) + assert bundle.entry is not None + assert len(bundle.entry) == 1 + resource = bundle.entry[0].resource + assert resource is not None + assert resource.id == "p-1" + assert type(resource) is Patient + + +def test_to_json_shape() -> None: + import json as json_mod + + p = Patient( + name=[HumanName(given=["Test"], family="Patient")], + gender="female", + birth_date="1980-01-01", + ) + data = json_mod.loads(p.to_json()) + + # Uses FHIR camelCase keys, not Python snake_case; includes resourceType + assert data == { + "resourceType": "Patient", + "name": [{"given": ["Test"], "family": "Patient"}], + "gender": "female", + "birthDate": "1980-01-01", + } + + # Unset fields are omitted + assert "id" not in data + assert "address" not in data + assert "telecom" not in data diff --git a/examples/python-fhirpy/tsconfig.json b/examples/python-r4/tsconfig.json similarity index 100% rename from examples/python-fhirpy/tsconfig.json rename to examples/python-r4/tsconfig.json