Skip to content

fix(json-schema): annotate optional fields as Optional[T] so from_json accepts None#150

Closed
valter-silva-au wants to merge 1 commit into
awslabs:devfrom
valter-silva-au:fix/optional-field-none-from-json
Closed

fix(json-schema): annotate optional fields as Optional[T] so from_json accepts None#150
valter-silva-au wants to merge 1 commit into
awslabs:devfrom
valter-silva-au:fix/optional-field-none-from-json

Conversation

@valter-silva-au

Copy link
Copy Markdown

Issue #, if available: #149

Problem

from_json(process_rich_values=True) rejects None for optional fields that plain ModelClass(**data) accepts. A model built from a JSON Schema raises pydantic ValidationError ("Input should be a valid string ... input_value=None") when an optional field is omitted and rich-value processing is on.

Root cause

When a JSON-Schema field is optional (absent from required), from_json_schema() builds a Pydantic field with default=None but a non-nullable annotation (bare str, not Optional[str]). Plain construction tolerates the omitted field, but the rich-value path round-trips nested objects through from_json(...).model_dump() (ConfigurationHelper._process_nested_structured_data). model_dump() materializes the None default, and re-validation checks it against the bare annotation — which fails. The default-vs-annotation mismatch is the bug.

Fix (3 sites)

In json_schema_field_converter.py, widen field_type to Optional[field_type] whenever the field is not required, mirroring the existing default = ... if is_required else None decision, at: primitive (convert_property_to_field), nested object (_handle_nested_object), and array (_handle_array_type). Required fields keep bare annotations. configuration_helper.py already handles Optional[...] correctly — it just never received them.

Testing

Added a regression test (TestOptionalFieldNoneFromJson) covering nested/primitive/array optional fields, asserting both M(**data) and M.from_json(data, process_rich_values=True) succeed and agree. Added a focused converter test asserting optional fields → Optional[...] and required fields stay bare. Updated existing converter assertions to unwrap the Optional wrapper for not-required fields only. Full suite: 1080 passed, 2 skipped (pre-existing skips), 0 failures.

Risk

Low. Annotation-only change for not-required fields; required-field behavior is unchanged. Export round-trip and None-handling suites pass.

Coordination note: PR #127 also touches this file, handling explicit-nullable forms ({"type": ["string","null"]} / nullable: true); this PR handles the implicit case (optional = absent from required). The two are complementary but overlap around the field_type computation — happy to rebase/coordinate so both converge on a single Optional-widening point.

Fixes #149


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

…n accepts None

from_json_schema() built optional fields (those absent from `required`) with
`default=None` but a NON-nullable annotation (bare `str`, not `Optional[str]`).
Plain `ModelClass(**data)` tolerated the omitted field, but
`from_json(process_rich_values=True)` did not: the rich-value path round-trips
nested objects through `from_json(...).model_dump()`
(ConfigurationHelper._process_nested_structured_data), which materializes the
`None` default and re-validates it against the bare annotation, raising a
pydantic ValidationError ("Input should be a valid string ... input_value=None").

Fix widens `field_type` to `Optional[field_type]` whenever the field is not
required, mirroring the existing `default = ... if is_required else None`
decision, at all three conversion sites:
- primitive types (convert_property_to_field)
- nested objects (_handle_nested_object)
- arrays (_handle_array_type)

Required fields keep their bare annotation. Downstream configuration_helper
already handles `Optional[...]` correctly; it simply never received them.

Existing converter assertions that checked bare `is str`/`is int`/List[...] for
NOT-REQUIRED fields are updated to unwrap the Optional wrapper (required fields
stay bare). Adds regression coverage for the awslabs#149 repro and for the
optional->Optional annotation at all three sites.

Fixes awslabs#149
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

Security Scan Results

ASH Security Scan Report

  • Report generated: 2026-06-08T08:24:33+00:00
  • Time since scan: 2 minutes

Scan Metadata

  • Project: ASH
  • Scan executed: 2026-06-08T08:22:29+00:00
  • ASH version: 3.1.2

Summary

Scanner Results

The table below shows findings by scanner, with status based on severity thresholds and dependencies:

  • Severity levels:
    • Suppressed (S): Findings that have been explicitly suppressed and don't affect scanner status
    • Critical (C): Highest severity findings that require immediate attention
    • High (H): Serious findings that should be addressed soon
    • Medium (M): Moderate risk findings
    • Low (L): Lower risk findings
    • Info (I): Informational findings with minimal risk
  • Duration (Time): Time taken by the scanner to complete its execution
  • Actionable: Number of findings at or above the threshold severity level that require attention
  • Result:
    • PASSED = No findings at or above threshold
    • FAILED = Findings at or above threshold
    • MISSING = Required dependencies not available
    • SKIPPED = Scanner explicitly disabled
    • ERROR = Scanner execution error
  • Threshold: The minimum severity level that will cause a scanner to fail
    • Thresholds: ALL, LOW, MEDIUM, HIGH, CRITICAL
    • Source: Values in parentheses indicate where the threshold is set:
      • global (global_settings section in the ASH_CONFIG used)
      • config (scanner config section in the ASH_CONFIG used)
      • scanner (default configuration in the plugin, if explicitly set)
  • Statistics calculation:
    • All statistics are calculated from the final aggregated SARIF report
    • Suppressed findings are counted separately and do not contribute to actionable findings
    • Scanner status is determined by comparing actionable findings to the threshold
