alt-stack-openapi-pydantic 1.6.4__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.4/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.4 → alt_stack_openapi_pydantic-1.7.0}/README.md +21 -0
  3. {alt_stack_openapi_pydantic-1.6.4 → 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.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/emission.py +30 -8
  6. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/rendering.py +22 -1
  7. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/number.py +10 -0
  8. {alt_stack_openapi_pydantic-1.6.4 → 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.4 → 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.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_master_openapi.py +16 -3
  12. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/LICENSE +0 -0
  13. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/setup.cfg +0 -0
  14. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/__init__.py +0 -0
  15. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/cli.py +0 -0
  16. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/dependencies.py +0 -0
  17. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/lowering.py +0 -0
  18. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/py.typed +0 -0
  19. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/registry.py +0 -0
  20. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/routes.py +0 -0
  21. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/schema_dedup.py +0 -0
  22. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/to_python.py +0 -0
  23. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/type_render.py +0 -0
  24. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/__init__.py +0 -0
  25. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/array.py +0 -0
  26. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/boolean.py +0 -0
  27. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/intersection.py +0 -0
  28. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/object.py +0 -0
  29. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/string.py +0 -0
  30. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/types.py +0 -0
  31. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/union.py +0 -0
  32. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/utils.py +0 -0
  33. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic.egg-info/dependency_links.txt +0 -0
  34. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic.egg-info/entry_points.txt +0 -0
  35. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic.egg-info/requires.txt +0 -0
  36. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic.egg-info/top_level.txt +0 -0
  37. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_array.py +0 -0
  38. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_boolean.py +0 -0
  39. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_convert_schema.py +0 -0
  40. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_dependencies.py +0 -0
  41. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_generated_client.py +0 -0
  42. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_intersection.py +0 -0
  43. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_number.py +0 -0
  44. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_object.py +0 -0
  45. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_registry.py +0 -0
  46. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_route_typing.py +0 -0
  47. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_string.py +0 -0
  48. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.0}/tests/test_to_python.py +0 -0
  49. {alt_stack_openapi_pydantic-1.6.4 → 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.4
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.4"
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,6 +3,7 @@ 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 (
@@ -40,6 +41,7 @@ _VALID_IDENTIFIER_CHARS = set("_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV
40
41
  class EmissionState:
41
42
  render_context: RenderContext = field(default_factory=RenderContext)
42
43
  rebuild_names: list[str] = field(default_factory=list)
44
+ uses_brands: bool = False
43
45
 
44
46
 
45
47
  def openapi_to_pydantic_code(
@@ -53,6 +55,11 @@ def openapi_to_pydantic_code(
53
55
  if not isinstance(schemas, dict):
54
56
  schemas = {}
55
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
+
56
63
  lowering_context = LoweringContext(set(schemas.keys()))
57
64
  lowered_components: list[NamedSchema] = []
58
65
  for name in topological_sort_schemas(schemas):
@@ -74,7 +81,6 @@ def openapi_to_pydantic_code(
74
81
  )
75
82
 
76
83
  if include_routes:
77
- routes = parse_openapi_paths(openapi)
78
84
  if routes:
79
85
  route_lines, schema_name_to_canonical = _emit_route_schemas(
80
86
  routes,
@@ -91,10 +97,10 @@ def openapi_to_pydantic_code(
91
97
  if route_lines:
92
98
  lines.append("")
93
99
  lines.extend(_generate_request_response_objects(routes, schema_name_to_canonical))
94
- return _finalize_module(lines)
100
+ return _finalize_module(lines, state)
95
101
 
96
102
  lines.extend(_emit_model_rebuilds(state.rebuild_names))
97
- return _finalize_module(lines)
103
+ return _finalize_module(lines, state)
98
104
 
99
105
 
100
106
  _ALL_OF_HELPER = [
@@ -111,12 +117,21 @@ _ALL_OF_HELPER = [
111
117
  ]
112
118
 
113
119
 
114
- def _finalize_module(lines: list[str]) -> str:
120
+ def _finalize_module(lines: list[str], state: EmissionState) -> str:
115
121
  """Inline the ``all_of`` intersection helper when the module uses it.
116
122
 
117
123
  Generated modules depend only on pydantic and ``alt_stack_http_client_httpx`` at runtime,
118
124
  not on the generator package.
119
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
+ ]
120
135
  anchor = "_omit_not_null = BeforeValidator(_reject_explicit_none)"
121
136
  if any("all_of(" in line for line in lines) and anchor in lines:
122
137
  index = lines.index(anchor) + 1
@@ -155,6 +170,10 @@ def _build_module_preamble(custom_import_lines: list[str] | None) -> list[str]:
155
170
 
156
171
 
157
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})', ""]
158
177
  if is_object_model_schema(schema) and not _has_root_model_allof_base(schema, state):
159
178
  return _emit_object_model(name, schema, state)
160
179
  return _emit_root_model(name, schema, state)
@@ -178,10 +197,12 @@ def _emit_object_model(name: str, schema: AnySchema, state: EmissionState) -> li
178
197
  all_of = schema.get("allOf") if isinstance(schema, dict) else None
179
198
  if isinstance(all_of, list):
180
199
  base_classes, merged_schema = _merge_allof_object_schema(all_of)
181
- 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
+ )
182
203
  model_lines.insert(1, f" __openapi_allof__ = {all_of!r}")
183
204
  else:
184
- model_lines = _build_model_lines(name, schema)
205
+ model_lines = _build_model_lines(name, schema, context=state.render_context)
185
206
 
186
207
  state.rebuild_names.append(name)
187
208
  return [*model_lines, ""]
@@ -242,6 +263,7 @@ def _build_model_lines(
242
263
  name: str,
243
264
  schema: AnySchema,
244
265
  *,
266
+ context: RenderContext,
245
267
  base_classes: list[str] | None = None,
246
268
  ) -> list[str]:
247
269
  properties = schema.get("properties") if isinstance(schema, dict) else None
@@ -262,7 +284,7 @@ def _build_model_lines(
262
284
  if alias:
263
285
  alias_used = True
264
286
 
265
- type_expr = schema_to_type_expr(prop_schema)
287
+ type_expr = schema_to_type_expr(prop_schema, context=context)
266
288
  is_required = prop_name in required
267
289
  if is_required:
268
290
  default_expr = f" = Field(alias={alias!r})" if alias else ""
@@ -284,7 +306,7 @@ def _build_model_lines(
284
306
  config_args.append("extra='allow'")
285
307
  elif isinstance(additional, dict):
286
308
  config_args.append("extra='allow'")
287
- extra_type = schema_to_type_expr(additional)
309
+ extra_type = schema_to_type_expr(additional, context=context)
288
310
  extra_line = (
289
311
  f" __pydantic_extra__: {extra_field_annotation(extra_type)} = Field(init=False)"
290
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,17 @@ 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
+
82
95
  # OpenAPI 3.1 spells "may be null" three ways; all of them render as Optional[...].
83
96
  if schema.get("type") == "null":
84
97
  return "None"
@@ -146,7 +159,10 @@ def schema_to_type_expr(
146
159
  if literal is not None:
147
160
  return wrap_nullable(literal, schema)
148
161
 
149
- 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)
150
166
 
151
167
 
152
168
  def wrap_nullable(expr: str, schema: dict[str, Any]) -> str:
@@ -198,6 +214,11 @@ def is_nullable_schema(schema: Any) -> bool:
198
214
  return False
199
215
  if schema.get("nullable") is True or schema.get("type") == "null":
200
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
201
222
  return strip_null_member(schema) is not None
202
223
 
203
224
 
@@ -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.4
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))
@@ -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):