Skip to content

Commit 45486e5

Browse files
authored
Merge pull request #252 from atomic-ehr/py/reference-family-comment
PY: name the widened reference family, and drop the TypeScript references from comments
2 parents 0a9eb47 + 97c8a1e commit 45486e5

7 files changed

Lines changed: 48 additions & 15 deletions

File tree

‎examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/observation.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class Observation(DomainResource):
6767
effectivePeriod: Period | None = Field(None, alias="effectivePeriod", serialization_alias="effectivePeriod")
6868
effectiveTiming: Timing | None = Field(None, alias="effectiveTiming", serialization_alias="effectiveTiming")
6969
encounter: Reference[Literal["Encounter"]] | None = Field(None, alias="encounter", serialization_alias="encounter")
70-
focus: PyList[Reference] | None = Field(None, alias="focus", serialization_alias="focus")
70+
focus: PyList[Reference] | None = Field(None, alias="focus", serialization_alias="focus") # Resource
7171
hasMember: PyList[Reference[Literal["MolecularSequence", "Observation", "QuestionnaireResponse"]]] | None = Field(None, alias="hasMember", serialization_alias="hasMember")
7272
identifier: PyList[Identifier] | None = Field(None, alias="identifier", serialization_alias="identifier")
7373
interpretation: PyList[CodeableConcept[Literal["_GeneticObservationInterpretation", "CAR", "Carrier", "_ObservationInterpretationChange", "B", "D", "U", "W", "_ObservationInterpretationExceptions", "<", ">", "AC", "IE", "QCF", "TOX", "_ObservationInterpretationNormality", "A", "AA", "HH", "LL", "H", "H>", "HU", "L", "L<", "LU", "N", "_ObservationInterpretationSusceptibility", "I", "MS", "NCL", "NS", "R", "SYN-R", "S", "SDD", "SYN-S", "VS", "EX", "HX", "LX", "HM", "ObservationInterpretationDetection", "IND", "E", "NEG", "ND", "POS", "DET", "ObservationInterpretationExpectation", "EXP", "UNE", "OBX", "ReactivityObservationInterpretation", "NR", "RR", "WR"] | str]] | None = Field(None, alias="interpretation", serialization_alias="interpretation")

‎examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/provenance.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class ProvenanceEntity(BackboneElement):
2626
model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid")
2727
agent: PyList[ProvenanceAgent] | None = Field(None, alias="agent", serialization_alias="agent")
2828
role: Literal["derivation", "revision", "quotation", "source", "removal"] = Field(alias="role", serialization_alias="role")
29-
what: Reference = Field(alias="what", serialization_alias="what")
29+
what: Reference = Field(alias="what", serialization_alias="what") # Resource
3030

3131

3232
class Provenance(DomainResource):
@@ -50,7 +50,7 @@ class Provenance(DomainResource):
5050
recorded: str = Field(alias="recorded", serialization_alias="recorded")
5151
recordedExtension: Element | None = Field(None, alias="_recorded", serialization_alias="_recorded")
5252
signature: PyList[Signature] | None = Field(None, alias="signature", serialization_alias="signature")
53-
target: PyList[Reference] = Field(alias="target", serialization_alias="target")
53+
target: PyList[Reference] = Field(alias="target", serialization_alias="target") # Resource
5454

5555
def model_post_init(self, __context: Any) -> None:
5656
self.__pydantic_fields_set__.add("resourceType")

‎src/api/writer-generator/python/naming-utils.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,10 @@ export const pyTypeFromIdentifier = (id: TypeIdentifier): string => {
146146
return deriveResourceName(id);
147147
};
148148

