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