alt-stack-openapi-pydantic 1.6.3__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {alt_stack_openapi_pydantic-1.6.3/src/alt_stack_openapi_pydantic.egg-info → alt_stack_openapi_pydantic-1.7.0}/PKG-INFO +22 -1
  2. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/README.md +21 -0
  3. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/pyproject.toml +1 -1
  4. alt_stack_openapi_pydantic-1.7.0/src/alt_stack_openapi_pydantic/brands.py +181 -0
  5. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/emission.py +32 -9
  6. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/rendering.py +75 -1
  7. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/number.py +10 -0
  8. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0/src/alt_stack_openapi_pydantic.egg-info}/PKG-INFO +22 -1
  9. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic.egg-info/SOURCES.txt +2 -0
  10. alt_stack_openapi_pydantic-1.7.0/tests/test_brands.py +199 -0
  11. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_convert_schema.py +27 -0
  12. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_master_openapi.py +16 -3
  13. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_to_python.py +37 -0
  14. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/LICENSE +0 -0
  15. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/setup.cfg +0 -0
  16. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/__init__.py +0 -0
  17. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/cli.py +0 -0
  18. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/dependencies.py +0 -0
  19. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/lowering.py +0 -0
  20. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/py.typed +0 -0
  21. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/registry.py +0 -0
  22. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/routes.py +0 -0
  23. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/schema_dedup.py +0 -0
  24. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/to_python.py +0 -0
  25. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/type_render.py +0 -0
  26. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/__init__.py +0 -0
  27. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/array.py +0 -0
  28. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/boolean.py +0 -0
  29. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/intersection.py +0 -0
  30. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/object.py +0 -0
  31. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/string.py +0 -0
  32. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/types.py +0 -0
  33. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/union.py +0 -0
  34. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/utils.py +0 -0
  35. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic.egg-info/dependency_links.txt +0 -0
  36. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic.egg-info/entry_points.txt +0 -0
  37. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic.egg-info/requires.txt +0 -0
  38. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic.egg-info/top_level.txt +0 -0
  39. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_array.py +0 -0
  40. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_boolean.py +0 -0
  41. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_dependencies.py +0 -0
  42. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_generated_client.py +0 -0
  43. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_intersection.py +0 -0
  44. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_number.py +0 -0
  45. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_object.py +0 -0
  46. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_registry.py +0 -0
  47. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_route_typing.py +0 -0
  48. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_string.py +0 -0
  49. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_union.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alt-stack-openapi-pydantic
3
- Version: 1.6.3
3
+ Version: 1.7.0
4
4
  Summary: Generate Pydantic models, typed route maps, and an asyncio client from OpenAPI
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/anthonyaltieri/alt-stack
@@ -80,6 +80,27 @@ if isinstance(result, ApiSuccess):
80
80
 
81
81
  Generated modules that use non-object intersections inline a small `all_of` validator, so they depend only on `pydantic` and `alt-stack-http-client-httpx` at runtime.
82
82
 
83
+ ## Branded types
84
+
85
+ Annotate an identity schema in OpenAPI:
86
+
87
+ ```json
88
+ { "type": "string", "format": "uuid", "x-altstack-brand": "UserId" }
89
+ ```
90
+
91
+ The generated module declares `UserId = NewType("UserId", UUID)` once and uses it
92
+ in field and parameter annotations. Pydantic still parses UUID strings into UUID
93
+ values and applies each use's existing constraints. Optional and nullable fields,
94
+ references, array items, and union members retain the nominal type.
95
+
96
+ Names must be ASCII identifiers (`[A-Za-z_][A-Za-z0-9_]*`) and must not be Python
97
+ keywords, imported helper names, or unrelated component names. Conflicting base
98
+ types produce a generation error. If a component and its brand have the same name,
99
+ the Pydantic wrapper is named `UserIdModel`, leaving `UserId` for the `NewType`.
100
+ Python requires a class as a `NewType` base. Literal brands use their primitive
101
+ base; composite unions use `object` with the original Pydantic validator retained
102
+ in `Annotated` metadata.
103
+
83
104
  ## Development
84
105
 
