Skip to content

Commit 64daebe

Browse files
authored
fix: enforce custom totals display text (#75)
1 parent 9a4ff69 commit 64daebe

5 files changed

Lines changed: 227 additions & 8 deletions

File tree

postprocess_models.py

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -488,17 +488,66 @@ def find_array_contains_constraints(schema_dir):
488488
groups = _extract_contains_groups(schema, path)
489489
if not groups:
490490
continue
491+
item_condition = _extract_item_required_condition(schema)
491492
title = schema.get("title")
492493
if not title:
493494
sys.stderr.write(
494495
f" ! {path}: array contains constraint but no title; "
495496
"cannot map to a model\n"
496497
)
497498
continue
498-
found[path.stem] = {"title": title, "groups": groups}
499+
found[path.stem] = {
500+
"title": title,
501+
"groups": groups,
502+
"item_condition": item_condition,
503+
}
499504
return found
500505

501506

507+
def _extract_item_required_condition(schema):
508+
"""Read a simple array-item ``not.enum`` + ``then.required`` rule."""
509+
items = schema.get("items")
510+
if not isinstance(items, dict):
511+
return None
512+
nodes = [items]
513+
nodes.extend(
514+
node for node in items.get("allOf", []) if isinstance(node, dict)
515+
)
516+
for node in nodes:
517+
condition = node.get("if")
518+
consequence = node.get("then")
519+
if not isinstance(condition, dict) or not isinstance(consequence, dict):
520+
continue
521+
props = condition.get("properties")
522+
required = consequence.get("required")
523+
if not isinstance(props, dict) or len(props) != 1:
524+
continue
525+
field, predicate = next(iter(props.items()))
526+
if (
527+
not isinstance(predicate, dict)
528+
or set(node) != {"if", "then"}
529+
or set(condition) != {"properties", "required"}
530+
or set(consequence) != {"required"}
531+
):
532+
continue
533+
excluded = predicate.get("not")
534+
values = excluded.get("enum") if isinstance(excluded, dict) else None
535+
if (
536+
condition.get("required") == [field]
537+
and set(predicate) == {"not"}
538+
and isinstance(excluded, dict)
539+
and set(excluded) == {"enum"}
540+
and isinstance(values, list)
541+
and values
542+
and all(isinstance(value, str) for value in values)
543+
and isinstance(required, list)
544+
and required
545+
and all(isinstance(name, str) for name in required)
546+
):
547+
return {"field": field, "excluded": values, "required": required}
548+
return None
549+
550+
502551
def _alias_name(title):
503552
"""Derive the generated alias name from a schema title (drop spaces)."""
504553
return "".join(title.split())
@@ -530,7 +579,7 @@ def _predicate_expr(pairs):
530579
return " and ".join(parts)
531580

532581

533-
def _build_contains_function(func_name, groups):
582+
def _build_contains_function(func_name, groups, item_condition=None):
534583
"""Render the module-level ``AfterValidator`` counting function."""
535584
lines = [
536585
f"def {func_name}(value):",
@@ -565,11 +614,28 @@ def _build_contains_function(func_name, groups):
565614
f' "matching {desc} (schema maxContains={maximum})"',
566615
" )",
567616
]
617+
if item_condition:
618+
field = item_condition["field"]
619+
lines += [
620+
f" _excluded = {item_condition['excluded']!r}",
621+
" for _item in value:",
622+
f" _actual = (_item.get({field!r}) if isinstance(_item, dict) ",
623+
f" else getattr(_item, {field!r}, None))",
624+
" if _actual in _excluded:",
625+
" continue",
626+
]
627+
for required in item_condition["required"]:
628+
lines += [
629+
f" if isinstance(_item, dict) and {required!r} not in _item:",
630+
f' raise ValueError("Field {required!r} is required for custom {field}")',
631+
f" if not isinstance(_item, dict) and {required!r} not in _item.model_fields_set:",
632+
f' raise ValueError("Field {required!r} is required for custom {field}")',
633+
]
568634
lines.append(" return value")
569635
return "\n".join(lines) + "\n"
570636

571637

572-
def inject_array_contains(source, alias_name, groups):
638+
def inject_array_contains(source, alias_name, groups, item_condition=None):
573639
"""Thread an ``AfterValidator`` into ``alias_name``'s alias metadata.
574640
575641
Array roots are emitted as ``NAME = TypeAliasType("NAME", Annotated[...])``,
@@ -602,7 +668,7 @@ def inject_array_contains(source, alias_name, groups):
602668
if close is None:
603669
return source
604670
out = source[:close] + f", AfterValidator({func_name})" + source[close:]
605-
func_src = _build_contains_function(func_name, groups)
671+
func_src = _build_contains_function(func_name, groups, item_condition)
606672
insert_at = assign_re.search(out).start()
607673
out = out[:insert_at] + func_src + "\n\n" + out[insert_at:]
608674
return _ensure_pydantic_import(out, "AfterValidator")
@@ -1082,11 +1148,20 @@ def _array_contains_targets():
10821148
None,
10831149
)
10841150
if origin is not None:
1085-
targets[info["title"]] = raw[origin]["groups"]
1151+
targets[info["title"]] = {
1152+
"groups": raw[origin]["groups"],
1153+
"item_condition": raw[origin]["item_condition"],
1154+
}
10861155
# Defensive: cover each raw base title even if the preprocessed base lost
10871156
# its contains entirely.
10881157
for info in raw.values():
1089-
targets.setdefault(info["title"], info["groups"])
1158+
targets.setdefault(
1159+
info["title"],
1160+
{
1161+
"groups": info["groups"],
1162+
"item_condition": info["item_condition"],
1163+
},
1164+
)
10901165
return targets
10911166

10921167

@@ -1133,7 +1208,8 @@ def _patch_array_contains():
11331208
sys.stdout.write("postprocess: no array contains constraints found\n")
11341209
return 0, 0
11351210
patched = 0
1136-
for title, groups in sorted(targets.items()):
1211+
for title, constraint in sorted(targets.items()):
1212+
groups = constraint["groups"]
11371213
alias = _alias_name(title)
11381214
hits = []
11391215
for path in sorted(OUTPUT_DIR.rglob("*.py")):
@@ -1142,7 +1218,9 @@ def _patch_array_contains():
11421218
rf"^{re.escape(alias)} = TypeAliasType\(", source, re.M
11431219
):
11441220
continue
1145-
updated = inject_array_contains(source, alias, groups)
1221+
updated = inject_array_contains(
1222+
source, alias, groups, constraint["item_condition"]
1223+
)
11461224
if updated != source:
11471225
path.write_text(updated, encoding="utf-8")
11481226
patched += 1

src/ucp_sdk/models/schemas/shopping/types/totals.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,30 @@ def _enforce_contains_totals(value):
140140
"Array must contain at most 1 entry "
141141
"matching type=='total' (schema maxContains=1)"
142142
)
143+
_excluded = [
144+
"subtotal",
145+
"items_discount",
146+
"discount",
147+
"fulfillment",
148+
"tax",
149+
"fee",
150+
"total",
151+
]
152+
for _item in value:
153+
_actual = (
154+
_item.get("type")
155+
if isinstance(_item, dict)
156+
else getattr(_item, "type", None)
157+
)
158+
if _actual in _excluded:
159+
continue
160+
if isinstance(_item, dict) and "display_text" not in _item:
161+
raise ValueError("Field 'display_text' is required for custom type")
162+
if (
163+
not isinstance(_item, dict)
164+
and "display_text" not in _item.model_fields_set
165+
):
166+
raise ValueError("Field 'display_text' is required for custom type")
143167
return value
144168

145169

src/ucp_sdk/models/schemas/shopping/types/totals_create_request.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@ def _enforce_contains_totals_create_request(value):
6868
"Array must contain at most 1 entry "
6969
"matching type=='total' (schema maxContains=1)"
7070
)
71+
_excluded = [
72+
"subtotal",
73+
"items_discount",
74+
"discount",
75+
"fulfillment",
76+
"tax",
77+
"fee",
78+
"total",
79+
]
80+
for _item in value:
81+
_actual = (
82+
_item.get("type")
83+
if isinstance(_item, dict)
84+
else getattr(_item, "type", None)
85+
)
86+
if _actual in _excluded:
87+
continue
88+
if isinstance(_item, dict) and "display_text" not in _item:
89+
raise ValueError("Field 'display_text' is required for custom type")
90+
if (
91+
not isinstance(_item, dict)
92+
and "display_text" not in _item.model_fields_set
93+
):
94+
raise ValueError("Field 'display_text' is required for custom type")
7195
return value
7296

7397

src/ucp_sdk/models/schemas/shopping/types/totals_update_request.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@ def _enforce_contains_totals_update_request(value):
6868
"Array must contain at most 1 entry "
6969
"matching type=='total' (schema maxContains=1)"
7070
)
71+
_excluded = [
72+
"subtotal",
73+
"items_discount",
74+
"discount",
75+
"fulfillment",
76+
"tax",
77+
"fee",
78+
"total",
79+
]
80+
for _item in value:
81+
_actual = (
82+
_item.get("type")
83+
if isinstance(_item, dict)
84+
else getattr(_item, "type", None)
85+
)
86+
if _actual in _excluded:
87+
continue
88+
if isinstance(_item, dict) and "display_text" not in _item:
89+
raise ValueError("Field 'display_text' is required for custom type")
90+
if (
91+
not isinstance(_item, dict)
92+
and "display_text" not in _item.model_fields_set
93+
):
94+
raise ValueError("Field 'display_text' is required for custom type")
7195
return value
7296

7397

tests/test_codegen_pipeline.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,27 @@ def test_create_request_variant_enforces_both_bounds(self):
12931293
def test_update_request_variant_enforces_both_bounds(self):
12941294
self._assert_matrix(TotalsUpdateRequest)
12951295

1296+
def test_custom_type_requires_display_text(self):
1297+
base = [self.SUBTOTAL, self.TOTAL]
1298+
for alias in (Totals, TotalsCreateRequest, TotalsUpdateRequest):
1299+
adapter = TypeAdapter(alias)
1300+
with self.subTest(model=alias.__name__):
1301+
with self.assertRaisesRegex(ValidationError, "display_text"):
1302+
adapter.validate_python(
1303+
base + [{"type": "surcharge", "amount": 5}]
1304+
)
1305+
adapter.validate_python(base + [{"type": "tax", "amount": 5}])
1306+
adapter.validate_python(
1307+
base
1308+
+ [
1309+
{
1310+
"type": "surcharge",
1311+
"amount": 5,
1312+
"display_text": "Surcharge",
1313+
}
1314+
]
1315+
)
1316+
12961317
def test_missing_total_names_the_total_rule(self):
12971318
# A subtotal-only array must fail specifically on the total rule.
12981319
with self.assertRaisesRegex(ValidationError, "total"):
@@ -1328,11 +1349,30 @@ class ArrayContainsInjectorTest(unittest.TestCase):
13281349
{"pairs": [("type", "total")], "min": 1, "max": 1},
13291350
]
13301351

1352+
ITEM_CONDITION = {
1353+
"field": "type",
1354+
"excluded": ["subtotal", "total"],
1355+
"required": ["display_text"],
1356+
}
1357+
13311358
def test_scan_reads_both_contains_from_allof_branches(self):
13321359
# The pristine totals.json shape: two allOf contains branches.
13331360
schema = {
13341361
"title": "Totals",
13351362
"type": "array",
1363+
"items": {
1364+
"allOf": [
1365+
{
1366+
"if": {
1367+
"properties": {
1368+
"type": {"not": {"enum": ["subtotal", "total"]}}
1369+
},
1370+
"required": ["type"],
1371+
},
1372+
"then": {"required": ["display_text"]},
1373+
}
1374+
]
1375+
},
13361376
"allOf": [
13371377
{
13381378
"contains": {"properties": {"type": {"const": "subtotal"}}},
@@ -1357,6 +1397,10 @@ def test_scan_reads_both_contains_from_allof_branches(self):
13571397
[g["pairs"] for g in found["totals"]["groups"]],
13581398
[[("type", "subtotal")], [("type", "total")]],
13591399
)
1400+
self.assertEqual(
1401+
found["totals"]["item_condition"],
1402+
self.ITEM_CONDITION,
1403+
)
13601404

13611405
def test_scan_reads_root_level_single_contains(self):
13621406
# A root-level (non-allOf) contains still yields one group.
@@ -1435,6 +1479,31 @@ def test_injected_validator_enforces_both_bounds(self):
14351479
adapter.validate_python(bad)
14361480
adapter.validate_python([sub, tot])
14371481

1482+
@unittest.skipUnless(HAVE_SDK, "executing the module needs pydantic")
1483+
def test_injected_validator_requires_custom_display_text(self):
1484+
out = postprocess_models.inject_array_contains(
1485+
self.MODULE, "Totals", self.GROUPS, self.ITEM_CONDITION
1486+
)
1487+
namespace: dict = {}
1488+
exec(compile(out, "<injected>", "exec"), namespace) # noqa: S102
1489+
adapter = TypeAdapter(namespace["Totals"])
1490+
base = [
1491+
{"type": "subtotal", "amount": 1},
1492+
{"type": "total", "amount": 1},
1493+
]
1494+
with self.assertRaisesRegex(ValidationError, "display_text"):
1495+
adapter.validate_python(base + [{"type": "surcharge", "amount": 1}])
1496+
adapter.validate_python(
1497+
base
1498+
+ [
1499+
{
1500+
"type": "surcharge",
1501+
"amount": 1,
1502+
"display_text": "Surcharge",
1503+
}
1504+
]
1505+
)
1506+
14381507

14391508
class UniqueItemsInjectorTest(unittest.TestCase):
14401509
"""The uniqueItems post-generation injector's own behavior."""

0 commit comments

Comments
 (0)