Skip to content

Commit cbc8f76

Browse files
feat: Explicitly export IO schema field order (#595)
#### Description of changes Export IO schemas' field declaration order in JSON. This is needed for downstream clients to reliably reproduce the order of inputs/outputs as declared in the schema (e.g. Postgres will scramble JSON field order). #### Testing done Local, CI --------- Co-authored-by: Dion Häfner <dion.haefner@simulation.science>
1 parent 39b27ce commit cbc8f76

2 files changed

Lines changed: 70 additions & 13 deletions

File tree

tesseract_core/runtime/schema_generation.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,19 @@ def _recurse_over_model_tree(treeobj: Any, path: list[str]) -> Any:
214214
return _recurse_over_model_tree(Schema, [])
215215

216216

217+
def _path_tuple_to_str(pathtuple: tuple) -> str:
218+
"""Convert a path tuple to a dotted string, replacing sentinels with [] / {} indexing."""
219+
str_parts = []
220+
for part in pathtuple:
221+
if part is SEQ_INDEX_SENTINEL:
222+
str_parts.append("[]")
223+
elif part is DICT_INDEX_SENTINEL:
224+
str_parts.append("{}")
225+
else:
226+
str_parts.append(part)
227+
return ".".join(str_parts)
228+
229+
217230
def _serialize_diffable_arrays(
218231
obj: dict[tuple, Any],
219232
) -> dict[str, dict[str, Any]]:
@@ -233,24 +246,19 @@ def _serialize_diffable_arrays(
233246
else:
234247
shape = tuple(shape)
235248

236-
# Replace sentinel values with indexing syntax
237-
str_parts = []
238-
for part in pathtuple:
239-
if part is SEQ_INDEX_SENTINEL:
240-
str_parts.append("[]")
241-
elif part is DICT_INDEX_SENTINEL:
242-
str_parts.append("{}")
243-
else:
244-
str_parts.append(part)
245-
246-
serialized[".".join(str_parts)] = {
249+
serialized[_path_tuple_to_str(pathtuple)] = {
247250
"shape": shape,
248251
"dtype": dtype,
249252
}
250253

251254
return serialized
252255

253256

257+
def _serialize_field_order(obj: dict[tuple, list[str]]) -> dict[str, list[str]]:
258+
"""Convert a dict {path_tuple: [field_names]} to a dict {path_str: [field_names]}."""
259+
return {_path_tuple_to_str(p): fields for p, fields in obj.items()}
260+
261+
254262
def create_apply_schema(
255263
InputSchema: type[BaseModel], OutputSchema: type[BaseModel]
256264
) -> tuple[type[BaseModel], type[BaseModel]]:
@@ -266,6 +274,11 @@ def create_apply_schema(
266274
OutputSchema, filter_fn=is_differentiable
267275
)
268276

277+
# Snapshot field declaration order for every (nested) model, since JSON object key order is
278+
# not guaranteed to be preserved by downstream clients (e.g. postgres).
279+
input_field_order = get_field_order(InputSchema)
280+
output_field_order = get_field_order(OutputSchema)
281+
269282
InputSchema = apply_function_to_model_tree(
270283
InputSchema,
271284
lambda x, _: x,
@@ -287,9 +300,15 @@ class ApplyInputSchema(BaseModel):
287300
differentiable_arrays: ClassVar[dict[str, dict[str, Any]]] = (
288301
_serialize_diffable_arrays(diffable_input_paths)
289302
)
303+
field_order: ClassVar[dict[str, list[str]]] = _serialize_field_order(
304+
input_field_order
305+
)
290306
model_config = ConfigDict(
291307
extra="forbid",
292-
json_schema_extra={"differentiable_arrays": differentiable_arrays},
308+
json_schema_extra={
309+
"differentiable_arrays": differentiable_arrays,
310+
"field_order": field_order,
311+
},
293312
)
294313

295314
class ApplyOutputSchema(RootModel):
@@ -299,8 +318,14 @@ class ApplyOutputSchema(RootModel):
299318
differentiable_arrays: ClassVar[dict[str, dict[str, Any]]] = (
300319
_serialize_diffable_arrays(diffable_output_paths)
301320
)
321+
field_order: ClassVar[dict[str, list[str]]] = _serialize_field_order(
322+
output_field_order
323+
)
302324
model_config = ConfigDict(
303-
json_schema_extra={"differentiable_arrays": differentiable_arrays}
325+
json_schema_extra={
326+
"differentiable_arrays": differentiable_arrays,
327+
"field_order": field_order,
328+
},
304329
)
305330

306331
return ApplyInputSchema, ApplyOutputSchema
@@ -382,6 +407,25 @@ def add_path(obj: T, path: tuple) -> T:
382407
return path_to_type
383408

384409

410+
def get_field_order(schema: type[BaseModel]) -> dict[tuple, list[str]]:
411+
"""Return a mapping {model_path: [field_names_in_declaration_order]} for every BaseModel in the tree.
412+
413+
The root model is keyed by the empty tuple. Field names are collected as a side effect while
414+
traversing leaf paths: any string at position `i` in a leaf path is a field of the model at
415+
prefix `path[:i]`.
416+
"""
417+
field_order_by_path: dict[tuple, dict[str, None]] = {}
418+
419+
def add_field(obj: T, path: tuple) -> T:
420+
for i, part in enumerate(path):
421+
if isinstance(part, str):
422+
field_order_by_path.setdefault(path[:i], {})[part] = None
423+
return obj
424+
425+
apply_function_to_model_tree(schema, add_field)
426+
return {p: list(fields) for p, fields in field_order_by_path.items()}
427+
428+
385429
def _path_to_pattern(path: Sequence[str | object]) -> str:
386430
"""Return a type describing valid paths for all passed paths."""
387431
# Check if path includes sequence or dict indexing -- in this case, we can't use the

tests/runtime_tests/test_schema_generation.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,19 @@ def test_create_apply_schema():
119119
OutputSchema.model_validate({**testinput, "foo": 1})
120120

121121

122+
def test_field_order_exported():
123+
InputSchema, OutputSchema = create_apply_schema(NestedModel, NestedModel)
124+
125+
expected = {
126+
"": list(NestedModel.model_fields.keys()),
127+
"testfoo.[]": list(SubModel.model_fields.keys()),
128+
"testbar.{}": list(SubModel.model_fields.keys()),
129+
"testrootmodel": ["root"],
130+
}
131+
assert InputSchema.model_json_schema()["field_order"] == expected
132+
assert OutputSchema.model_json_schema()["field_order"] == expected
133+
134+
122135
def test_create_abstract_eval_schema():
123136
testinput_abstract = replace_arrays(
124137
testinput, lambda arr: {"shape": arr["shape"], "dtype": arr["dtype"]}

0 commit comments

Comments
 (0)