Scanner Suppressed Critical High Medium Low Info Actionable Result Threshold
bandit 0 0 0 0 3724 0 0 PASSED MEDIUM (global)
cdk-nag 0 0 0 0 0 0 0 PASSED MEDIUM (global)
cfn-nag 0 0 0 0 0 0 0 MISSING MEDIUM (global)
checkov 0 1 0 0 0 0 1 SKIPPED MEDIUM (global)
detect-secrets 0 0 0 0 0 0 0 PASSED MEDIUM (global)
grype 0 0 0 0 0 0 0 MISSING MEDIUM (global)
npm-audit 0 0 0 0 0 0 0 PASSED MEDIUM (global)
opengrep 0 0 0 0 0 0 0 MISSING MEDIUM (global)
semgrep 0 0 0 0 0 0 0 PASSED MEDIUM (global)
syft 0 0 0 0 0 0 0 MISSING MEDIUM (global)

Top 1 Hotspots

Files with the highest number of security findings:

Finding Count File Location
1 .github/workflows/workflow.yml

Detailed Findings

Show 1 actionable findings

Finding 1: CKV2_GHA_1

  • Severity: HIGH
  • Scanner: checkov
  • Rule ID: CKV2_GHA_1
  • Location: .github/workflows/workflow.yml:11-12

Description:
Ensure top-level permissions are not set to write-all


Report generated by Automated Security Helper (ASH) at 2026-06-08T08:24:34+00:00

@adiadd adiadd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apologies for the delayed review - thanks for this! just a couple things:

blocking:

  • Optional[NestedModel] isn't recognized by is_structured_field_type, so compare_recursive silently drops the nested metric breakdown for optional object fields inside array items, verified empirically against base. see the inline comment in _handle_nested_object.

worth resolving before merge:

  • the parallel stickler-config path (field_converter.py) still has the identical bug, and to_stickler_config round-trips a fixed model back into a broken one. inline comment in convert_property_to_field.
  • required fields with an explicit "default": null still crash the same way; the widening condition probably wants not is_required or default is None. inline at json_schema_field_converter.py:147.
  • the 3-site widening collapses cleanly to one site in convert_property_to_field, which is also the convergence point with #127. inline in _handle_array_type.
  • two of the three regression tests pass without the fix; only the nested one actually pins the bug. inline in test_json_schema_integration.py.
  • explicit-null payloads are now accepted where they used to raise, an observable contract change with no test or release-note line. inline on the new test class.
  • HTML report threshold extraction also misses Optional-wrapped fields; fine as a filed follow-up. inline near the array return.

nits (non-blocking):

  • test_json_schema_field_converter.py:111: the get_origin(req_type) is not typing.Union assert is redundant right after req_type is str.
  • the test helper _unwrap_optional (test_json_schema_field_converter.py:16-28) near-duplicates StructuredModel._unwrap_optional (structured_model.py:1357); fine for test independence but a pointer comment would help future-us.
  • test_export_roundtrip.py:234's "Optional nature is not preserved in round-trip" comment is now partially stale for the JSON-Schema direction.

on the #127 overlap you flagged: agreed it's real, and the single-site widening above is the concrete convergence point that makes both PRs compose cleanly.

appreciate it!

# round-trips through from_json(...).model_dump(), which materializes
# the None default and re-validates it against the annotation.
field_type = NestedModel
if not is_required:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this widening leaks into annotation-based dispatch that doesn't understand Optional[NestedModel]. is_structured_field_type (configuration_helper.py:152-168) only scans the Union branch for List[...], so the elif inspect.isclass(annotation) arm never runs for a union and Optional[NestedModel] returns False. structured_list_comparator.py:264-266 uses that (via structured_model.py:647) to route sub-fields of list-of-objects items into _handle_hierarchical_field, so an optional object field inside an array item now takes the non-hierarchical path.

verified on this head vs base: a schema with people: array of {name (required), addr: object (optional)} through compare_recursive used to return fields.people.fields.addr with the full nested breakdown (fields.city, derived precision/recall/f1, raw_similarity_score); post-fix addr collapses to flat counts only. top-line scores are unchanged (dispatch there is runtime-isinstance), so nothing fails loudly, the detailed metric tree just silently degrades for any schema where an object inside an array item isn't in required, which is exactly the population #149 targets.

fix is small: add an Optional[StructuredModel] case to the Union branch of is_structured_field_type; _is_optional_structured_model at configuration_helper.py:386 already handles this exact shape. worth a regression test asserting compare_recursive keeps the nested fields breakdown for an optional nested object inside a list of objects.

