From 7dde8ec7e48de6d974821985dfab4c4502e3a3ac Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 18 Jun 2026 17:43:07 +0200 Subject: [PATCH 1/2] PY: expose resourceType on the fhirpy base model for static type-checkers fhirpy AsyncFHIRClient is generic over TResource, bound to a ResourceProtocol with a settable resourceType. snake_case models expose the discriminator as resource_type (alias resourceType) and only set resourceType at runtime via __pydantic_init_subclass__, which mypy cannot see. Declare a TYPE_CHECKING-only settable instance annotation (resourceType: str = "") so the models satisfy the protocol bound and class access, with no runtime change. --- .../api/writer-generator/python/fhirpy_base_model.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/assets/api/writer-generator/python/fhirpy_base_model.py b/assets/api/writer-generator/python/fhirpy_base_model.py index 4c41c0bdc..24577ea49 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 TYPE_CHECKING, Any, Union, Optional, Iterator, Tuple, Dict from pydantic import BaseModel, Field from typing import Protocol @@ -15,6 +15,15 @@ class FhirpyBaseModel(BaseModel): 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 From 6e72a72e1e8e3aba201a86b90bf550b0e1225413 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 18 Jun 2026 17:43:07 +0200 Subject: [PATCH 2/2] API: add python client option, default fhirpy; deprecate fhirpyClient Replace the boolean fhirpyClient with client: "fhirpy" | "none" on .python({}). client wins; fhirpyClient is the deprecated fallback (logs a warning, still translated); the default is now "fhirpy". Add tests for the default, "none", and the deprecated path. --- src/api/writer-generator/python/writer.ts | 22 ++++++++++++++++- test/api/write-generator/python.test.ts | 30 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/api/writer-generator/python/writer.ts b/src/api/writer-generator/python/writer.ts index 6c60b64cf..e7d3cac37 100644 --- a/src/api/writer-generator/python/writer.ts +++ b/src/api/writer-generator/python/writer.ts @@ -79,6 +79,16 @@ export interface PythonGeneratorOptions extends WriterOptions { generateProfile?: boolean; rootPackageName: string; /// e.g. .hl7_fhir_r4_core.Patient. fieldFormat: StringFormatKey; + /** + * Which client integration to generate into the output models. + * - `"fhirpy"` — models extend `FhirpyBaseModel` for use with the fhirpy async client (default). + * - `"none"` — plain Pydantic models with no client-specific code. + */ + client?: "fhirpy" | "none"; + /** + * @deprecated Use `client` instead: `fhirpyClient: true` → `client: "fhirpy"`, + * `fhirpyClient: false` → `client: "none"`. + */ fhirpyClient?: boolean; } @@ -105,10 +115,20 @@ export class Python extends Writer { constructor(options: PythonGeneratorOptions) { super({ ...options, resolveAssets: options.resolveAssets ?? resolvePyAssets }); this.nameFormatFunction = this.getFieldFormatFunction(options.fieldFormat); - this.forFhirpyClient = options.fhirpyClient ?? false; + this.forFhirpyClient = this.resolveClient(options); this.fieldFormat = options.fieldFormat; } + /** Resolve which client integration to emit. `client` wins; `fhirpyClient` is the deprecated fallback; default is fhirpy. */ + private resolveClient(options: PythonGeneratorOptions): boolean { + if (options.client !== undefined) return options.client === "fhirpy"; + if (options.fhirpyClient !== undefined) { + this.logger()?.warn('python: `fhirpyClient` is deprecated; use `client: "fhirpy" | "none"` instead.'); + return options.fhirpyClient; + } + return true; // default: fhirpy client + } + override async generate(tsIndex: TypeSchemaIndex): Promise { this.tsIndex = tsIndex; const groups: TypeSchemaPackageGroups = { diff --git a/test/api/write-generator/python.test.ts b/test/api/write-generator/python.test.ts index 50625c353..74b2ee5b4 100644 --- a/test/api/write-generator/python.test.ts +++ b/test/api/write-generator/python.test.ts @@ -6,6 +6,7 @@ describe("Python Writer Generator", async () => { const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) .python({ inMemoryOnly: true, + client: "none", }) .generate(); expect(result.success).toBeTrue(); @@ -112,6 +113,7 @@ describe("Python R4 Example (with generateProfile)", async () => { .python({ inMemoryOnly: true, generateProfile: true, + client: "none", }) .generate(); const files = result.filesGenerated.python!; @@ -150,6 +152,7 @@ describe("Python US Core Example", async () => { .python({ inMemoryOnly: true, generateProfile: true, + client: "none", }) .generate(); const files = result.filesGenerated.python!; @@ -182,3 +185,30 @@ describe("Python US Core Example", async () => { expect(files["generated/hl7_fhir_us_core/profiles/__init__.py"]).toMatchSnapshot(); }); }); + +describe("Python client option", async () => { + const gen = async (opts: { client?: "fhirpy" | "none"; fhirpyClient?: boolean }) => { + const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) + .python({ inMemoryOnly: true, ...opts }) + .generate(); + expect(result.success).toBeTrue(); + return result.filesGenerated.python!; + }; + + it("defaults to the fhirpy client", async () => { + const files = await gen({}); + expect(files["generated/fhirpy_base_model.py"]).toBeDefined(); + expect(files["generated/hl7_fhir_r4_core/resource.py"]).toContain("FhirpyBaseModel"); + }); + + it('client: "none" emits plain models with no fhirpy code', async () => { + const files = await gen({ client: "none" }); + expect(files["generated/fhirpy_base_model.py"]).toBeUndefined(); + expect(files["generated/hl7_fhir_r4_core/resource.py"]).not.toContain("FhirpyBaseModel"); + }); + + it("deprecated fhirpyClient: true still selects the fhirpy client", async () => { + const files = await gen({ fhirpyClient: true }); + expect(files["generated/fhirpy_base_model.py"]).toBeDefined(); + }); +});