alt-stack-openapi-pydantic 1.6.4__tar.gz → 1.7.1__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 (51) hide show
  1. {alt_stack_openapi_pydantic-1.6.4/src/alt_stack_openapi_pydantic.egg-info → alt_stack_openapi_pydantic-1.7.1}/PKG-INFO +39 -4
  2. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/README.md +38 -3
  3. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/pyproject.toml +1 -1
  4. alt_stack_openapi_pydantic-1.7.1/src/alt_stack_openapi_pydantic/brands.py +181 -0
  5. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/emission.py +60 -36
  6. alt_stack_openapi_pydantic-1.7.1/src/alt_stack_openapi_pydantic/identifiers.py +55 -0
  7. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/rendering.py +28 -2
  8. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/number.py +10 -0
  9. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1/src/alt_stack_openapi_pydantic.egg-info}/PKG-INFO +39 -4
  10. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/SOURCES.txt +4 -0
  11. alt_stack_openapi_pydantic-1.7.1/tests/test_brands.py +199 -0
  12. alt_stack_openapi_pydantic-1.7.1/tests/test_identifiers.py +138 -0
  13. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_master_openapi.py +29 -11
  14. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_route_typing.py +3 -1
  15. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/LICENSE +0 -0
  16. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/setup.cfg +0 -0
  17. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/__init__.py +0 -0
  18. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/cli.py +0 -0
  19. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/dependencies.py +0 -0
  20. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/lowering.py +0 -0
  21. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/py.typed +0 -0
  22. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/registry.py +0 -0
  23. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/routes.py +0 -0
  24. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/schema_dedup.py +0 -0
  25. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/to_python.py +0 -0
  26. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/type_render.py +0 -0
  27. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/__init__.py +0 -0
  28. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/array.py +0 -0
  29. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/boolean.py +0 -0
  30. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/intersection.py +0 -0
  31. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/object.py +0 -0
  32. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/string.py +0 -0
  33. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/types.py +0 -0
  34. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/union.py +0 -0
  35. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/utils.py +0 -0
  36. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/dependency_links.txt +0 -0
  37. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/entry_points.txt +0 -0
  38. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/requires.txt +0 -0
  39. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/top_level.txt +0 -0
  40. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_array.py +0 -0
  41. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_boolean.py +0 -0
  42. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_convert_schema.py +0 -0
  43. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_dependencies.py +0 -0
  44. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_generated_client.py +0 -0
  45. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_intersection.py +0 -0
  46. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_number.py +0 -0
  47. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_object.py +0 -0
  48. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_registry.py +0 -0
  49. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_string.py +0 -0
  50. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_to_python.py +0 -0
  51. {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/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.1
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,12 +80,47 @@ 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
+
104
+ ## Identifiers and wire names
105
+
106
+ Component names become Python identifiers: `Rule-Output` becomes `Rule_Output`,
107
+ leading digits gain an underscore, and Python keywords gain a trailing underscore.
108
+ Names that normalize to the same identifier cause a generation error with both
109
+ original names. References and route maps use the generated identifiers.
110
+
111
+ Underscore properties become public fields with aliases: `_id` becomes `id_`.
112
+ Invalid property characters and Python keywords are also handled, with numeric
113
+ suffixes where needed to avoid field collisions. Validate with the original wire
114
+ names and use `model_dump(by_alias=True)` or `model_dump_json(by_alias=True)` to
115
+ serialize them. The HTTP client serializes aliases automatically.
116
+
83
117
  ## Development
84
118
 
85
119
  ```bash
86
- uv run --project packages/python-pydantic-openapi --extra dev pytest packages/python-pydantic-openapi/tests
87
- uv run --project packages/python-pydantic-openapi --extra dev ruff check packages/python-pydantic-openapi
88
- uv run --project packages/python-pydantic-openapi --extra dev ty check packages/python-pydantic-openapi
120
+ uv sync --project packages/python-pydantic-openapi --extra dev
121
+ packages/python-pydantic-openapi/.venv/bin/pytest packages/python-pydantic-openapi/tests -q
122
+ packages/python-pydantic-openapi/.venv/bin/ruff check packages/python-pydantic-openapi
123
+ packages/python-pydantic-openapi/.venv/bin/ty check packages/python-pydantic-openapi
89
124
  ```
90
125
 
91
126
  ## Documentation
@@ -56,12 +56,47 @@ 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
+
80
+ ## Identifiers and wire names
81
+
82
+ Component names become Python identifiers: `Rule-Output` becomes `Rule_Output`,
83
+ leading digits gain an underscore, and Python keywords gain a trailing underscore.
84
+ Names that normalize to the same identifier cause a generation error with both
85
+ original names. References and route maps use the generated identifiers.
86
+
87
+ Underscore properties become public fields with aliases: `_id` becomes `id_`.
88
+ Invalid property characters and Python keywords are also handled, with numeric
89
+ suffixes where needed to avoid field collisions. Validate with the original wire
90
+ names and use `model_dump(by_alias=True)` or `model_dump_json(by_alias=True)` to
91
+ serialize them. The HTTP client serializes aliases automatically.
92
+
59
93
  ## Development
60
94
 
61
95
  ```bash
62
- uv run --project packages/python-pydantic-openapi --extra dev pytest packages/python-pydantic-openapi/tests
63
- uv run --project packages/python-pydantic-openapi --extra dev ruff check packages/python-pydantic-openapi
64
- uv run --project packages/python-pydantic-openapi --extra dev ty check packages/python-pydantic-openapi
96
+ uv sync --project packages/python-pydantic-openapi --extra dev
97
+ packages/python-pydantic-openapi/.venv/bin/pytest packages/python-pydantic-openapi/tests -q
98
+ packages/python-pydantic-openapi/.venv/bin/ruff check packages/python-pydantic-openapi
99
+ packages/python-pydantic-openapi/.venv/bin/ty check packages/python-pydantic-openapi
65
100
  ```
66
101
 
67
102
  ## Documentation
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alt-stack-openapi-pydantic"
3
- version = "1.6.4"
3
+ version = "1.7.1"
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,7 +3,9 @@ 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
8
+ from .identifiers import component_identifiers, field_identifiers
7
9
  from .lowering import LoweringContext, NamedSchema, lower_named_schema
8
10
  from .rendering import (
9
11
  RenderContext,
@@ -33,13 +35,12 @@ from .schema_dedup import (
33
35
  )
34
36
  from .types import AnySchema
35
37
 
36
- _VALID_IDENTIFIER_CHARS = set("_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
37
-
38
38
 
39
39
  @dataclass(slots=True)
40
40
  class EmissionState:
41
41
  render_context: RenderContext = field(default_factory=RenderContext)
42
42
  rebuild_names: list[str] = field(default_factory=list)
43
+ uses_brands: bool = False
43
44
 
44
45
 
45
46
  def openapi_to_pydantic_code(
@@ -53,16 +54,24 @@ def openapi_to_pydantic_code(
53
54
  if not isinstance(schemas, dict):
54
55
  schemas = {}
55
56
 
56
- lowering_context = LoweringContext(set(schemas.keys()))
57
+ routes = parse_openapi_paths(openapi) if include_routes else []
58
+ schemas, routes = prepare_brand_schemas(
59
+ schemas, routes, schema_to_type_expr, is_object_model_schema
60
+ )
61
+
62
+ component_names = component_identifiers(schemas)
63
+ lowering_context = LoweringContext(set(component_names.values()))
57
64
  lowered_components: list[NamedSchema] = []
58
65
  for name in topological_sort_schemas(schemas):
59
66
  schema = schemas.get(name)
60
67
  if schema is None:
61
68
  continue
62
- lowered_components.extend(lower_named_schema(lowering_context, name, schema))
69
+ lowered_components.extend(
70
+ lower_named_schema(lowering_context, component_names[name], schema)
71
+ )
63
72
 
64
73
  lines = _build_module_preamble(custom_import_lines)
65
- state = EmissionState()
74
+ state = EmissionState(render_context=RenderContext(component_names=component_names))
66
75
  registry = create_schema_registry()
67
76
 
68
77
  for named_schema in lowered_components:
@@ -74,7 +83,6 @@ def openapi_to_pydantic_code(
74
83
  )
75
84
 
76
85
  if include_routes:
77
- routes = parse_openapi_paths(openapi)
78
86
  if routes:
79
87
  route_lines, schema_name_to_canonical = _emit_route_schemas(
80
88
  routes,
@@ -90,11 +98,15 @@ def openapi_to_pydantic_code(
90
98
  lines.extend(_emit_model_rebuilds(state.rebuild_names))
91
99
  if route_lines:
92
100
  lines.append("")
93
- lines.extend(_generate_request_response_objects(routes, schema_name_to_canonical))
94
- return _finalize_module(lines)
101
+ lines.extend(
102
+ _generate_request_response_objects(
103
+ routes, schema_name_to_canonical, state.render_context
104
+ )
105
+ )
106
+ return _finalize_module(lines, state)
95
107
 
96
108
  lines.extend(_emit_model_rebuilds(state.rebuild_names))
97
- return _finalize_module(lines)
109
+ return _finalize_module(lines, state)
98
110
 
99
111
 
100
112
  _ALL_OF_HELPER = [
@@ -111,12 +123,21 @@ _ALL_OF_HELPER = [
111
123
  ]
112
124
 
113
125
 
114
- def _finalize_module(lines: list[str]) -> str:
126
+ def _finalize_module(lines: list[str], state: EmissionState) -> str:
115
127
  """Inline the ``all_of`` intersection helper when the module uses it.
116
128
 
117
129
  Generated modules depend only on pydantic and ``alt_stack_http_client_httpx`` at runtime,
118
130
  not on the generator package.
119
131
  """
132
+ if state.uses_brands:
133
+ lines.insert(5, "from typing import NewType")
134
+ if state.render_context.uses_json:
135
+ lines.insert(5, "from pydantic import JsonValue as _JsonValue")
136
+ index = lines.index("def _reject_explicit_none(value: Any) -> Any:")
137
+ lines[index:index] = [
138
+ "_json_value = TypeAdapter(_JsonValue, config=ConfigDict(allow_inf_nan=False))",
139
+ "",
140
+ ]
120
141
  anchor = "_omit_not_null = BeforeValidator(_reject_explicit_none)"
121
142
  if any("all_of(" in line for line in lines) and anchor in lines:
122
143
  index = lines.index(anchor) + 1
@@ -155,6 +176,10 @@ def _build_module_preamble(custom_import_lines: list[str] | None) -> list[str]:
155
176
 
156
177
 
157
178
  def _emit_named_schema(name: str, schema: AnySchema, state: EmissionState) -> list[str]:
179
+ if isinstance(schema, dict) and schema.get("_altstack_newtype"):
180
+ state.uses_brands = True
181
+ base = newtype_base(schema_to_type_expr(schema, context=state.render_context))
182
+ return [f'{name} = NewType("{name}", {base})', ""]
158
183
  if is_object_model_schema(schema) and not _has_root_model_allof_base(schema, state):
159
184
  return _emit_object_model(name, schema, state)
160
185
  return _emit_root_model(name, schema, state)
@@ -169,7 +194,7 @@ def _has_root_model_allof_base(schema: AnySchema, state: EmissionState) -> bool:
169
194
  return any(
170
195
  isinstance(part, dict)
171
196
  and isinstance(part.get("$ref"), str)
172
- and decode_component_ref(part["$ref"]) in state.render_context.root_model_names
197
+ and state.render_context.resolve_ref(part["$ref"]) in state.render_context.root_model_names
173
198
  for part in all_of
174
199
  )
175
200
 
@@ -177,11 +202,13 @@ def _has_root_model_allof_base(schema: AnySchema, state: EmissionState) -> bool:
177
202
  def _emit_object_model(name: str, schema: AnySchema, state: EmissionState) -> list[str]:
178
203
  all_of = schema.get("allOf") if isinstance(schema, dict) else None
179
204
  if isinstance(all_of, list):
180
- 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)
205
+ base_classes, merged_schema = _merge_allof_object_schema(all_of, state.render_context)
206
+ model_lines = _build_model_lines(
207
+ name, merged_schema, context=state.render_context, base_classes=base_classes or None
208
+ )
182
209
  model_lines.insert(1, f" __openapi_allof__ = {all_of!r}")
183
210
  else:
184
- model_lines = _build_model_lines(name, schema)
211
+ model_lines = _build_model_lines(name, schema, context=state.render_context)
185
212
 
186
213
  state.rebuild_names.append(name)
187
214
  return [*model_lines, ""]
@@ -198,7 +225,9 @@ def _emit_root_model(name: str, schema: AnySchema, state: EmissionState) -> list
198
225
  ]
199
226
 
200
227
 
201
- def _merge_allof_object_schema(all_of: list[Any]) -> tuple[list[str], dict[str, Any]]:
228
+ def _merge_allof_object_schema(
229
+ all_of: list[Any], context: RenderContext
230
+ ) -> tuple[list[str], dict[str, Any]]:
202
231
  base_classes: list[str] = []
203
232
  inline_properties: dict[str, Any] = {}
204
233
  inline_required: list[str] = []
@@ -208,7 +237,7 @@ def _merge_allof_object_schema(all_of: list[Any]) -> tuple[list[str], dict[str,
208
237
  if not isinstance(part, dict):
209
238
  continue
210
239
  if isinstance(part.get("$ref"), str):
211
- base_classes.append(decode_component_ref(part["$ref"]))
240
+ base_classes.append(context.resolve_ref(part["$ref"]))
212
241
  continue
213
242
 
214
243
  if part.get("type") == "object" or "properties" in part:
@@ -242,6 +271,7 @@ def _build_model_lines(
242
271
  name: str,
243
272
  schema: AnySchema,
244
273
  *,
274
+ context: RenderContext,
245
275
  base_classes: list[str] | None = None,
246
276
  ) -> list[str]:
247
277
  properties = schema.get("properties") if isinstance(schema, dict) else None
@@ -253,23 +283,25 @@ def _build_model_lines(
253
283
 
254
284
  alias_used = False
255
285
  field_lines: list[str] = []
256
- used_names: set[str] = set()
257
286
 
258
287
  if isinstance(properties, dict):
288
+ attr_names = field_identifiers(properties)
259
289
  for prop_name, prop_schema in properties.items():
260
- attr_name = _dedupe_name(_sanitize_identifier(prop_name), used_names)
290
+ attr_name = attr_names[prop_name]
261
291
  alias = prop_name if attr_name != prop_name else None
262
- if alias:
292
+ if alias is not None:
263
293
  alias_used = True
264
294
 
265
- type_expr = schema_to_type_expr(prop_schema)
295
+ type_expr = schema_to_type_expr(prop_schema, context=context)
266
296
  is_required = prop_name in required
267
297
  if is_required:
268
- default_expr = f" = Field(alias={alias!r})" if alias else ""
298
+ default_expr = f" = Field(alias={alias!r})" if alias is not None else ""
269
299
  else:
270
300
  if not is_nullable_schema(prop_schema):
271
301
  type_expr = f"Annotated[Optional[{type_expr}], _omit_not_null]"
272
- default_expr = f" = Field(default=None, alias={alias!r})" if alias else " = None"
302
+ default_expr = (
303
+ f" = Field(default=None, alias={alias!r})" if alias is not None else " = None"
304
+ )
273
305
 
274
306
  field_lines.append(f" {attr_name}: {type_expr}{default_expr}")
275
307
 
@@ -284,7 +316,7 @@ def _build_model_lines(
284
316
  config_args.append("extra='allow'")
285
317
  elif isinstance(additional, dict):
286
318
  config_args.append("extra='allow'")
287
- extra_type = schema_to_type_expr(additional)
319
+ extra_type = schema_to_type_expr(additional, context=context)
288
320
  extra_line = (
289
321
  f" __pydantic_extra__: {extra_field_annotation(extra_type)} = Field(init=False)"
290
322
  )
@@ -339,7 +371,7 @@ def _emit_route_schemas(
339
371
  )
340
372
 
341
373
  def register_route_schema(name: str, schema: AnySchema) -> None:
342
- if _route_schema_ref_target(schema):
374
+ if _route_schema_ref_target(schema, state.render_context):
343
375
  return
344
376
  register_named_schema_batch(lower_named_schema(lowering_context, name, schema))
345
377
 
@@ -424,12 +456,13 @@ RouteMap = dict[str, dict[str, dict[str, str]]]
424
456
  def _generate_request_response_objects(
425
457
  routes: list[RouteInfo],
426
458
  schema_name_to_canonical: dict[str, str],
459
+ context: RenderContext,
427
460
  ) -> list[str]:
428
461
  request_paths: RouteMap = {}
429
462
  response_paths: RouteMap = {}
430
463
 
431
464
  def resolve_schema_name(name: str, schema: AnySchema) -> str:
432
- ref_target = _route_schema_ref_target(schema)
465
+ ref_target = _route_schema_ref_target(schema, context)
433
466
  if ref_target:
434
467
  return ref_target
435
468
  return schema_name_to_canonical.get(name, name)
@@ -670,7 +703,7 @@ def _build_openapi_object_schema(params: list[dict[str, Any]]) -> dict[str, Any]
670
703
  }
671
704
 
672
705
 
673
- def _route_schema_ref_target(schema: AnySchema) -> str | None:
706
+ def _route_schema_ref_target(schema: AnySchema, context: RenderContext) -> str | None:
674
707
  if not isinstance(schema, dict):
675
708
  return None
676
709
  ref = schema.get("$ref")
@@ -678,7 +711,7 @@ def _route_schema_ref_target(schema: AnySchema) -> str | None:
678
711
  return None
679
712
  if set(schema.keys()) != {"$ref"}:
680
713
  return None
681
- return decode_component_ref(ref)
714
+ return context.resolve_ref(ref)
682
715
 
683
716
 
684
717
  def _rewrite_schema_refs(schema: AnySchema, aliases: dict[str, str]) -> AnySchema:
@@ -705,15 +738,6 @@ def _rewrite_schema_refs(schema: AnySchema, aliases: dict[str, str]) -> AnySchem
705
738
  return rewritten
706
739
 
707
740
 
708
- def _sanitize_identifier(name: str) -> str:
709
- result = "".join(ch if ch in _VALID_IDENTIFIER_CHARS else "_" for ch in name)
710
- if result and result[0].isupper() and result[1:].islower():
711
- result = result[0].lower() + result[1:]
712
- if not result or result[0].isdigit():
713
- result = f"_{result}"
714
- return f"{result}_" if result in {"class", "from", "global", "pass"} else result
715
-
716
-
717
741
  def _dedupe_name(name: str, used: set[str]) -> str:
718
742
  if name not in used:
719
743
  used.add(name)
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import keyword
4
+ import re
5
+ from collections.abc import Iterable
6
+
7
+
8
+ def component_identifiers(names: Iterable[str]) -> dict[str, str]:
9
+ """Keep wire names separate from the unique symbols used in Python source."""
10
+ identifiers: dict[str, str] = {}
11
+ owners: dict[str, str] = {}
12
+ for name in names:
13
+ identifier = re.sub(r"[^A-Za-z0-9_]", "_", name) or "Schema"
14
+ if identifier[0].isdigit():
15
+ identifier = f"_{identifier}"
16
+ if keyword.iskeyword(identifier):
17
+ identifier += "_"
18
+ if identifier in owners:
19
+ raise ValueError(
20
+ f"Component names {owners[identifier]!r} and {name!r} normalize to the "
21
+ f"same Python identifier {identifier!r}"
22
+ )
23
+ identifiers[name] = identifier
24
+ owners[identifier] = name
25
+ return identifiers
26
+
27
+
28
+ def field_identifiers(names: Iterable[str]) -> dict[str, str]:
29
+ names = list(names)
30
+ wire_names = set(names)
31
+ identifiers: dict[str, str] = {}
32
+ used: set[str] = set()
33
+ for name in names:
34
+ candidate = re.sub(r"[^A-Za-z0-9_]", "_", name)
35
+ if candidate and candidate[0].isupper() and candidate[1:].islower():
36
+ candidate = candidate[0].lower() + candidate[1:]
37
+ if not candidate or candidate.startswith("_"):
38
+ candidate = f"{candidate.lstrip('_') or 'field'}_"
39
+ if candidate[0].isdigit():
40
+ candidate = f"field_{candidate}"
41
+ if keyword.iskeyword(candidate):
42
+ candidate += "_"
43
+
44
+ # Preserve existing casing aliases, and reserve wire names when repairing fields.
45
+ unavailable = used
46
+ if not name.isidentifier() or name.startswith("_") or keyword.iskeyword(name):
47
+ unavailable = used | wire_names
48
+ identifier = candidate
49
+ index = 2
50
+ while identifier in unavailable:
51
+ identifier = f"{candidate}_{index}"
52
+ index += 1
53
+ identifiers[name] = identifier
54
+ used.add(identifier)
55
+ return identifiers