# None default is a valid value. See issue #149: the rich-value path
# round-trips through from_json(...).model_dump(), which materializes
# the None default and re-validates it against the annotation.
if not is_required:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the fix is asymmetric: the stickler-config path still builds the exact broken shape. field_converter.py:54 resolves the annotation from the type string and field_converter.py:85-87 sets default = None for non-required fields without widening, so model_from_json(config) with "required": false still crashes from_json(process_rich_values=True) exactly as in #149. and since to_stickler_config emits "required": false for these fields (json_schema_field_converter.py:505-507), schema -> to_stickler_config -> model_from_json round-trips a fixed model back into a broken one.

ideally both paths get fixed together since they converge on the same ModelFactory; at minimum worth filing a follow-up issue so the round-trip hole is tracked.

@@ -147,7 +147,14 @@ def convert_property_to_field(
default = property_schema.get("default", ... if is_required else None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small gap in the widening condition: a field that is in required but carries an explicit "default": null keeps the bare annotation and the None default, and i verified from_json(..., process_rich_values=True) still raises the same ValidationError for that shape. the invariant being repaired is "a None default needs a nullable annotation", so the condition arguably wants to be not is_required or default is None rather than keying off required alone.

# None default is a valid value. See issue #149: the rich-value path
# round-trips through from_json(...).model_dump(), which materializes
# the None default and re-validates it against the annotation.
if not is_required:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth collapsing the three identical widening blocks into one site. convert_property_to_field (:118-133) is the sole dispatcher and convert_properties_to_fields (:88-91) its only caller, so wrapping once there is behavior-identical and deletes two of the three 6-line blocks (:151-156, :364-370, :432-437) plus the triplicated comment. it also gives #127 (which touches the same type-resolution lines for explicit-nullable) a single clean convergence point. Optional[Optional[T]] collapses, so double-wrapping during the refactor is benign.

)

return field_type, field

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

related consumer that the widening breaks, lower stakes than the comparison path: HTML report threshold extraction at data_extractors.py:199 (hasattr(field_type, '__fields__')) and :205 (__origin__ is list) both miss Optional[NestedModel] / Optional[List[NestedModel]], and the except at :212 swallows it, so nested field thresholds silently vanish from HTML reports for schema-built models. fine as a follow-up if filed.

# The two paths must agree.
assert plain.model_dump() == rich.model_dump()

def test_optional_primitive_field_none_via_from_json_rich_values(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heads up that this test and the array one at :239 pass even without the fix (verified by running them against base): top-level omitted fields never re-validate their defaults, so only the nested test at :181 is a genuine pre-fix failure. as written they're annotation coverage, not regressions, and the docstrings overstate that. two options: nest them (an array with an optional inner field inside object items did fail pre-fix and is the untested real path through configuration_helper.py:92-113), or relabel them so the next reader doesn't assume they pin the bug.

assert result["overall_score"] == 1.0


class TestOptionalFieldNoneFromJson:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one externally observable behavior change with no coverage: M(**{"b": None}) for an optional {"type": "string"} field previously raised and now succeeds (verified). that's more permissive than JSON Schema semantics (null is invalid for "type": "string" regardless of required) and diverges from utils/json_schema_validator.py's jsonschema-based validation. i'd say it's defensible, even desirable here, since LLM predictions emit explicit nulls and those used to crash evaluation instead of scoring as FN/TN, but it deserves an explicit-null test through both construction paths and a release-note line. same for the model_json_schema() shape change (these fields now emit anyOf: [T, null]).

("opt_str", lambda t: t is str),
(
"opt_obj",
lambda t: (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the __import__(...).StructuredModel in t.__mro__ lambda here is needlessly obscure; sibling tests in this file import StructuredModel directly and use issubclass.

@valter-silva-au

Copy link
Copy Markdown
Author

Superseded by #159. I renamed the branch to follow a type/slug convention and GitHub can't repoint an open PR's head, so I reopened it — same work, rebased onto current dev.

Thanks @adiadd, the review was spot on. All of it is addressed in #159:

  • Blockingis_structured_field_type now recognizes Optional[StructuredModel] (via the existing _is_optional_structured_model), so compare_recursive keeps the nested breakdown for optional objects inside array items; regression test added.
  • Widening condition is now not is_required or default is None (catches required-with-"default": null).
  • The three widening sites collapse into the sole dispatcher convert_property_to_field — the convergence point with fix: support JSON Schema list-form nullable types #127.
  • field_converter.py widened too, closing the to_stickler_configmodel_from_json round-trip hole.
  • The two regression tests that passed without the fix are nested so they genuinely pin the bug.
  • Explicit-null is documented as an intentional contract change (anyOf:[T,null]) with tests through both paths.
  • data_extractors.py unwraps Optional[...] so nested HTML-report thresholds survive.
  • Nits cleaned up.

Continuing the conversation on #159.

@valter-silva-au
valter-silva-au deleted the fix/optional-field-none-from-json branch June 27, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] from_json(process_rich_values=True) rejects None for optional fields that plain ModelClass(**data) accepts

2 participants