Skip to content
Merged
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
229 changes: 121 additions & 108 deletions bun.lock

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def set_race(self, value: "UscoreRaceExtension | Extension | dict") -> "UscorePa
sub_extensions.append({"url": "detailed", "value_coding": item})
if value.get("text") is not None:
sub_extensions.append({"url": "text", "value_string": value["text"]})
push_extension(self._resource, {"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", "extension": sub_extensions})
push_extension(self._resource, Extension(url="http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", extension=sub_extensions))
return self

@overload
Expand Down Expand Up @@ -150,7 +150,7 @@ def set_ethnicity(self, value: "UscoreEthnicityExtension | Extension | dict") ->
sub_extensions.append({"url": "detailed", "value_coding": item})
if value.get("text") is not None:
sub_extensions.append({"url": "text", "value_string": value["text"]})
push_extension(self._resource, {"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", "extension": sub_extensions})
push_extension(self._resource, Extension(url="http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", extension=sub_extensions))
return self

@overload
Expand Down Expand Up @@ -185,7 +185,7 @@ def set_tribal_affiliation(self, value: "UscoreTribalAffiliationExtension | Exte
sub_extensions.append({"url": "tribalAffiliation", "value_codeable_concept": value["tribalAffiliation"]})
if value.get("isEnrolled") is not None:
sub_extensions.append({"url": "isEnrolled", "value_boolean": value["isEnrolled"]})
push_extension(self._resource, {"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation", "extension": sub_extensions})
push_extension(self._resource, Extension(url="http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation", extension=sub_extensions))
return self

@overload
Expand Down Expand Up @@ -214,7 +214,7 @@ def set_sex(self, value: "UscoreIndividualSexExtension | Extension | Any") -> "U
raise ValueError(f"Expected extension url 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-individual-sex', got {_get_key(value, 'url')!r}")
push_extension(self._resource, value)
else:
push_extension(self._resource, {"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-individual-sex", "value_coding": value})
push_extension(self._resource, Extension(url="http://hl7.org/fhir/us/core/StructureDefinition/us-core-individual-sex", value_coding=value))
return self

@overload
Expand Down Expand Up @@ -243,7 +243,7 @@ def set_interpreter_required(self, value: "UscoreInterpreterNeededExtension | Ex
raise ValueError(f"Expected extension url 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed', got {_get_key(value, 'url')!r}")
push_extension(self._resource, value)
else:
push_extension(self._resource, {"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed", "value_coding": value})
push_extension(self._resource, Extension(url="http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed", value_coding=value))
return self

def validate(self) -> dict[str, list[str]]:
Expand Down
4 changes: 2 additions & 2 deletions examples/python-us-core/test_profile_patient.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ def test_set_race_get_race_round_trip_with_detailed_categories():
)

race = profile.get_race()
assert race["ombCategory"]["code"] == "2106-3"
assert race["ombCategory"].code == "2106-3"
assert race["text"] == "White European"


Expand Down Expand Up @@ -350,7 +350,7 @@ def test_fluent_chaining_across_extensions():
assert profile.get_race()["text"] == "White"
assert profile.get_ethnicity()["text"] == "Not Hispanic or Latino"
assert profile.get_sex().code == "male"
assert profile.get_tribal_affiliation()["tribalAffiliation"]["text"] == "Navajo"
assert profile.get_tribal_affiliation()["tribalAffiliation"].text == "Navajo"
assert profile.get_interpreter_required().code == "no"


Expand Down
13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,16 @@
"@atomic-ehr/fhirschema": "0.0.11",
"mustache": "^4.2.0",
"picocolors": "^1.1.1",
"yaml": "^2.8.3",
"yaml": "^2.9.0",
"yargs": "^18.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@types/bun": "^1.3.9",
"@biomejs/biome": "^2.5.0",
"@types/bun": "^1.3.14",
"@types/mustache": "^4.2.6",
"@types/node": "^22.19.11",
"@types/node": "^25.9.3",
"@types/yargs": "^17.0.35",
"knip": "^5.88.1",
"knip": "^6.17.1",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
},
Expand All @@ -69,6 +69,7 @@
"rollup": ">=4.59.0",
"smol-toml": ">=1.6.1",
"brace-expansion": ">=5.0.6",
"picomatch": ">=4.0.4"
"picomatch": ">=4.0.4",
"esbuild": ">=0.28.1"
}
}
17 changes: 12 additions & 5 deletions src/api/writer-generator/python/profile-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const generateExtensionMethods = (
generateComplexExtensionSetter(w, ext, className, baseName, targetPath, extProfileInfo);
} else if (ext.valueFieldTypes?.length === 1 && ext.valueFieldTypes[0]) {
const valueType = ext.valueFieldTypes[0];
const valueField = pyValueFieldName(valueType);
const valueField = pyValueFieldName(valueType, w.nameFormatFunction);
const pyType = pyTypeFromIdentifier(valueType);
generateSingleValueExtensionGetter(w, ext, baseName, targetPath, valueField, pyType, extProfileInfo);
generateSingleValueExtensionSetter(w, ext, className, baseName, targetPath, valueField, extProfileInfo);
Expand Down Expand Up @@ -224,7 +224,9 @@ const generateComplexExtensionGetter = (
): void => {
generateExtensionGetter(w, ext, baseName, "dict", targetPath, extProfileInfo, () => {
const configItems = (ext.subExtensions ?? []).map((sub) => {
const valueField = sub.valueFieldType ? pyValueFieldName(sub.valueFieldType) : "value";
const valueField = sub.valueFieldType
? pyValueFieldName(sub.valueFieldType, w.nameFormatFunction)
: "value";
const isArray = sub.max === "*";
return `{"name": ${JSON.stringify(sub.url)}, "valueField": ${JSON.stringify(valueField)}, "isArray": ${isArray ? "True" : "False"}}`;
});
Expand Down Expand Up @@ -269,7 +271,9 @@ const generateComplexExtensionSetter = (
generateExtensionSetter(w, ext, className, baseName, "dict", targetPath, extProfileInfo, () => {
w.line("sub_extensions = []");
for (const sub of ext.subExtensions ?? []) {
const valueField = sub.valueFieldType ? pyValueFieldName(sub.valueFieldType) : "value";
const valueField = sub.valueFieldType
? pyValueFieldName(sub.valueFieldType, w.nameFormatFunction)
: "value";
if (sub.max === "*") {
w.line(`for item in value.get(${JSON.stringify(sub.url)}, []):`);
w.indentBlock(() => {
Expand All @@ -286,7 +290,9 @@ const generateComplexExtensionSetter = (
});
}
}
const extObj = `{"url": ${JSON.stringify(ext.url)}, "extension": sub_extensions}`;
// Wrap in an Extension model so Pydantic coerces the sub-extension dicts and
// serializes their value fields with FHIR aliases (valueCoding, not value_coding).
const extObj = `Extension(url=${JSON.stringify(ext.url)}, extension=sub_extensions)`;
emitExtPush(w, targetPath, extObj);
});
};
Expand Down Expand Up @@ -319,7 +325,8 @@ const generateSingleValueExtensionSetter = (
extProfileInfo: ExtensionProfileInfo | undefined,
): void => {
generateExtensionSetter(w, ext, className, baseName, "Any", targetPath, extProfileInfo, () => {
emitExtPush(w, targetPath, `{"url": ${JSON.stringify(ext.url)}, ${JSON.stringify(valueField)}: value}`);
// Wrap in an Extension model so the value field serializes with its FHIR alias.
emitExtPush(w, targetPath, `Extension(url=${JSON.stringify(ext.url)}, ${valueField}=value)`);
});
};

Expand Down
30 changes: 16 additions & 14 deletions src/api/writer-generator/python/profile-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,27 +179,27 @@ export const collectProfileFactoryInfo = (
// ---------------------------------------------------------------------------

/** Build `*, param1: Type1, param2: Type2` keyword-only signature. */
export const buildParamSignature = (factoryInfo: ProfileFactoryInfo): string => {
export const buildParamSignature = (factoryInfo: ProfileFactoryInfo, formatName: (s: string) => string): string => {
const parts: string[] = [];
for (const f of factoryInfo.sliceAutoFields) {
parts.push(`${pyFieldName(f.name)}: ${f.pyType} | None = None`);
parts.push(`${pyFieldName(f.name, formatName)}: ${f.pyType} | None = None`);
}
for (const p of factoryInfo.params) {
parts.push(`${pyFieldName(p.name)}: ${p.pyType}`);
parts.push(`${pyFieldName(p.name, formatName)}: ${p.pyType}`);
}
if (parts.length === 0) return "";
return `*, ${parts.join(", ")}`;
};

/** Build call-site args matching the param signature. */
export const buildCallArgs = (factoryInfo: ProfileFactoryInfo): string => {
export const buildCallArgs = (factoryInfo: ProfileFactoryInfo, formatName: (s: string) => string): string => {
const parts: string[] = [];
for (const f of factoryInfo.sliceAutoFields) {
const name = pyFieldName(f.name);
const name = pyFieldName(f.name, formatName);
parts.push(`${name}=${name}`);
}
for (const p of factoryInfo.params) {
const name = pyFieldName(p.name);
const name = pyFieldName(p.name, formatName);
parts.push(`${name}=${name}`);
}
return parts.join(", ");
Expand All @@ -217,15 +217,16 @@ export const generateCreateResource = (
hasParams: boolean,
factoryInfo: ProfileFactoryInfo,
): void => {
const fmt = w.nameFormatFunction;
w.line("@classmethod");
if (hasParams) {
w.line(`def create_resource(cls, ${buildParamSignature(factoryInfo)}) -> ${annotatedBaseTypeName}:`);
w.line(`def create_resource(cls, ${buildParamSignature(factoryInfo, fmt)}) -> ${annotatedBaseTypeName}:`);
} else {
w.line(`def create_resource(cls) -> ${annotatedBaseTypeName}:`);
}
w.indentBlock(() => {
for (const f of factoryInfo.sliceAutoFields) {
const fieldName = pyFieldName(f.name);
const fieldName = pyFieldName(f.name, fmt);
const matchRefs = f.sliceNames.map((s) => `cls.${pySliceStaticName(s)}`);
if (matchRefs.length === 1) {
w.line(`${fieldName}_with_defaults = ensure_slice_defaults(list(${fieldName} or []), ${matchRefs[0]})`);
Expand All @@ -242,13 +243,13 @@ export const generateCreateResource = (

const buildArgs: string[] = [];
for (const f of factoryInfo.autoFields) {
buildArgs.push(`${pyFieldName(f.name)}=${f.value}`);
buildArgs.push(`${pyFieldName(f.name, fmt)}=${f.value}`);
}
for (const f of factoryInfo.sliceAutoFields) {
buildArgs.push(`${pyFieldName(f.name)}=${pyFieldName(f.name)}_with_defaults`);
buildArgs.push(`${pyFieldName(f.name, fmt)}=${pyFieldName(f.name, fmt)}_with_defaults`);
}
for (const p of factoryInfo.params) {
buildArgs.push(`${pyFieldName(p.name)}=${pyFieldName(p.name)}`);
buildArgs.push(`${pyFieldName(p.name, fmt)}=${pyFieldName(p.name, fmt)}`);
}
if (isResourceBase) {
buildArgs.push(`meta={"profile": [cls.canonical_url]}`);
Expand All @@ -275,8 +276,9 @@ export const generateFieldAccessors = (
factoryInfo: ProfileFactoryInfo,
extSliceMethodBaseNames: Set<string>,
): void => {
const fmt = w.nameFormatFunction;
for (const p of factoryInfo.params) {
const fieldName = pyFieldName(p.name);
const fieldName = pyFieldName(p.name, fmt);
const methodSuffix = pySnakeName(p.name);
w.line(`def get_${methodSuffix}(self) -> ${p.pyType} | None:`);
w.indentBlock(() => {
Expand All @@ -294,7 +296,7 @@ export const generateFieldAccessors = (
for (const a of factoryInfo.accessors) {
const methodSuffix = pySnakeName(a.name);
if (extSliceMethodBaseNames.has(methodSuffix)) continue;
const fieldName = pyFieldName(a.name);
const fieldName = pyFieldName(a.name, fmt);
w.line(`def get_${methodSuffix}(self) -> ${a.pyType} | None:`);
w.indentBlock(() => {
w.line(`return getattr(self._resource, ${JSON.stringify(fieldName)}, None)`);
Expand All @@ -304,7 +306,7 @@ export const generateFieldAccessors = (
w.indentBlock(() => {
if (a.choiceSiblings?.length) {
for (const sibling of a.choiceSiblings) {
w.line(`setattr(self._resource, ${JSON.stringify(pyFieldName(sibling))}, None)`);
w.line(`setattr(self._resource, ${JSON.stringify(pyFieldName(sibling, fmt))}, None)`);
}
}
w.line(`setattr(self._resource, ${JSON.stringify(fieldName)}, value)`);
Expand Down
15 changes: 10 additions & 5 deletions src/api/writer-generator/python/profile-naming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ export const pySnakeName = (name: string): string => {
return snakeCase(cleaned);
};

/** Snake-case a field name, escaping Python keywords. */
export const pyFieldName = (n: string): string => {
const out = pySnakeName(n);
/** Format a field name per the active field-naming mode (defaults to snake_case),
* escaping Python keywords. The result must match the model's field name, so the
* same `formatName` used to generate the models is threaded through. */
export const pyFieldName = (n: string, formatName: (s: string) => string = snakeCase): string => {
const cleaned = n.replace(/\[x\]/g, "").replace(/[:./]/g, "_");
const out = formatName(cleaned);
return PYTHON_KEYWORDS.has(out) ? `${out}_` : out;
};

Expand Down Expand Up @@ -83,8 +86,10 @@ export const pySliceStaticName = (name: string): string => {
return `_${snakeCase(cleaned)}_slice_match`;
};

/** snake_case the FHIR `value[x]` field for a TypeIdentifier. */
export const pyValueFieldName = (id: TypeIdentifier): string => `value_${snakeCase(normalizePyName(id.name))}`;
/** The FHIR `value[x]` field name for a TypeIdentifier, formatted per the active
* field-naming mode (e.g. `value_coding` for snake_case, `valueCoding` for camelCase). */
export const pyValueFieldName = (id: TypeIdentifier, formatName: (s: string) => string = snakeCase): string =>
formatName(`value${pascalCase(normalizePyName(id.name))}`);

export type ResolvedProfileMethods = {
extensions: Record<string, string>;
Expand Down
4 changes: 2 additions & 2 deletions src/api/writer-generator/python/profile-slices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export const generateSliceGetters = (
const baseName =
sliceBaseNames[`${sliceDef.fieldName}:${sliceDef.sliceName}`] ?? sliceDef.nameCandidates.recommended;
const staticName = pySliceStaticName(sliceDef.sliceName);
const fieldName = pyFieldName(sliceDef.fieldName);
const fieldName = pyFieldName(sliceDef.fieldName, w.nameFormatFunction);
const matchKeys = JSON.stringify(Object.keys(sliceDef.match));

if (sliceDef.isTypeDiscriminated) {
Expand Down Expand Up @@ -236,7 +236,7 @@ export const generateSliceSetters = (
const baseName =
sliceBaseNames[`${sliceDef.fieldName}:${sliceDef.sliceName}`] ?? sliceDef.nameCandidates.recommended;
const staticName = pySliceStaticName(sliceDef.sliceName);
const fieldName = pyFieldName(sliceDef.fieldName);
const fieldName = pyFieldName(sliceDef.fieldName, w.nameFormatFunction);
if (sliceDef.isTypeDiscriminated) {
const retType = sliceElementRetType(sliceDef);
const isUnbounded = sliceDef.array && sliceDef.max === 0;
Expand Down
5 changes: 3 additions & 2 deletions src/api/writer-generator/python/profile-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,20 @@ export const collectValidateBody = (
resolveRef: TypeSchemaIndex["findLastSpecializationByIdentifier"],
errorLines: string[],
warningLines: string[],
formatName: (s: string) => string,
): Set<string> => {
const helpers = new Set<string>();
const fields = flatProfile.fields;
for (const [name, field] of Object.entries(fields)) {
const pyName = pyFieldName(name);
const pyName = pyFieldName(name, formatName);
if (isChoiceInstanceField(field)) {
collectProhibitedChoiceValidation(fields, name, pyName, helpers, errorLines);
continue;
}
if (isChoiceDeclarationField(field)) {
if (field.required) {
helpers.add("validate_choice_required");
const pyChoices = field.choices.map(pyFieldName);
const pyChoices = field.choices.map((c) => pyFieldName(c, formatName));
errorLines.push(
`errors.extend(validate_choice_required(self._resource, profile_name, ${JSON.stringify(pyChoices)}))`,
);
Expand Down
7 changes: 5 additions & 2 deletions src/api/writer-generator/python/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,10 @@ const generateCreateMethod = (
): void => {
w.line("@classmethod");
if (hasParams) {
w.line(`def create(cls, ${buildParamSignature(factoryInfo)}) -> "${className}":`);
w.indentBlock(() => w.line(`return cls.apply(cls.create_resource(${buildCallArgs(factoryInfo)}))`));
w.line(`def create(cls, ${buildParamSignature(factoryInfo, w.nameFormatFunction)}) -> "${className}":`);
w.indentBlock(() =>
w.line(`return cls.apply(cls.create_resource(${buildCallArgs(factoryInfo, w.nameFormatFunction)}))`),
);
} else {
w.line(`def create(cls) -> "${className}":`);
w.indentBlock(() => w.line("return cls.apply(cls.create_resource())"));
Expand Down Expand Up @@ -387,6 +389,7 @@ const generateProfileModule = (w: Python, tsIndex: TypeSchemaIndex, flatProfile:
tsIndex.findLastSpecializationByIdentifier,
errorLines,
warningLines,
w.nameFormatFunction,
);

const helperImports = collectHelperImports(isResourceBase, factoryInfo, sliceDefs, extensions, validationHelpers);
Expand Down
Loading
Loading