85
106
  ```bash
@@ -56,6 +56,27 @@ if isinstance(result, ApiSuccess):
56
56
 
57
57
  Generated modules that use non-object intersections inline a small `all_of` validator, so they depend only on `pydantic` and `alt-stack-http-client-httpx` at runtime.
58
58
 
59
+ ## Branded types
60
+
61
+ Annotate an identity schema in OpenAPI:
62
+
63
+ ```json
64
+ { "type": "string", "format": "uuid", "x-altstack-brand": "UserId" }
65
+ ```
66
+
67
+ The generated module declares `UserId = NewType("UserId", UUID)` once and uses it
68
+ in field and parameter annotations. Pydantic still parses UUID strings into UUID
69
+ values and applies each use's existing constraints. Optional and nullable fields,
70
+ references, array items, and union members retain the nominal type.
71
+
72
+ Names must be ASCII identifiers (`[A-Za-z_][A-Za-z0-9_]*`) and must not be Python
73
+ keywords, imported helper names, or unrelated component names. Conflicting base
74
+ types produce a generation error. If a component and its brand have the same name,
75
+ the Pydantic wrapper is named `UserIdModel`, leaving `UserId` for the `NewType`.
76
+ Python requires a class as a `NewType` base. Literal brands use their primitive
77
+ base; composite unions use `object` with the original Pydantic validator retained
78
+ in `Annotated` metadata.
79
+
59
80
  ## Development
60
81
 
61
82
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alt-stack-openapi-pydantic"
3
- version = "1.6.3"
3
+ version = "1.7.0"
4
4
  description = "Generate Pydantic models, typed route maps, and an asyncio client from OpenAPI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import json
5
+ import keyword
6
+ import re
7
+ from dataclasses import replace
8
+ from typing import Callable
9
+
10
+ from .routes import RouteInfo, build_route_schema_name, generate_route_schema_names
11
+ from .types import AnySchema
12
+
13
+ _RESERVED = set(
14
+ "Any Annotated BaseModel BeforeValidator ConfigDict Field Final JsonValue Literal NewType "
15
+ "Optional Request Response RootModel TypeAdapter TypedDict UUID Union bool dict float int "
16
+ "list str date datetime cast overload _client _JsonValue _json_value "
17
+ "ApiClient HttpxApiClient".split()
18
+ )
19
+
20
+
21
+ def get_brand_name(schema: AnySchema) -> str | None:
22
+ if not isinstance(schema, dict) or "x-altstack-brand" not in schema:
23
+ return None
24
+ name = schema["x-altstack-brand"]
25
+ if (
26
+ not isinstance(name, str)
27
+ or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None
28
+ or keyword.iskeyword(name)
29
+ or name in _RESERVED
30
+ ):
31
+ raise ValueError(f"Invalid x-altstack-brand {name!r}: expected a non-reserved identifier")
32
+ return name
33
+
34
+
35
+ def annotation_base(expr: str) -> str:
36
+ node = ast.parse(expr, mode="eval").body
37
+ if (
38
+ isinstance(node, ast.Subscript)
39
+ and isinstance(node.value, ast.Name)
40
+ and node.value.id == "Annotated"
41
+ and isinstance(node.slice, ast.Tuple)
42
+ ):
43
+ return ast.unparse(node.slice.elts[0])
44
+ return expr
45
+
46
+
47
+ def branded_annotation(name: str, expr: str) -> str:
48
+ """Retain validation metadata at each use while sharing the nominal type."""
49
+ if newtype_base(expr) != annotation_base(expr):
50
+ return f"Annotated[{name}, BeforeValidator(TypeAdapter({expr}).validate_python)]"
51
+ if expr.startswith("Annotated["):
52
+ base = ast.parse(expr, mode="eval").body
53
+ assert isinstance(base, ast.Subscript) and isinstance(base.slice, ast.Tuple)
54
+ end = base.slice.elts[0].end_col_offset
55
+ return f"Annotated[{name}{expr[end:]}"
56
+ return name
57
+
58
+
59
+ def newtype_base(expr: str) -> str:
60
+ """NewType requires a class; preserve non-class validation at the use site."""
61
+ base = annotation_base(expr)
62
+ node = ast.parse(base, mode="eval").body
63
+ if isinstance(node, ast.Name) and node.id not in {"Any", "_JsonValue"}:
64
+ return base
65
+ if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name):
66
+ if node.value.id not in {"Union", "Optional", "Literal"}:
67
+ return base
68
+ if node.value.id == "Literal":
69
+ values = node.slice.elts if isinstance(node.slice, ast.Tuple) else [node.slice]
70
+ types = {type(ast.literal_eval(value)) for value in values}
71
+ if len(types) == 1 and next(iter(types)) in {str, int, bool, float}:
72
+ return next(iter(types)).__name__
73
+ return "object"
74
+
75
+
76
+ def prepare_brand_schemas(
77
+ schemas: dict[str, AnySchema],
78
+ routes: list[RouteInfo],
79
+ to_type: Callable[[AnySchema], str],
80
+ needs_model: Callable[[AnySchema], bool],
81
+ ) -> tuple[dict[str, AnySchema], list[RouteInfo]]:
82
+ """Lower nominal aliases into the existing component dependency/emission path."""
83
+ definitions: dict[str, AnySchema] = {}
84
+ bases: dict[str, str] = {}
85
+ route_names: set[str] = set()
86
+ for route in routes:
87
+ names = generate_route_schema_names(route)
88
+ route_names.update(name for name in vars(names).values() if isinstance(name, str))
89
+ for status in route.responses:
90
+ suffix = "Response" if status.startswith("2") else "ErrorResponse"
91
+ route_names.add(build_route_schema_name(route.path, route.method, f"{status}{suffix}"))
92
+
93
+ def resolve_base(schema: AnySchema) -> AnySchema:
94
+ base = {k: v for k, v in schema.items() if k not in {"x-altstack-brand", "nullable"}}
95
+ seen: set[str] = set()
96
+ while isinstance(base.get("$ref"), str):
97
+ target = base["$ref"].removeprefix("#/components/schemas/")
98
+ if target in seen or target not in schemas:
99
+ break
100
+ seen.add(target)
101
+ base = {
102
+ k: v
103
+ for k, v in schemas[target].items()
104
+ if k != "nullable" and not (k == "x-altstack-brand" and v == get_brand_name(schema))
105
+ }
106
+ return base
107
+
108
+ def transform(schema: AnySchema) -> AnySchema:
109
+ if not isinstance(schema, dict):
110
+ return schema
111
+ brand = get_brand_name(schema)
112
+ ref = schema.get("$ref")
113
+ target = ref.removeprefix("#/components/schemas/") if isinstance(ref, str) else None
114
+ if not brand and target in schemas and get_brand_name(schemas[target]):
115
+ return transform(
116
+ {**schemas[target], **{k: v for k, v in schema.items() if k != "$ref"}}
117
+ )
118
+ if brand:
119
+ if brand in route_names:
120
+ raise ValueError(f"x-altstack-brand {brand!r} conflicts with a route model")
121
+ base = resolve_base(schema)
122
+ base_type = annotation_base(to_type(base))
123
+ signature = f"{base_type}:{base.get('format', '')}"
124
+ if needs_model(base):
125
+ signature = json.dumps(base, sort_keys=True)
126
+ if brand in bases and bases[brand] != signature:
127
+ raise ValueError(f"Conflicting base types for x-altstack-brand {brand!r}")
128
+ if brand in schemas and get_brand_name(schemas[brand]) != brand:
129
+ raise ValueError(f"x-altstack-brand {brand!r} conflicts with component {brand}")
130
+ bases[brand] = signature
131
+ base = transform(base)
132
+ if needs_model(base):
133
+ base_name = f"{brand}Base"
134
+ if base_name in schemas:
135
+ raise ValueError(
136
+ f"x-altstack-brand {brand!r} conflicts with component {base_name}"
137
+ )
138
+ definitions[base_name] = base
139
+ base = {"$ref": f"#/components/schemas/{base_name}"}
140
+ definitions[brand] = {**base, "_altstack_newtype": True}
141
+ result = {
142
+ "$ref": f"#/components/schemas/{brand}",
143
+ "_altstack_brand_schema": base,
144
+ }
145
+ if schema.get("nullable") is True:
146
+ result["nullable"] = True
147
+ return result
148
+
149
+ result = dict(schema)
150
+ properties = result.get("properties")
151
+ if isinstance(properties, dict):
152
+ result["properties"] = {name: transform(child) for name, child in properties.items()}
153
+ for key in ("items", "additionalProperties"):
154
+ if isinstance(result.get(key), dict):
155
+ result[key] = transform(result[key])
156
+ for key in ("oneOf", "anyOf", "allOf"):
157
+ if isinstance(result.get(key), list):
158
+ result[key] = [transform(child) for child in result[key]]
159
+ return result
160
+
161
+ transformed: dict[str, AnySchema] = {}
162
+ for name, schema in schemas.items():
163
+ output_name = f"{name}Model" if get_brand_name(schema) == name else name
164
+ if output_name != name and output_name in schemas:
165
+ raise ValueError(f"x-altstack-brand {name!r} conflicts with component {output_name}")
166
+ transformed[output_name] = transform(schema)
167
+ transformed_routes = [
168
+ replace(
169
+ route,
170
+ parameters=[
171
+ replace(param, schema=transform(param.schema)) for param in route.parameters
172
+ ],
173
+ request_body=transform(route.request_body) if route.request_body is not None else None,
174
+ responses={status: transform(schema) for status, schema in route.responses.items()},
175
+ )
176
+ for route in routes
177
+ ]
178
+ conflicts = definitions.keys() & transformed.keys()
179
+ if conflicts:
180
+ raise ValueError(f"x-altstack-brand conflicts with generated model {min(conflicts)}")
181
+ return {**definitions, **transformed}, transformed_routes
@@ -3,12 +3,14 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass, field
4
4
  from typing import Any
5
5
 
6
+ from .brands import newtype_base, prepare_brand_schemas
6
7
  from .dependencies import topological_sort_schemas
7
8
  from .lowering import LoweringContext, NamedSchema, lower_named_schema
8
9
  from .rendering import (
9
10
  RenderContext,
10
11
  decode_component_ref,
11
12
  extra_field_annotation,
13
+ is_nullable_schema,
12
14
  is_object_model_schema,
13
15
  root_model_annotation,
14
16
  schema_to_type_expr,
@@ -39,6 +41,7 @@ _VALID_IDENTIFIER_CHARS = set("_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV
39
41
  class EmissionState:
40
42
  render_context: RenderContext = field(default_factory=RenderContext)
41
43
  rebuild_names: list[str] = field(default_factory=list)
44
+ uses_brands: bool = False
42
45
 
43
46
 
44
47
  def openapi_to_pydantic_code(
@@ -52,6 +55,11 @@ def openapi_to_pydantic_code(
52
55
  if not isinstance(schemas, dict):
53
56
  schemas = {}
54
57
 
58
+ routes = parse_openapi_paths(openapi) if include_routes else []
59
+ schemas, routes = prepare_brand_schemas(
60
+ schemas, routes, schema_to_type_expr, is_object_model_schema
61
+ )
62
+
55
63
  lowering_context = LoweringContext(set(schemas.keys()))
56
64
  lowered_components: list[NamedSchema] = []
57
65
  for name in topological_sort_schemas(schemas):
@@ -73,7 +81,6 @@ def openapi_to_pydantic_code(
73
81
  )
74
82
 
75
83
  if include_routes:
76
- routes = parse_openapi_paths(openapi)
77
84
  if routes:
78
85
  route_lines, schema_name_to_canonical = _emit_route_schemas(
79
86
  routes,
@@ -90,10 +97,10 @@ def openapi_to_pydantic_code(
90
97
  if route_lines:
91
98
  lines.append("")
92
99
  lines.extend(_generate_request_response_objects(routes, schema_name_to_canonical))
93
- return _finalize_module(lines)
100
+ return _finalize_module(lines, state)
94
101
 
95
102
  lines.extend(_emit_model_rebuilds(state.rebuild_names))
96
- return _finalize_module(lines)
103
+ return _finalize_module(lines, state)
97
104
 
98
105
 
99
106
  _ALL_OF_HELPER = [
@@ -110,12 +117,21 @@ _ALL_OF_HELPER = [
110
117
  ]
111
118
 
112
119
 
113
- def _finalize_module(lines: list[str]) -> str:
120
+ def _finalize_module(lines: list[str], state: EmissionState) -> str:
114
121
  """Inline the ``all_of`` intersection helper when the module uses it.
115
122
 
116
123
  Generated modules depend only on pydantic and ``alt_stack_http_client_httpx`` at runtime,
117
124
  not on the generator package.
118
125
  """
126
+ if state.uses_brands:
127
+ lines.insert(5, "from typing import NewType")
128
+ if state.render_context.uses_json:
129
+ lines.insert(5, "from pydantic import JsonValue as _JsonValue")
130
+ index = lines.index("def _reject_explicit_none(value: Any) -> Any:")
131
+ lines[index:index] = [
132
+ "_json_value = TypeAdapter(_JsonValue, config=ConfigDict(allow_inf_nan=False))",
133
+ "",
134
+ ]
119
135
  anchor = "_omit_not_null = BeforeValidator(_reject_explicit_none)"
120
136
  if any("all_of(" in line for line in lines) and anchor in lines:
121
137
  index = lines.index(anchor) + 1
@@ -154,6 +170,10 @@ def _build_module_preamble(custom_import_lines: list[str] | None) -> list[str]:
154
170
 
155
171
 
156
172
  def _emit_named_schema(name: str, schema: AnySchema, state: EmissionState) -> list[str]:
173
+ if isinstance(schema, dict) and schema.get("_altstack_newtype"):
174
+ state.uses_brands = True
175
+ base = newtype_base(schema_to_type_expr(schema, context=state.render_context))
176
+ return [f'{name} = NewType("{name}", {base})', ""]
157
177
  if is_object_model_schema(schema) and not _has_root_model_allof_base(schema, state):
158
178
  return _emit_object_model(name, schema, state)
159
179
  return _emit_root_model(name, schema, state)
@@ -177,10 +197,12 @@ def _emit_object_model(name: str, schema: AnySchema, state: EmissionState) -> li
177
197
  all_of = schema.get("allOf") if isinstance(schema, dict) else None
178
198
  if isinstance(all_of, list):
179
199
  base_classes, merged_schema = _merge_allof_object_schema(all_of)
180
- model_lines = _build_model_lines(name, merged_schema, base_classes=base_classes or None)
200
+ model_lines = _build_model_lines(
201
+ name, merged_schema, context=state.render_context, base_classes=base_classes or None
202
+ )
181
203
  model_lines.insert(1, f" __openapi_allof__ = {all_of!r}")
182
204
  else:
183
- model_lines = _build_model_lines(name, schema)
205
+ model_lines = _build_model_lines(name, schema, context=state.render_context)
184
206
 
185
207
  state.rebuild_names.append(name)
186
208
  return [*model_lines, ""]
@@ -241,6 +263,7 @@ def _build_model_lines(
241
263
  name: str,
242
264
  schema: AnySchema,
243
265
  *,
266
+ context: RenderContext,
244
267
  base_classes: list[str] | None = None,
245
268
  ) -> list[str]:
246
269
  properties = schema.get("properties") if isinstance(schema, dict) else None
@@ -261,12 +284,12 @@ def _build_model_lines(
261
284
  if alias:
262
285
  alias_used = True
263
286
 
264
- type_expr = schema_to_type_expr(prop_schema)
287
+ type_expr = schema_to_type_expr(prop_schema, context=context)
265
288
  is_required = prop_name in required
266
289
  if is_required:
267
290
  default_expr = f" = Field(alias={alias!r})" if alias else ""
268
291
  else:
269
- if not isinstance(prop_schema, dict) or prop_schema.get("nullable") is not True:
292
+ if not is_nullable_schema(prop_schema):
270
293
  type_expr = f"Annotated[Optional[{type_expr}], _omit_not_null]"
271
294
  default_expr = f" = Field(default=None, alias={alias!r})" if alias else " = None"
272
295
 
@@ -283,7 +306,7 @@ def _build_model_lines(
283
306
  config_args.append("extra='allow'")
284
307
  elif isinstance(additional, dict):
285
308
  config_args.append("extra='allow'")
286
- extra_type = schema_to_type_expr(additional)
309
+ extra_type = schema_to_type_expr(additional, context=context)
287
310
  extra_line = (
288
311
  f" __pydantic_extra__: {extra_field_annotation(extra_type)} = Field(init=False)"
289
312
  )
@@ -4,6 +4,7 @@ from dataclasses import dataclass, field
4
4
  from typing import Any
5
5
  from urllib.parse import unquote
6
6
 
7
+ from .brands import branded_annotation, get_brand_name
7
8
  from .registry import (
8
9
  get_schema_exported_variable_name_for_primitive_type,
9
10
  get_schema_exported_variable_name_for_string_format,
@@ -22,6 +23,7 @@ from .types.union import convert_openapi_union_to_pydantic
22
23
  @dataclass(slots=True)
23
24
  class RenderContext:
24
25
  root_model_names: set[str] = field(default_factory=set)
26
+ uses_json: bool = False
25
27
 
26
28
 
27
29
  def decode_component_ref(ref: str) -> str:
@@ -79,6 +81,24 @@ def schema_to_type_expr(
79
81
 
80
82
  render_context = context or RenderContext()
81
83
 
84
+ brand_schema = schema.get("_altstack_brand_schema")
85
+ if isinstance(brand_schema, dict):
86
+ name = decode_component_ref(schema["$ref"])
87
+ expr = schema_to_type_expr(brand_schema, context=render_context)
88
+ return wrap_nullable(branded_annotation(name, expr), schema)
89
+ brand = get_brand_name(schema)
90
+ if brand:
91
+ base = {k: v for k, v in schema.items() if k not in {"x-altstack-brand", "nullable"}}
92
+ expr = schema_to_type_expr(base, context=render_context)
93
+ return wrap_nullable(branded_annotation(brand, expr), schema)
94
+
95
+ # OpenAPI 3.1 spells "may be null" three ways; all of them render as Optional[...].
96
+ if schema.get("type") == "null":
97
+ return "None"
98
+ without_null = strip_null_member(schema)
99
+ if without_null is not None:
100
+ return f"Optional[{schema_to_type_expr(without_null, context=render_context)}]"
101
+
82
102
  if isinstance(schema.get("$ref"), str):
83
103
  ref = schema["$ref"]
84
104
  if not ref.startswith("#/components/schemas/"):
@@ -139,7 +159,10 @@ def schema_to_type_expr(
139
159
  if literal is not None:
140
160
  return wrap_nullable(literal, schema)
141
161
 
142
- return wrap_nullable("Any", schema)
162
+ json_value = "Annotated[_JsonValue, BeforeValidator(_json_value.validate_python)]"
163
+ if schema_type is None:
164
+ render_context.uses_json = True
165
+ return wrap_nullable(json_value if schema_type is None else "Any", schema)
143
166
 
144
167
 
145
168
  def wrap_nullable(expr: str, schema: dict[str, Any]) -> str:
@@ -148,6 +171,57 @@ def wrap_nullable(expr: str, schema: dict[str, Any]) -> str:
148
171
  return expr
149
172
 
150
173
 
174
+ def _is_null_schema(schema: Any) -> bool:
175
+ return isinstance(schema, dict) and schema.get("type") == "null" and len(schema) <= 2
176
+
177
+
178
+ def strip_null_member(schema: dict[str, Any]) -> dict[str, Any] | None:
179
+ """Return the schema without its null alternative, or None when it has none.
180
+
181
+ Handles ``type: ["string", "null"]`` and ``anyOf``/``oneOf`` compositions that include a
182
+ ``{"type": "null"}`` member. A composition left with one member collapses to that member so
183
+ the caller renders ``Optional[Member]`` rather than ``Optional[Union[Member]]``.
184
+ """
185
+ type_value = schema.get("type")
186
+ if isinstance(type_value, list) and "null" in type_value:
187
+ remaining = [item for item in type_value if item != "null"]
188
+ stripped = dict(schema)
189
+ if len(remaining) == 1:
190
+ stripped["type"] = remaining[0]
191
+ else:
192
+ stripped.pop("type")
193
+ stripped["anyOf"] = [{"type": item} for item in remaining]
194
+ return stripped
195
+ for keyword in ("anyOf", "oneOf"):
196
+ members = schema.get(keyword)
197
+ if isinstance(members, list) and any(_is_null_schema(member) for member in members):
198
+ remaining = [member for member in members if not _is_null_schema(member)]
199
+ stripped = {key: value for key, value in schema.items() if key != keyword}
200
+ if len(remaining) == 1 and isinstance(remaining[0], dict):
201
+ merged = dict(remaining[0])
202
+ for key in ("description", "title", "default"):
203
+ if key in stripped and key not in merged:
204
+ merged[key] = stripped[key]
205
+ return merged
206
+ stripped[keyword] = remaining
207
+ return stripped
208
+ return None
209
+
210
+
211
+ def is_nullable_schema(schema: Any) -> bool:
212
+ """True when the schema admits null: ``nullable: true``, ``type: "null"``, or a null member."""
213
+ if not isinstance(schema, dict):
214
+ return False
215
+ if schema.get("nullable") is True or schema.get("type") == "null":
216
+ return True
217
+ if not any(
218
+ key in schema
219
+ for key in ("type", "$ref", "properties", "enum", "const", "anyOf", "oneOf", "allOf")
220
+ ):
221
+ return True
222
+ return strip_null_member(schema) is not None
223
+
224
+
151
225
  def registered_output_alias(schema: AnySchema, context: RenderContext | None = None) -> str | None:
152
226
  if not isinstance(schema, dict):
153
227
  return None
@@ -21,18 +21,28 @@ def convert_openapi_number_to_pydantic(schema: dict[str, Any]) -> str:
21
21
 
22
22
  minimum = schema.get("minimum")
23
23
  maximum = schema.get("maximum")
24
+ exclusive_minimum = schema.get("exclusiveMinimum")
25
+ exclusive_maximum = schema.get("exclusiveMaximum")
24
26
 
25
27
  field_args: list[str] = ["strict=True"]
26
28
  if isinstance(minimum, (int, float)):
27
29
  field_args.append(f"ge={integer_bound(minimum, base)}")
28
30
  if isinstance(maximum, (int, float)):
29
31
  field_args.append(f"le={integer_bound(maximum, base)}")
32
+ if type(exclusive_minimum) in (int, float):
33
+ field_args.append(f"gt={integer_bound(exclusive_minimum, base)}")
34
+ if type(exclusive_maximum) in (int, float):
35
+ field_args.append(f"lt={integer_bound(exclusive_maximum, base)}")
30
36
 
31
37
  meta: dict[str, Any] = {}
32
38
  if isinstance(minimum, (int, float)):
33
39
  meta["minimum"] = minimum
34
40
  if isinstance(maximum, (int, float)):
35
41
  meta["maximum"] = maximum
42
+ if type(exclusive_minimum) in (int, float):
43
+ meta["exclusiveMinimum"] = exclusive_minimum
44
+ if type(exclusive_maximum) in (int, float):
45
+ meta["exclusiveMaximum"] = exclusive_maximum
36
46
  if meta:
37
47
  field_args.append(format_openapi_metadata(meta))
38
48
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alt-stack-openapi-pydantic
3
- Version: 1.6.3
3
+ Version: 1.7.0
4
4
  Summary: Generate Pydantic models, typed route maps, and an asyncio client from OpenAPI
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/anthonyaltieri/alt-stack
@@ -80,6 +80,27 @@ if isinstance(result, ApiSuccess):
80
80
 
81
81
  Generated modules that use non-object intersections inline a small `all_of` validator, so they depend only on `pydantic` and `alt-stack-http-client-httpx` at runtime.
82
82
 
83
+ ## Branded types
84
+
85
+ Annotate an identity schema in OpenAPI:
86
+
87
+ ```json
88
+ { "type": "string", "format": "uuid", "x-altstack-brand": "UserId" }
89
+ ```
90
+
91
+ The generated module declares `UserId = NewType("UserId", UUID)` once and uses it
92
+ in field and parameter annotations. Pydantic still parses UUID strings into UUID
93
+ values and applies each use's existing constraints. Optional and nullable fields,
94
+ references, array items, and union members retain the nominal type.
95
+
96
+ Names must be ASCII identifiers (`[A-Za-z_][A-Za-z0-9_]*`) and must not be Python
97
+ keywords, imported helper names, or unrelated component names. Conflicting base
98
+ types produce a generation error. If a component and its brand have the same name,
99
+ the Pydantic wrapper is named `UserIdModel`, leaving `UserId` for the `NewType`.
100
+ Python requires a class as a `NewType` base. Literal brands use their primitive
101
+ base; composite unions use `object` with the original Pydantic validator retained
102
+ in `Annotated` metadata.
103
+
83
104
  ## Development
84
105
 
85
106
  ```bash
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/alt_stack_openapi_pydantic/__init__.py
5
+ src/alt_stack_openapi_pydantic/brands.py
5
6
  src/alt_stack_openapi_pydantic/cli.py
