Skip to content

🐛 fix: handle PEP 604 union syntax in list type detection (#139)#146

Open
vawsgit wants to merge 3 commits into
devfrom
bugfix/139-empty-list
Open

🐛 fix: handle PEP 604 union syntax in list type detection (#139)#146
vawsgit wants to merge 3 commits into
devfrom
bugfix/139-empty-list

Conversation

@vawsgit

@vawsgit vawsgit commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Fields declared with PEP 604 syntax (list[X] | None) produce a types.UnionType at runtime, which has no __origin__ and is not recognized by get_origin(...) is Union. As a result, the type-detection methods failed to identify these as list fields, routing empty-list comparisons ([] vs []) through the primitive comparator and yielding match=False, score=0.0 instead of a true negative.

✨ Add types.UnionType handling to 5 type-detection methods:

  • _is_list_field (structured_model.py)
  • _is_list_of_structured_model_type (structured_model.py)
  • is_structured_field_type (configuration_helper.py)
  • _is_list_structured_model (configuration_helper.py)
  • _extract_structured_class_from_list (configuration_helper.py)

✅ Add test_pep604_union_type_handling.py with 24 tests covering type detection, empty/non-empty nested list comparison, primitive lists, and a parametrized validation suite guarding both PEP 604 and typing.Optional.

🧪 Full suite: 1100 passed, 2 skipped, 0 failures.

Issue #, if available:

#139

Description of changes:

🔍 Root Cause

Python 3.10+ PEP 604 syntax (X | Y) produces a types.UnionType at runtime, which behaves differently from typing.Union:

import types
from typing import get_origin

t = list[int] | None
type(t)                    # → types.UnionType
get_origin(t)              # → types.UnionType  (NOT typing.Union!)
hasattr(t, '__origin__')   # → False

The type-detection methods only checked get_origin(...) is Union and hasattr(field_type, "__origin__"), so PEP 604 unions silently fell through to return False. The comparison logic itself was correct — the bug was purely in type detection.

✨ Changes

Added types.UnionType handling to 5 type-detection methods:

File Method
structured_model.py _is_list_field
structured_model.py _is_list_of_structured_model_type
configuration_helper.py is_structured_field_type
configuration_helper.py _is_list_structured_model
configuration_helper.py _extract_structured_class_from_list

Each method now unwraps types.UnionType args (skipping NoneType) using the same logic already applied to typing.Union.

✅ Tests

New file test_pep604_union_type_handling.py24 tests:

  • 🔬 Unit tests — verify all 5 type-detection methods recognize PEP 604 syntax (structured lists, primitive lists, root-level)
  • 🔗 Integration tests — empty list → score 1.0, mismatched list → penalty, non-empty structured dispatch
  • 🛡️ Regression guardtyping.Optional[List[X]] behavior unchanged
  • 🎯 Parametrized validation (TestIssue139Validation) — runs every assertion against both PEP 604 and typing.Optional styles in one command:
uv run pytest tests/structured_object_evaluator/test_pep604_union_type_handling.py::TestIssue139Validation -v

🧪 Test Results

1100 passed, 2 skipped, 0 failures

No regressions — all existing tests continue to pass because they use typing.Union / typing.Optional syntax.

📌 Notes

  • No public API or ComparableField interface changes
  • No new dependencies (types is stdlib; project requires Python >=3.12 where types.UnionType is guaranteed)
  • Orthogonal to PR Feat/universal aggregate metrics #115 (aggregate metrics) — this fix is a prerequisite that ensures type detection works for either PR's paths

Closes #139

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


🔄 Update (addressing review #146)

Per @adiadd's review, the five duplicated union branches were collapsed to a single get_origin(annotation) in (Union, types.UnionType) check across all five type-detection methods. This fixes the blocking issue by construction: is_structured_field_type no longer has a divergent direct-Model | None arm, so Model | None keeps parity with Optional[Model] (both route through LevenshteinComparator), and behavior no longer reverts on Python 3.14.

⚠️ Behavior change on upgrade

Fields spelled list[Model] | None = ComparableField(threshold=...) (PEP 604) now raise ValueError at class-definition time — the same guard that has always applied to Optional[List[Model]]. Hungarian matching uses each element class's match_threshold, so a per-field threshold on a list-of-models field is rejected. Previously the PEP 604 spelling silently bypassed this validation.

📌 Supported annotation syntax

List/optional detection now handles list[X], List[X], Optional[List[X]], and PEP 604 list[X] | None uniformly.

🔭 Tracked follow-ups (out of scope)

_is_optional_structured_model, _extract_structured_class_from_optional, _unwrap_optional, data_extractors.py:205, and the dead field_helper.py:48 duplicate remain PEP-604-blind — to be filed as separate issues.

Fields declared with PEP 604 syntax (`list[X] | None`) produce a
`types.UnionType` at runtime, which has no `__origin__` and is not
recognized by `get_origin(...) is Union`. As a result, the type-detection
methods failed to identify these as list fields, routing empty-list
comparisons (`[]` vs `[]`) through the primitive comparator and yielding
`match=False, score=0.0` instead of a true negative.

✨ Add `types.UnionType` handling to 5 type-detection methods:
  - `_is_list_field` (structured_model.py)
  - `_is_list_of_structured_model_type` (structured_model.py)
  - `is_structured_field_type` (configuration_helper.py)
  - `_is_list_structured_model` (configuration_helper.py)
  - `_extract_structured_class_from_list` (configuration_helper.py)

✅ Add `test_pep604_union_type_handling.py` with 24 tests covering type
detection, empty/non-empty nested list comparison, primitive lists, and a
parametrized validation suite guarding both PEP 604 and typing.Optional.

🧪 Full suite: 1100 passed, 2 skipped, 0 failures.
@vawsgit
vawsgit requested a review from adiadd June 5, 2026 14:20
@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown

Security Scan Results

ASH Security Scan Report

  • Report generated: 2026-07-16T22:11:52+00:00
  • Time since scan: 2 minutes

Scan Metadata

  • Project: ASH
  • Scan executed: 2026-07-16T22:09:22+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 4191 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 0 0 0 0 0 0 PASSED 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 19 0 0 0 0 19 FAILED MEDIUM (global)
syft 0 0 0 0 0 0 0 MISSING MEDIUM (global)

Top 7 Hotspots

Files with the highest number of security findings:

Finding Count File Location
7 .github/workflows/workflow.yml
3 .github/workflows/security.yaml
2 .github/workflows/docs.yml
2 .github/workflows/lint.yaml
2 .github/workflows/run_pytest.yaml
2 .github/workflows/security-pr-comment.yaml
1 pyproject.toml

Detailed Findings

Show 19 actionable findings

Finding 1: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/docs.yml:19

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 2: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/docs.yml:22

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 3: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/lint.yaml:16

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 4: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/lint.yaml:18

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 5: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/run_pytest.yaml:16

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 6: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/run_pytest.yaml:18

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 7: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security-pr-comment.yaml:20

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/download-artifact@v4

Finding 8: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security-pr-comment.yaml:27

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/github-script@v7

Finding 9: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security.yaml:18

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 10: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security.yaml:20

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 11: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security.yaml:42

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/upload-artifact@v4

Finding 12: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:18

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 13: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:22

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 14: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:28

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/upload-artifact@v4

Finding 15: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:45

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/download-artifact@v4

Finding 16: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:50

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: pypa/gh-action-pypi-publish@release/v1

Finding 17: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:64

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/download-artifact@v4

Finding 18: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:69

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: pypa/gh-action-pypi-publish@release/v1

Finding 19: package_managers.uv.uv-missing-dependency-cooldown.uv-missing-dependency-cooldown

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: package_managers.uv.uv-missing-dependency-cooldown.uv-missing-dependency-cooldown
  • Location: pyproject.toml:116-117

Description:
This pyproject.toml configures uv but does not set a dependency cooldown. Newly published packages can be malicious or unstable. Add exclude-newer = "7 days" under [tool.uv] to wait 7 days before resolving newly published package versions. Added in: 0.9.17 Reference: https://docs.astral.sh/uv/concepts/resolution/#dependency-cooldowns

Code Snippet:

[tool.uv]
default-groups = ["dev"]

Report generated by Automated Security Helper (ASH) at 2026-07-16T22:11:53+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.

appreciate this vincil! just a couple things:

blocking:

  • the PEP 604 branch in is_structured_field_type does more than parity: its direct-model arm silently changes the default comparator and threshold for Model | None fields, and the behavior reverts on python 3.14. details and two fix options on configuration_helper.py:180-181.

non-blocking:

  • the five duplicated union blocks could collapse to a two-token change (get_origin(annotation) in (Union, types.UnionType)) in 4 of the 5 methods, which would also make the blocking issue impossible by construction. see configuration_helper.py:168.

  • __init_subclass__ validation now raises at import time for PEP 604 spellings it used to silently accept, correct but worth a PR-description note and a test (structured_model.py:281).

  • test gaps: None vs [] / None vs None for the PEP 604 spelling, and a from_json round-trip so the two branches only unit tests reach get integration coverage (comments inline).

  • or arg is list in _is_list_field is another non-parity spot and untested (structured_model.py:734), and _is_optional_structured_model / _extract_structured_class_from_optional in the same file are still PEP-604-blind, fine out of scope but worth a follow-up issue (configuration_helper.py:417,447).

nits, none blocking: style drift between the five new blocks (some hoist none_type = type(None), some inline it), the pep604 arm of TestIssue139Validation mostly duplicates the earlier tests, a direct per-field match=True assertion on items would pin the issue's exact symptom, and a one-line docs note on supported annotation syntax would help. also worth filing follow-ups beyond this PR: _unwrap_optional (structured_model.py:1384) is PEP-604-blind so schema export silently emits {"type": "string"} for these fields, data_extractors.py:205 misses both spellings in HTML reports, and field_helper.py:48 has a dead duplicate is_structured_field_type with no union handling at all.

appreciate it!

Comment thread src/stickler/structured_object_evaluator/models/configuration_helper.py Outdated
Comment thread src/stickler/structured_object_evaluator/models/configuration_helper.py Outdated
Comment thread src/stickler/structured_object_evaluator/models/structured_model.py Outdated
Comment thread src/stickler/structured_object_evaluator/models/configuration_helper.py Outdated
Comment thread src/stickler/structured_object_evaluator/models/structured_model.py Outdated
Comment thread src/stickler/structured_object_evaluator/models/configuration_helper.py Outdated
…ator parity

Address PR #146 review (adiadd). Collapse the five near-identical
typing.Union / types.UnionType branches into a single
`get_origin(annotation) in (Union, types.UnionType)` check across all five
type-detection methods. get_origin/get_args normalize both union spellings
identically on 3.12, so the branches were pure duplication (~88 lines).

Blocking fix: is_structured_field_type had a divergent PEP 604 arm that
returned True for a direct `Model | None` field, silently switching its
default comparator from LevenshteinComparator@0.5 to
StructuredModelComparator@0.9 (flipping match decisions for scores in
[0.5, 0.9)). The unified branch only recognizes list-wrapped models, so
`Model | None` now has parity with `Optional[Model]`. The collapse also
makes the divergence structurally impossible and is correct on Python 3.14,
where the two union types unify.

Also drop the `or arg is list` asymmetry in _is_list_field so
`list | None` matches the bare-`list` / `Optional[list]` behavior.

Tests (+9): direct Model|None vs Optional[Model] parity, None-vs-[] and
None-vs-None contract, per-field true-negative assertion for [] vs [],
from_json round-trip coverage for the list-detection methods, and
__init_subclass__ ValueError parity for the PEP 604 spelling.
@vawsgit

vawsgit commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @adiadd — you were right on every point, and taking the refactor you suggested made most of them fall out for free. Pushed b9f9473.

Blocking — resolved via the refactor

Collapsed all five typing.Union / types.UnionType branches to a single get_origin(annotation) in (Union, types.UnionType) check (verified: get_origin/get_args normalize both spellings identically on 3.12, and the union types unify on 3.14). This removes ~88 duplicated lines and makes the divergent direct-Model | None arm structurally impossible — is_structured_field_type now only recognizes list-wrapped models, so Model | None has parity with Optional[Model] (both → LevenshteinComparator, no silent match-decision flips, no 3.14 revert). Added a test asserting the two spellings resolve identically.

Non-blocking — addressed

  • or arg is list asymmetry (_is_list_field): dropped, so list | None now matches bare list / Optional[list] behavior.
  • __init_subclass__ import-time ValueError: added tests asserting it fires for both the PEP 604 and Optional[List[...]] spellings, plus a note in the PR description.
  • Test gaps: added None vs [], None vs None, a per-field true-negative assertion pinning the exact [BUG]: Empty list fields inside a structured-list item scored as mismatch instead of true negative #139 symptom, and a from_json round-trip that exercises _is_list_structured_model / _extract_structured_class_from_list (previously unit-only).

Full suite: 1109 passed, 2 skipped, 0 failures.

Follow-ups — filed as separate issues

All four out-of-scope spots you flagged are now tracked:

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]: Empty list fields inside a structured-list item scored as mismatch instead of true negative

2 participants