|
67 | 67 | injects a ``field_validator(mode="after")`` into each generated class that |
68 | 68 | declares a matching list field. |
69 | 69 |
|
| 70 | +* Simple conditional ``required`` constraints are dropped: pagination requires |
| 71 | + ``cursor`` when ``has_next_page`` is true, but the generated response model |
| 72 | + always treats it as optional. The script accepts only an unambiguous single |
| 73 | + required discriminator using ``const``/``enum`` and a ``then.required`` list, |
| 74 | + then injects a ``model_validator(mode="after")``. More complex conditions are |
| 75 | + skipped rather than approximated. |
| 76 | +
|
70 | 77 | Runs from generate_models.sh between generation and formatting; idempotent. |
71 | 78 | """ |
72 | 79 |
|
@@ -117,6 +124,24 @@ def {marker}(self): |
117 | 124 |
|
118 | 125 | _UNIQUE_MARKER = "_enforce_unique_items" |
119 | 126 |
|
| 127 | +_CONDITIONAL_REQUIRED_MARKER = "_enforce_conditional_required" |
| 128 | + |
| 129 | +_CONDITIONAL_REQUIRED_TEMPLATE = ''' |
| 130 | + @model_validator(mode="after") |
| 131 | + def {marker}(self): |
| 132 | + """JSON Schema if/then: enforce conditionally required fields.""" |
| 133 | + rules = {rules!r} |
| 134 | + for rule in rules: |
| 135 | + if getattr(self, rule["discriminator"], None) not in rule["values"]: |
| 136 | + continue |
| 137 | + for field in rule["required"]: |
| 138 | + if field not in self.model_fields_set: |
| 139 | + raise ValueError( |
| 140 | + f"Field {{field!r}} is required by a schema condition" |
| 141 | + ) |
| 142 | + return self |
| 143 | +''' |
| 144 | + |
120 | 145 | _UNIQUE_VALIDATOR_TEMPLATE = ''' |
121 | 146 | @field_validator("{field}", mode="after") |
122 | 147 | def {marker}_{field}(cls, value): # noqa: N805 |
@@ -528,6 +553,137 @@ def inject_array_contains(source, alias_name, groups): |
528 | 553 | return _ensure_pydantic_import(out, "AfterValidator") |
529 | 554 |
|
530 | 555 |
|
| 556 | +def find_conditional_required(schema_dir): |
| 557 | + """Map generated class names to simple if/then required rules.""" |
| 558 | + rules_by_class = {} |
| 559 | + |
| 560 | + def describe(node, properties): |
| 561 | + if not isinstance(node, dict) or set(node) != {"if", "then"}: |
| 562 | + return None |
| 563 | + condition = node["if"] |
| 564 | + consequence = node["then"] |
| 565 | + if ( |
| 566 | + not isinstance(condition, dict) |
| 567 | + or set(condition) != {"properties", "required"} |
| 568 | + or not isinstance(consequence, dict) |
| 569 | + or set(consequence) != {"required"} |
| 570 | + ): |
| 571 | + return None |
| 572 | + condition_props = condition["properties"] |
| 573 | + condition_required = condition["required"] |
| 574 | + consequence_required = consequence["required"] |
| 575 | + if ( |
| 576 | + not isinstance(condition_props, dict) |
| 577 | + or len(condition_props) != 1 |
| 578 | + or not isinstance(condition_required, list) |
| 579 | + or len(condition_required) != 1 |
| 580 | + or not isinstance(consequence_required, list) |
| 581 | + or not consequence_required |
| 582 | + ): |
| 583 | + return None |
| 584 | + discriminator, predicate = next(iter(condition_props.items())) |
| 585 | + if condition_required != [discriminator] or not isinstance( |
| 586 | + predicate, dict |
| 587 | + ): |
| 588 | + return None |
| 589 | + if set(predicate) == {"const"}: |
| 590 | + values = [predicate["const"]] |
| 591 | + elif ( |
| 592 | + set(predicate) == {"enum"} |
| 593 | + and isinstance(predicate["enum"], list) |
| 594 | + and predicate["enum"] |
| 595 | + ): |
| 596 | + values = predicate["enum"] |
| 597 | + else: |
| 598 | + return None |
| 599 | + if ( |
| 600 | + discriminator not in properties |
| 601 | + or any( |
| 602 | + not isinstance(name, str) or name not in properties |
| 603 | + for name in consequence_required |
| 604 | + ) |
| 605 | + or any( |
| 606 | + not isinstance(value, (str, int, float, bool)) |
| 607 | + for value in values |
| 608 | + ) |
| 609 | + ): |
| 610 | + return None |
| 611 | + return { |
| 612 | + "discriminator": discriminator, |
| 613 | + "values": values, |
| 614 | + "required": sorted(consequence_required), |
| 615 | + } |
| 616 | + |
| 617 | + def walk(node, current_class_name, path_str): |
| 618 | + if not isinstance(node, dict): |
| 619 | + return |
| 620 | + if isinstance(node.get("title"), str): |
| 621 | + current_class_name = _alias_name(node["title"]) |
| 622 | + properties = node.get("properties") |
| 623 | + then = node.get("then") |
| 624 | + is_required_rule = isinstance(then, dict) and "required" in then |
| 625 | + if isinstance(properties, dict) and is_required_rule: |
| 626 | + if "else" in node: |
| 627 | + rule = None |
| 628 | + else: |
| 629 | + rule = describe( |
| 630 | + {key: node[key] for key in ("if", "then") if key in node}, |
| 631 | + properties, |
| 632 | + ) |
| 633 | + if rule is None: |
| 634 | + sys.stderr.write( |
| 635 | + f" ! {path_str}: unsupported conditional required rule; skipped\n" |
| 636 | + ) |
| 637 | + elif current_class_name is not None: |
| 638 | + rules_by_class.setdefault(current_class_name, []).append(rule) |
| 639 | + if isinstance(properties, dict): |
| 640 | + for name, prop in properties.items(): |
| 641 | + walk(prop, _to_camel_case(name), path_str) |
| 642 | + defs = node.get("$defs") |
| 643 | + if isinstance(defs, dict): |
| 644 | + for def_name, def_node in defs.items(): |
| 645 | + walk(def_node, _to_camel_case(def_name), path_str) |
| 646 | + for key in ("allOf", "anyOf", "oneOf"): |
| 647 | + if isinstance(node.get(key), list): |
| 648 | + for item in node[key]: |
| 649 | + walk(item, current_class_name, path_str) |
| 650 | + |
| 651 | + for path in sorted(Path(schema_dir).rglob("*.json")): |
| 652 | + try: |
| 653 | + schema = json.loads(path.read_text(encoding="utf-8")) |
| 654 | + except (OSError, json.JSONDecodeError): |
| 655 | + continue |
| 656 | + if not isinstance(schema, dict): |
| 657 | + continue |
| 658 | + root_title = schema.get("title") |
| 659 | + initial_class = ( |
| 660 | + _alias_name(root_title) if root_title else _to_camel_case(path.stem) |
| 661 | + ) |
| 662 | + walk(schema, initial_class, str(path)) |
| 663 | + return rules_by_class |
| 664 | + |
| 665 | + |
| 666 | +def inject_conditional_required(source, class_name, rules): |
| 667 | + """Inject simple conditional-required checks into one generated class.""" |
| 668 | + class_re = re.compile(rf"^class {re.escape(class_name)}\(", re.M) |
| 669 | + match = class_re.search(source) |
| 670 | + if not match: |
| 671 | + return source |
| 672 | + tail = re.compile(r"^\S", re.M) |
| 673 | + end_match = tail.search(source, match.end()) |
| 674 | + end = end_match.start() if end_match else len(source) |
| 675 | + if f"def {_CONDITIONAL_REQUIRED_MARKER}(" in source[match.start() : end]: |
| 676 | + return source |
| 677 | + method = _CONDITIONAL_REQUIRED_TEMPLATE.format( |
| 678 | + marker=_CONDITIONAL_REQUIRED_MARKER, |
| 679 | + rules=rules, |
| 680 | + ) |
| 681 | + body = source[:end].rstrip("\n") |
| 682 | + rest = source[end:] |
| 683 | + out = body + "\n" + method + ("\n" + rest if rest else "") |
| 684 | + return _ensure_pydantic_import(out, "model_validator") |
| 685 | + |
| 686 | + |
531 | 687 | def find_unique_items_fields(schema_dir): |
532 | 688 | """Map generated class names to fields carrying ``uniqueItems``. |
533 | 689 |
|
@@ -787,6 +943,39 @@ def _patch_array_contains(): |
787 | 943 | return patched, 0 |
788 | 944 |
|
789 | 945 |
|
| 946 | +def _patch_conditional_required(): |
| 947 | + """Inject conditional-required validators; return counts and status.""" |
| 948 | + rules_by_class = find_conditional_required(SCHEMA_DIR) |
| 949 | + if not rules_by_class: |
| 950 | + sys.stdout.write( |
| 951 | + "postprocess: no simple conditional required rules found\n" |
| 952 | + ) |
| 953 | + return 0, 0 |
| 954 | + patched = 0 |
| 955 | + for class_name, rules in sorted(rules_by_class.items()): |
| 956 | + hits = [] |
| 957 | + for path in sorted(OUTPUT_DIR.rglob("*.py")): |
| 958 | + source = path.read_text(encoding="utf-8") |
| 959 | + if not re.search( |
| 960 | + rf"^class {re.escape(class_name)}\(", source, re.M |
| 961 | + ): |
| 962 | + continue |
| 963 | + updated = inject_conditional_required(source, class_name, rules) |
| 964 | + if updated != source: |
| 965 | + path.write_text(updated, encoding="utf-8") |
| 966 | + patched += 1 |
| 967 | + hits.append(path) |
| 968 | + label = ( |
| 969 | + ", ".join(str(path) for path in hits) or "NO GENERATED CLASS FOUND" |
| 970 | + ) |
| 971 | + sys.stdout.write( |
| 972 | + f" conditional required on '{class_name}' -> {label}\n" |
| 973 | + ) |
| 974 | + if not hits: |
| 975 | + return patched, 1 |
| 976 | + return patched, 0 |
| 977 | + |
| 978 | + |
790 | 979 | def _patch_unique_items(): |
791 | 980 | """Inject uniqueItems validators; return (patched_count, exit_code).""" |
792 | 981 | unique_fields_by_class = find_unique_items_fields(SCHEMA_DIR) |
@@ -820,10 +1009,11 @@ def main(): |
820 | 1009 | patched_mp, rc_mp = _patch_min_properties() |
821 | 1010 | patched_pn, rc_pn = _patch_property_names() |
822 | 1011 | patched_ac, rc_ac = _patch_array_contains() |
| 1012 | + patched_cr, rc_cr = _patch_conditional_required() |
823 | 1013 | patched_ui, rc_ui = _patch_unique_items() |
824 | | - total = patched_mp + patched_pn + patched_ac + patched_ui |
| 1014 | + total = patched_mp + patched_pn + patched_ac + patched_cr + patched_ui |
825 | 1015 | sys.stdout.write(f"postprocess: {total} module(s) patched\n") |
826 | | - return rc_mp or rc_pn or rc_ac or rc_ui |
| 1016 | + return rc_mp or rc_pn or rc_ac or rc_cr or rc_ui |
827 | 1017 |
|
828 | 1018 |
|
829 | 1019 | if __name__ == "__main__": |
|
0 commit comments