6
7
  src/alt_stack_openapi_pydantic/dependencies.py
7
8
  src/alt_stack_openapi_pydantic/emission.py
@@ -31,6 +32,7 @@ src/alt_stack_openapi_pydantic/types/types.py
31
32
  src/alt_stack_openapi_pydantic/types/union.py
32
33
  tests/test_array.py
33
34
  tests/test_boolean.py
35
+ tests/test_brands.py
34
36
  tests/test_convert_schema.py
35
37
  tests/test_dependencies.py
36
38
  tests/test_generated_client.py
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, get_args
4
+ from uuid import UUID
5
+
6
+ import pytest
7
+ from pydantic import ValidationError
8
+
9
+ from alt_stack_openapi_pydantic import openapi_to_pydantic_code
10
+ from tests.test_route_typing import (
11
+ PYREFLY_PATTERN,
12
+ TY_PATTERN,
13
+ _assert_route_contract,
14
+ _check_with_pyrefly,
15
+ _check_with_ty,
16
+ _expected_error_lines,
17
+ )
18
+
19
+
20
+ def generate(schemas: dict[str, Any], paths: dict[str, Any] | None = None) -> tuple[str, Any]:
21
+ code = openapi_to_pydantic_code(
22
+ {"components": {"schemas": schemas}, "paths": paths or {}},
23
+ options={"include_routes": True},
24
+ )
25
+ namespace: dict[str, Any] = {}
26
+ exec(code, namespace)
27
+ return code, namespace
28
+
29
+
30
+ def test_brands_share_types_and_preserve_validation_in_every_schema_position() -> None:
31
+ identifier = {"type": "string", "format": "uuid", "x-altstack-brand": "UserId"}
32
+ code, module = generate(
33
+ {
34
+ "UserId": identifier,
35
+ "User": {
36
+ "type": "object",
37
+ "properties": {
38
+ "id": {"$ref": "#/components/schemas/UserId"},
39
+ "optional": {**identifier, "nullable": True},
40
+ "ids": {"type": "array", "items": identifier},
41
+ "choice": {"anyOf": [identifier, {"type": "null"}]},
42
+ },
43
+ "required": ["id", "ids", "choice"],
44
+ "additionalProperties": False,
45
+ },
46
+ },
47
+ {
48
+ "/users/{id}": {
49
+ "get": {
50
+ "parameters": [
51
+ {"name": "id", "in": "path", "required": True, "schema": identifier}
52
+ ],
53
+ "responses": {
54
+ "200": {
55
+ "content": {
56
+ "application/json": {
57
+ "schema": {"$ref": "#/components/schemas/User"}
58
+ }
59
+ }
60
+ }
61
+ },
62
+ }
63
+ }
64
+ },
65
+ )
66
+ assert code.count('UserId = NewType("UserId", UUID)') == 1
67
+ assert module["UserId"].__supertype__ is UUID
68
+ model = module["User"]
69
+ assert model.model_fields["id"].annotation is module["UserId"]
70
+ assert get_args(get_args(model.model_fields["ids"].annotation)[0])[0] is module["UserId"]
71
+ value = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
72
+ user = model.model_validate({"id": value, "ids": [value], "choice": None})
73
+ assert user.id == UUID(value)
74
+ assert user.optional is None
75
+ assert (
76
+ model.model_validate({"id": value, "ids": [], "choice": value, "optional": None}).optional
77
+ is None
78
+ )
79
+ with pytest.raises(ValidationError):
80
+ model.model_validate({"id": "invalid", "ids": [], "choice": None})
81
+ params = module["Request"]["/users/{id}"]["GET"]["params"]
82
+ assert params.model_fields["id"].annotation is module["UserId"]
83
+ assert params.model_validate({"id": value}).id == UUID(value)
84
+ assert module["Response"]["/users/{id}"]["GET"]["200"] is model
85
+
86
+
87
+ @pytest.mark.parametrize("brand", ["bad-name", "class", "", 123, None])
88
+ def test_invalid_brand_names_fail_generation(brand: Any) -> None:
89
+ with pytest.raises(ValueError, match="Invalid x-altstack-brand"):
90
+ generate({"Value": {"type": "string", "x-altstack-brand": brand}})
91
+
92
+
93
+ def test_conflicting_brand_bases_and_component_names_fail_generation() -> None:
94
+ with pytest.raises(ValueError, match="Conflicting base types.*UserId"):
95
+ generate(
96
+ {
97
+ "First": {"type": "string", "x-altstack-brand": "UserId"},
98
+ "Second": {"type": "integer", "x-altstack-brand": "UserId"},
99
+ }
100
+ )
101
+ with pytest.raises(ValueError, match="conflicts with component UserId"):
102
+ generate(
103
+ {
104
+ "UserId": {"type": "integer"},
105
+ "First": {"type": "string", "x-altstack-brand": "UserId"},
106
+ }
107
+ )
108
+ with pytest.raises(ValueError, match="conflicts with a route model"):
109
+ generate(
110
+ {},
111
+ {
112
+ "/users/{id}": {
113
+ "get": {
114
+ "parameters": [
115
+ {
116
+ "name": "id",
117
+ "in": "path",
118
+ "required": True,
119
+ "schema": {
120
+ "type": "string",
121
+ "x-altstack-brand": "GetUsersIdParams",
122
+ },
123
+ }
124
+ ],
125
+ "responses": {},
126
+ }
127
+ }
128
+ },
129
+ )
130
+
131
+
132
+ def test_empty_schema_rejects_non_json_values() -> None:
133
+ _, module = generate({"Json": {}, "Value": {"type": "object", "properties": {"value": {}}}})
134
+ model = module["Json"]
135
+ assert model.model_validate({"key": [None, True, 1]}).root == {"key": [None, True, 1]}
136
+ for value in (object(), float("nan"), float("inf")):
137
+ with pytest.raises(ValidationError):
138
+ model.model_validate({"key": value})
139
+ assert module["Value"].model_validate({"value": None}).value is None
140
+
141
+
142
+ def test_exclusive_numeric_bounds_are_enforced() -> None:
143
+ _, module = generate(
144
+ {
145
+ "Bounded": {
146
+ "type": "number",
147
+ "minimum": -1,
148
+ "maximum": 11,
149
+ "exclusiveMinimum": 0,
150
+ "exclusiveMaximum": 10,
151
+ }
152
+ }
153
+ )
154
+ model = module["Bounded"]
155
+ assert model.model_validate(5).root == 5
156
+ for value in (0, 10, -1, 11):
157
+ with pytest.raises(ValidationError):
158
+ model.model_validate(value)
159
+
160
+
161
+ @pytest.mark.parametrize(
162
+ ("check", "pattern"), [(_check_with_ty, TY_PATTERN), (_check_with_pyrefly, PYREFLY_PATTERN)]
163
+ )
164
+ def test_generated_brands_are_nominal_and_composite_bases_type_check(
165
+ tmp_path, check, pattern
166
+ ) -> None:
167
+ code, module = generate(
168
+ {
169
+ "Identifier": {"type": "string", "x-altstack-brand": "UserId"},
170
+ "Record": {
171
+ "type": "object",
172
+ "properties": {"id": {"type": "string", "x-altstack-brand": "UserId"}},
173
+ "required": ["id"],
174
+ },
175
+ "UnionValue": {
176
+ "anyOf": [{"type": "string"}, {"type": "integer"}],
177
+ "x-altstack-brand": "ValueId",
178
+ },
179
+ "Constant": {"type": "string", "const": "fixed", "x-altstack-brand": "TagId"},
180
+ }
181
+ )
182
+ assert module["UnionValue"].model_validate(1).root == 1
183
+ assert module["Constant"].model_validate("fixed").root == "fixed"
184
+ with pytest.raises(ValidationError):
185
+ module["Constant"].model_validate("wrong")
186
+ with pytest.raises(ValidationError):
187
+ module["UnionValue"].model_validate({})
188
+ consumer = "\n".join(
189
+ [
190
+ "from typing import assert_type",
191
+ "from generated_sdk import Record, UserId",
192
+ 'record = Record.model_validate({"id": "value"})',
193
+ "assert_type(record.id, UserId)",
194
+ 'plain: UserId = "value" # expect-error',
195
+ ]
196
+ )
197
+ (tmp_path / "generated_sdk.py").write_text(code)
198
+ (tmp_path / "consumer.py").write_text(consumer)
199
+ _assert_route_contract(check(tmp_path), pattern, _expected_error_lines(consumer))
@@ -169,3 +169,30 @@ def test_untyped_const_and_enum_render_as_literals() -> None:
169
169
  convert_schema_to_pydantic_string({"const": "only", "nullable": True})