149-
/** `Literal[...]` type argument for a `Reference` field's targets, mirroring
150-
* the TypeScript writer's `Reference<"Patient" | ...>`. Returns undefined
151-
* when there are no targets or a target is a family type (e.g. `Resource`),
152-
* where the bare `Reference` (defaulting to `str`) is the right annotation. */
149+
/** `Literal[...]` type argument naming a `Reference` field's permitted targets.
150+
* Returns undefined when there are no targets, or when one is a family type
151+
* (e.g. `Resource`) — a family admits any resource, so the bare `Reference`
152+
* (defaulting to `str`) is the honest annotation. */
153153
export const pyReferenceTypeParam = (
154154
field: RegularField | ChoiceFieldInstance,
155155
tsIndex: TypeSchemaIndex,
@@ -160,3 +160,18 @@ export const pyReferenceTypeParam = (
160160
const names = [...new Set(resolved.map((ref) => ref.name))];
161161
return `Literal[${names.map((n) => JSON.stringify(n)).join(", ")}]`;
162162
};
163+
164+
/** The family a reference's targets were widened to, when they were.
165+
*
166+
* A family target such as `Resource` leaves the annotation a bare `Reference`,
167+
* which on its own does not say whether the element is unconstrained by design
168+
* or whether something was lost. The name goes at end of line, since Python
169+
* cannot carry a comment inside an annotation. */
170+
export const pyReferenceFamilyName = (
171+
field: RegularField | ChoiceFieldInstance,
172+
tsIndex: TypeSchemaIndex,
173+
): string | undefined => {
174+
if (!field.reference || field.reference.resource.length === 0) return undefined;
175+
const resolved = field.reference.resource.map((ref) => tsIndex.findLastSpecializationByIdentifier(ref));
176+
return resolved.find(tsIndex.isFamilyType)?.name;
177+
};

‎src/api/writer-generator/python/profile-slices.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export type SliceDef = {
2121
match: Record<string, unknown>;
2222
required: string[];
2323
array: boolean;
24-
/** 0 = unbounded ("*"), mirrors TS SliceDef.max */
24+
/** 0 = unbounded ("*"). */
2525
max: number;
2626
constrainedChoice: ConstrainedChoiceInfo | undefined;
2727
elementTypeName: string | undefined;
@@ -274,7 +274,7 @@ export const generateSliceSetters = (
274274
}
275275
} else {
276276
// Make input optional when there are no required fields (input can be empty / omitted),
277-
// mirroring TS `inputOptional = sliceDef.required.length === 0`.
277+
// A slice that requires nothing of its element accepts no input.
278278
const inputOptional = sliceDef.required.length === 0;
279279
const sig = inputOptional
280280
? `def set_${baseName}(self, value: dict[str, Any] | None = None) -> "${className}":`

‎src/api/writer-generator/python/profile-validation.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,6 @@ const collectRegularFieldValidation = (
148148
* satisfied by any one of its permitted typed variants — the choice base name
149149
* itself is not a FHIR element and can never be present on a conformant
150150
* resource, so it must never be emitted as a plain required field.
151-
*
152-
* Mirrors `collectSliceRequirements` in the TypeScript writer.
153151
*/
154152
const collectSliceRequirements = (
155153
slice: FieldSlice,

‎src/api/writer-generator/python/writer.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
type TypeIdentifier,
2424
} from "@typeschema/types.ts";
2525
import { resolveGeneratorAsset } from "../assets";
26-
import { pyReferenceTypeParam } from "./naming-utils";
26+
import { pyReferenceFamilyName, pyReferenceTypeParam } from "./naming-utils";
2727
import { collectProfileClassNames, generateNewProfiles } from "./profile";
2828

2929
export const resolvePyAssets = (fn: string) => resolveGeneratorAsset(import.meta.url, "python", fn);
@@ -94,6 +94,8 @@ interface FieldInfo {
9494
name: string;
9595
type: string;
9696
defaultValue: string;
97+
/** Trailing `# <Family>` when a family target widened the annotation away. */
98+
comment?: string;
9799
}
98100

99101
type TypeSchemaPackageGroups = {
@@ -302,8 +304,8 @@ export class Python extends Writer<PythonGeneratorOptions> {
302304
}
303305

304306
/** Re-export a package's profile classes from its `__init__.py`, so they are
305-
* reachable as `<package>.<ProfileClass>` the way the TypeScript barrel makes
306-
* them. Explicit, so strict mypy (`no_implicit_reexport`) sees the names. */
307+
* reachable as `<package>.<ProfileClass>` and not only by module path.
308+
* Explicit, so strict mypy (`no_implicit_reexport`) sees the names. */
307309
private importPackageProfiles(profileNames: string[]): void {
308310
if (profileNames.length === 0) return;
309311
this.pyImportFrom(".profiles", ...profileNames);
@@ -533,7 +535,7 @@ export class Python extends Writer<PythonGeneratorOptions> {
533535
if ("choices" in field && field.choices) continue;
534536

535537
const fieldInfo = this.buildFieldInfo(fieldName, field, schema);
536-
this.line(`${fieldInfo.name}: ${fieldInfo.type}${fieldInfo.defaultValue}`);
538+
this.line(`${fieldInfo.name}: ${fieldInfo.type}${fieldInfo.defaultValue}${fieldInfo.comment ?? ""}`);
537539

538540
if (withExtensions && "type" in field && isPrimitiveIdentifier(field.type)) {
539541
this.addPrimitiveExtensionField(fieldName, field.array ?? false);
@@ -567,11 +569,13 @@ export class Python extends Writer<PythonGeneratorOptions> {
567569
const pyFieldName = fixReservedWords(this.nameFormatFunction(fieldName));
568570
const fieldType = this.determineFieldType(field, fieldName, schema);
569571
const defaultValue = this.getFieldDefaultValue(field, fieldName);
572+
const family = this.tsIndex && "reference" in field ? pyReferenceFamilyName(field, this.tsIndex) : undefined;
570573

571574
return {
572575
name: pyFieldName,
573576
type: fieldType,
574577
defaultValue: defaultValue,
578+
comment: family ? ` # ${family}` : undefined,
575579
};
576580
}
577581

‎test/api/write-generator/python.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,22 @@ describe("Python Writer Generator", async () => {
6969
});
7070
});
7171

72+
describe("observation.py", () => {
73+
const observationPy = files["generated/hl7_fhir_r4_core/observation.py"];
74+
// A family target widens the annotation to a bare Reference; the name it
75+
// was widened from is kept at end of line, since Python cannot comment
76+
// inside an annotation.
77+
it("names the family a reference was widened from", () => {
78+
expect(observationPy).toContain(
79+
'focus: PyList[Reference] | None = Field(None, alias="focus", serialization_alias="focus") # Resource',
80+
);
81+
});
82+
it("leaves a narrow reference without the comment", () => {
83+
expect(observationPy).toContain("subject: Reference[Literal[");
84+
expect(observationPy).not.toMatch(/subject: Reference\[Literal\[[^\n]*# \w/);
85+
});
86+
});
87+
7288
describe("patient.py", () => {
7389
const patientPy = files["generated/hl7_fhir_r4_core/patient.py"];
7490
it("generates CodeableConcept fields with enum bindings", () => {

0 commit comments

Comments
 (0)