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.
- {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
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/README.md +21 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/pyproject.toml +1 -1
- alt_stack_openapi_pydantic-1.7.0/src/alt_stack_openapi_pydantic/brands.py +181 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/emission.py +32 -9
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/rendering.py +75 -1
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/number.py +10 -0
- {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
- {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
- alt_stack_openapi_pydantic-1.7.0/tests/test_brands.py +199 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_convert_schema.py +27 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_master_openapi.py +16 -3
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_to_python.py +37 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/LICENSE +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/setup.cfg +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/__init__.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/cli.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/dependencies.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/lowering.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/py.typed +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/registry.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/routes.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/schema_dedup.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/to_python.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/type_render.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/__init__.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/array.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/boolean.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/intersection.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/object.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/string.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/types.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/types/union.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/src/alt_stack_openapi_pydantic/utils.py +0 -0
- {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
- {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
- {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
- {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
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_array.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_boolean.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_dependencies.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_generated_client.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_intersection.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_number.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_object.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_registry.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_route_typing.py +0 -0
- {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_string.py +0 -0
- {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.
|
|
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
|
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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))
|
{alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_convert_schema.py
RENAMED
|
@@ -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
|
+
)
|
{alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_master_openapi.py
RENAMED
|
@@ -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
|
|
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
|
|
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):
|
{alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_to_python.py
RENAMED
|
@@ -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": {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_dependencies.py
RENAMED
|
File without changes
|
{alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_generated_client.py
RENAMED
|
File without changes
|
{alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_intersection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_registry.py
RENAMED
|
File without changes
|
{alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.7.0}/tests/test_route_typing.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|