170
170
  == "Optional[Literal['only']]"
171
171
  )
172
+
173
+
174
+ def test_null_members_render_as_optional() -> None:
175
+ assert (
176
+ convert_schema_to_pydantic_string({"anyOf": [{"type": "string"}, {"type": "null"}]})
177
+ == "Optional[Annotated[str, Field(strict=True)]]"
178
+ )
179
+ assert (
180
+ convert_schema_to_pydantic_string({"oneOf": [{"type": "integer"}, {"type": "null"}]})
181
+ == "Optional[Annotated[int, Field(strict=True)]]"
182
+ )
183
+ assert (
184
+ convert_schema_to_pydantic_string({"type": ["string", "null"]})
185
+ == "Optional[Annotated[str, Field(strict=True)]]"
186
+ )
187
+ assert (
188
+ convert_schema_to_pydantic_string(
189
+ {"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]}
190
+ )
191
+ == "Optional[Union[Annotated[str, Field(strict=True)], Annotated[int, Field(strict=True)]]]"
192
+ )
193
+ assert (
194
+ convert_schema_to_pydantic_string(
195
+ {"anyOf": [{"$ref": "#/components/schemas/User"}, {"type": "null"}]}
196
+ )
197
+ == "Optional[User]"
198
+ )
@@ -12,7 +12,15 @@ from typing import Annotated, Any, Literal, Union, cast, get_args, get_origin
12
12
  from uuid import UUID
13
13
 
14
14
  import pytest
15
- from pydantic import AnyUrl, BaseModel, BeforeValidator, EmailStr, RootModel, ValidationError
15
+ from pydantic import (
16
+ AnyUrl,
17
+ BaseModel,
18
+ BeforeValidator,
19
+ EmailStr,
20
+ JsonValue,
21
+ RootModel,
22
+ ValidationError,
23
+ )
16
24
  from pydantic.fields import FieldInfo
17
25
 
18
26
  from alt_stack_openapi_pydantic.registry import clear_pydantic_schema_registry
@@ -33,7 +41,7 @@ def _strip_examples(value: Any) -> Any:
33
41
  for key, child in value.items():
34
42
  if key == "x-altstack-examples":
35
43
  continue
36
- result[key] = _strip_examples(child)
44
+ result["oneOf" if key == "anyOf" else key] = _strip_examples(child)
37
45
  return result
38
46
  return value
39
47
 
@@ -137,7 +145,12 @@ def _to_openapi_schema(
137
145
  schema: dict[str, Any]
138
146
 
139
147
  origin = get_origin(base)
140
- if origin is Literal:
148
+ if hasattr(base, "__supertype__"):
149
+ schema = _to_openapi_schema(base.__supertype__, type_to_name, self_name=self_name)
150
+ schema["x-altstack-brand"] = base.__name__
151
+ elif base is JsonValue:
152
+ schema = {}
153
+ elif origin is Literal:
141
154
  values = list(get_args(base))
142
155
  schema = {"enum": values}
143
156
  if all(isinstance(v, str) for v in values):
@@ -292,6 +292,43 @@ def test_optional_non_nullable_fields_reject_explicit_none() -> None:
292
292
  module.Item.model_validate({"description": None})
293
293
 
294
294
 
295
+ def test_optional_null_member_fields_accept_explicit_none() -> None:
296
+ openapi = {
297
+ "components": {
298
+ "schemas": {
299
+ "Bundle": {
300
+ "type": "object",
301
+ "properties": {
302
+ "access_token": {"type": "string"},
303
+ "refresh_token": {"anyOf": [{"type": "string"}, {"type": "null"}]},
304
+ "expires_in": {"type": ["integer", "null"]},
305
+ "scope": {"type": "string"},
306
+ },
307
+ "required": ["access_token"],
308
+ "additionalProperties": False,
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ code = openapi_to_pydantic_code(openapi)
315
+ assert "refresh_token: Optional[Annotated[str, Field(strict=True)]] = None" in code
316
+ assert "expires_in: Optional[Annotated[int, Field(strict=True)]] = None" in code
317
+ assert (
318
+ "scope: Annotated[Optional[Annotated[str, Field(strict=True)]], _omit_not_null] = None"
319
+ in code
320
+ )
321
+
322
+ module = _load_module(code)
323
+ bundle = module.Bundle.model_validate(
324
+ {"access_token": "a", "refresh_token": None, "expires_in": None}
325
+ )
326
+ assert bundle.refresh_token is None and bundle.expires_in is None
327
+ assert module.Bundle.model_validate({"access_token": "a"}).refresh_token is None
328
+ with pytest.raises(ValidationError):
329
+ module.Bundle.model_validate({"access_token": "a", "scope": None})
330
+
331
+
295
332
  def test_allof_wrapper_around_root_model_imports() -> None:
296
333
  openapi = {
297
334
  "components": {