Repository navigation
fix(349)!: export plain models and nested lists, refuse model unions - #414
Conversation
to_json_schema() and to_stickler_config() fell back to "string" / "str" for
any annotation missing from their type tables. A plain BaseModel, a list of
plain models and a list of lists all took that fallback, so the re-imported
field was str (or List[str], or an unknown type in the config) and could not
load the data the original model accepted. The guard meant to stop a nested
model reaching the fallback checked `isinstance(field_type, type)`, which is
False for Union[Card, Bank], so that union exported as "string" too.
from_json_schema() builds exactly that union from a multi-arm anyOf, so a
schema-only round trip broke.
- A plain BaseModel is scored as one object, as a mapping is, so it exports
as a free-form {"type": "object"} ("dict" in a Stickler config, List[dict]
for a list) and re-imports as dict with its comparison settings. Its own
field types are not exported.
- A list of lists exports as nested arrays, so List[List[float]] and
List[List[Dict]] reload. The element goes through the existing primitive
list branch, so that branch stays the one place that writes a list
property.
- to_stickler_config() has no type for a union with a model arm, or for a
StructuredModel inside a nested list, so both exporters raise ValueError
for these and follow one rule. JSON Schema could express the union (anyOf),
and that export could follow; a model inside a nested list would also
need importer work. Optional and
Annotated wrappers are stripped at every level before the check.
Mapping fields and List[Dict] elements are left alone here: the first is
handled in awslabs#360, the second in awslabs#405.
Refs awslabs#349 (parts B and C)
sromoam
left a comment
There was a problem hiding this comment.
Thanks @Thanatos9404. Refusing model unions instead of silently writing "string" is the right call, and the nested-array export is a real improvement. I measured everything below on 7faefc0 with the import pinned to the PR tree.
Two things to fix, both about valid models that now fail to export:
Annotatedaround a nested model is refused. The export dispatch (structured_model.py:1847) stripsOptionalbut notAnnotated. A supported nested field therefore skips the recursive branch and reaches_refuse_scalar_export. For example,card: Optional[Annotated[Card, Field(description="d")]](how pydantic storesAnnotated[Card, Field(...)] | None) andcards: List[Annotated[Card, "m"]]both raise "Cannot export Card as a scalar" on both exporters. RunningConfigurationHelper.strip_annotation_wrappersin the dispatch, for both the field and the list element, should send them down the recursive path.- A single-level list is called a nested list.
_refuse_scalar_exportsetsnested=Trueafter removing one list level (:2011), butfield_to_propertyandfield_to_stickler_configpass it the whole field annotation. Soitems: Optional[Annotated[List[Card], Field(description="d")]]raises "has no type for a StructuredModel inside a nested list". Please add these two shapes totest_export_roundtrip_shapes.py. They're what let this slip through.
Not blocking, but please narrow the CHANGELOG and test docstring claim ("neither exporter writes a structured field as a scalar"). The check doesn't look inside container arms or containers yet:
Union[List[Card], str]andUnion[Dict[str, Card], str]still export as"string".List[List[Dict[str, Card]]]andTuple[Card, ...]don't raise.- A
List[List[Union[str, int]]]leaf still falls back to"string"(:2054).
I'll file those as a follow-up so this PR doesn't grow. The helper consolidation (_json_type_schema and the four plain-model overrides) can wait too.
Both exporters stripped Optional but not Annotated before dispatching, so a nested model declared as Optional[Annotated[Card, Field(...)]] or List[Annotated[Card, "m"]] skipped the recursive branch and was refused as a scalar. The field and the list element now go through strip_annotation_wrappers, so they export as the model, and an annotated scalar exports as its own type instead of a string. _refuse_scalar_export now takes the field's whole annotation and counts list levels, so a single List[Card] is no longer reported as a nested list. Both list branches pass it the whole field. The CHANGELOG entry and the test docstring now say which shapes the refusal does not reach yet. Refs awslabs#349
|
Thanks for measuring this so carefully. Both fixes are in bcc24de: the export dispatch now runs |
sromoam
left a comment
There was a problem hiding this comment.
Thanks @Thanatos9404, this is exactly what was needed. I re-measured on bcc24de with the import pinned to the PR tree.
Optional[Annotated[Card, Field(description="d")]],List[Annotated[Card, "m"]]andOptional[Annotated[List[Card], Field(description="d")]]now take the recursive path on both exporters. Each round-trips throughfrom_json_schema()andmodel_from_json()with the same scores as the original.- Having
_refuse_scalar_exportcount list depth on the whole annotation is a cleaner rule than before, and the single-level test pins it. - Nearby shapes (
Optional[Card],List[Optional[Card]], outerAnnotated, nested primitive lists, plain models) still round-trip, and the model-union and nested-list refusals still refuse. The suite passes on its own and merged withdev.
The narrowed CHANGELOG reads well, and I'll track the container cases in #426.
One heads-up, no action needed here: this overlaps with #405 in the to_stickler_config() list branch. Whichever lands second keeps #405's List[Dict] mapping branch first, then your plain-model dict fallback.
Approving. Thanks again for the careful follow-up.
Security Scan ResultsASH Security Scan Report
Scan Metadata
SummaryScanner ResultsThe table below shows findings by scanner, with status based on severity thresholds and dependencies:
Top 2 HotspotsFiles with the highest number of security findings:
Detailed FindingsShow 4 actionable findingsFinding 1: SECRET-HEX-HIGH-ENTROPY-STRING
Description: Code Snippet: Finding 2: B108
Description: Code Snippet: Finding 3: B307
Description: Code Snippet: Finding 4: B307
Description: Code Snippet: Report generated by Automated Security Helper (ASH) at 2026-10-19T21:24:27+00:00 |
… optional elements nullable Review follow-ups on #405: - docs: a list of lists exports nested arrays since #414; drop "or another list" from the string-export sentence. - to_stickler_config() writes List[Optional[Dict]] for an optional mapping element, so the re-imported model accepts None elements again. - A declared ComparableField whose annotation resolves only at model_rebuild() exported Levenshtein with clip on while the engine scored ANLS* with clip off (0.95 vs 0.5 after the round trip). Mapping lists now always export get_comparison_info(), declared or not, and the stale comparator_config is dropped.
Pull Request
Issue Reference
Refs #349 (parts B and C).
Not in this PR: part A is #407, the
List[Dict]row is #405, and the singularDictrow is left to #360, which already exports a mapping field as an object on both paths.Description of Changes
to_json_schema()andto_stickler_config()fell back to"string"/"str"for any annotation missing from their type tables, and the guard meant to stop a nested model reaching that fallback checkedisinstance(field_type, type), which is False forUnion[Card, Bank]. For the shapes below, each exporter now writes a type its importer reads back as one that holds the same data, or raises:BaseModel, or a list of them{"type": "object"}/"dict"(List[dict]for a list), re-imported asdictwith the declared comparator, threshold, weight and clipList[List[Dict]]List[List]Union[Card, Bank],Union[Plain, str]), also as a list element or insideOptional/AnnotatedValueErroron both exportersList[List[Card]])ValueErroron both exportersChanges Made
structured_model.py: the StructuredModel guard moves into_refuse_scalar_export(), which stripsOptionalandAnnotatedat every level and also refuses a union with a model arm and a StructuredModel inside a nested list. Into_json_schema(), a list of lists or of plain models still goes through the primitive list branch, and itsitemsis then replaced by_json_type_schema()(nested arrays, objects for mappings and plain models). Scalar elements, scalar unions and mapping elements are untouched. Into_stickler_config(), a list of plain models is writtenList[dict]; it wasList[<ClassName>], whichmodel_from_json()refused withUnknown type.json_schema_field_converter.py:field_to_property()andfield_to_stickler_config()call that guard, and write a plain model as"object"/"dict".tests/structured_object_evaluator/test_export_roundtrip_shapes.py, 45 tests.docs/docs/Advanced/model-export.md: one paragraph under the "at a glance" table naming what these shapes export as, what is not kept, and what raises.CHANGELOG.md: one**Breaking:**entry under[Unreleased]/ Changed.Why These Changes Are Needed
The issue's model, one field per model so the union does not stop the others.
e403b9bisdev,8ade791is this branch; "loads" means the re-imported model accepts the issue'sDATAvalue.to_json_schema()on devto_stickler_config()on devchild: Plainstring, ValidationErrorobject, loadsstr, ValidationErrordict, loadseither: Union[Card, Bank]string, ValidationErrorValueErrorstr, ValidationErrorValueErrornested: List[List[float]]string, ValidationErrorarrayofnumber, loadsList[List], loadsmeta: Dict[str, Any]string, ValidationErrorstr, ValidationErrorrows: List[Dict[str, str]]string, ValidationErrorList[Dict], loadskid: Cardobject, loadsstructured_model, loadsfield_to_property()on the issue's C table:Union[Card, Bank]went from{"type": "string"}toValueError,Plainfrom"string"to"object", andCardstill raises. The schema-only loop from part C (from_json_schema(S).to_json_schema()with the two-armanyOf) wrote"string"on dev and then could not reload{"iban": "DE89"}; it now raises at the export.List[List[Dict[str, str]]], which the #405 review pointed here, now round-trips on the JSON path and scores the same (0.9706before and after).I also exported 40 annotations on both trees and compared the exported
type/itemsand whether the re-import loads a sample value. 11 changed: four plain-model spellings (Plain,Optional[Plain],List[Plain],List[Optional[Plain]]), four lists of lists, and three unions with a model arm, which now raise. The other 29 are unchanged, including every mapping spelling, every scalar (date,Decimal,UUID,Enum,Literal,Any,bytes),Card,List[Card],List[Dict[str, str]],Union[str, int],List[Union[str, int]],TupleandSet.Two choices worth flagging:
BaseModelexports as an object, not a raise. The acceptance list asks the guard to cover it. One403b9b, raising there fails 10 tests inTestTheExportedConfigurationMatchesTheEngine, which pin thatto_json_schema()reports the comparator the engine uses for a plain-model field. The engine scores a plain model the way it scores a mapping (is_object_grade_annotation), so a free-form object reloads the data and scores the same, which the tests check. Its own field types are not exported. If you would rather it raise, it is a small change plus those 10 tests.anyOfofCard.to_json_schema()andBank.to_json_schema()re-imports asUnion[Card, Bank]and scores the same. A model inside a nested list would also need importer work: a nested array ofCard.to_json_schema()re-imports asList[List[DynamicModel]], withoutCard's name or settings. The Stickler config has no type for either (itsList[List]re-scoredList[List[Card]]from0.9857to0.9706on dev), so the JSON path raises too, to keep one rule for both exports. AnanyOfexport for the union can follow if you want it.Testing
signal.SIGALRM, details below)Windows, Python 3.12.5,
uv sync --frozen --extra llm,pytest tests -n 6.inspect.getfile(stickler)pointed at the tree named in each row.e403b9b8ade791test_export_roundtrip_shapes.py(45)The 6 failures are the same on both:
test_realistic_deep_nesting.py,AttributeError: module 'signal' has no attribute 'SIGALRM'.On dev, the new tests fail for the reported reasons: 22 because nothing raised (nine union spellings and the nested-model case on both exporters, the schema-only loop,
field_to_property()); 6 because the config wroteList[Plain], whichmodel_from_json()refuses; 2 withValidationErroron the re-imported model; and 8 type assertions on"string"/"str"/List[Plain]. The 7 that pass on dev are guards: the JSON re-import resolves the same comparator, threshold, weight and clip for each of the five fields (the extensions already survived; only the type was wrong), the config still writesList[List]for a list of lists (the documented limit), andfield_to_property()still refuses a bareStructuredModel.The round-trip test has one value wrong in every field; the original and both re-imports give the same field scores and confusion matrix.
test_value_types_are_not_exportedpins the documented limit (a plain model withn: intcoerces"5"; the re-importeddictkeeps"5").ruff checkandruff format --checkpass on the three changed Python files;ruff check .reports the same 16 findings on both trees, all inexamples/.mkdocs buildsucceeds on both trees with the same 9 warnings.Reviewers
@sromoam
Checklist
CHANGELOG.mdentry added under[Unreleased], or not needed becausenothing user-visible changed (see RELEASING.md)
Additional Notes
default_factory=listdefault on both paths, because the new list shapes share the primitive list branch.ComparableField(child: Optional[Plain] = None) exports nox-aws-stickler-*keys, so it re-imports with the importer's defaults (ExactComparatorat1.0) and a one-of-two-wrong pair scores0.0instead of0.5. On dev this export could not reload the data at all. Same cause as the bare-field note in fix(333): export a List[Dict] element as an object so the schema loads its own data #405 (Comparator, threshold and clip resolve differently on the JSON Schema and Stickler-config paths than on the Python path, so #300 and #316 landed on one entry point each #348).dict, which the engine scores slightly differently there:List[List[Plain]]went from0.9762to0.9773in my test pair on both paths (the config path already did this on dev).List[Union[Dict, str]](the other shape the fix(333): export a List[Dict] element as an object so the schema loads its own data #405 review pointed here) and scalar unions such asUnion[str, int]still export as"string";to_stickler_config()still writesList[Optional[X]]asList[X]and a list of lists without its inner type.By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.