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.
- {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
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/README.md +38 -3
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/pyproject.toml +1 -1
- alt_stack_openapi_pydantic-1.7.1/src/alt_stack_openapi_pydantic/brands.py +181 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/emission.py +60 -36
- alt_stack_openapi_pydantic-1.7.1/src/alt_stack_openapi_pydantic/identifiers.py +55 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/rendering.py +28 -2
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/number.py +10 -0
- {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
- {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
- alt_stack_openapi_pydantic-1.7.1/tests/test_brands.py +199 -0
- alt_stack_openapi_pydantic-1.7.1/tests/test_identifiers.py +138 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_master_openapi.py +29 -11
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_route_typing.py +3 -1
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/LICENSE +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/setup.cfg +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/__init__.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/cli.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/dependencies.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/lowering.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/py.typed +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/registry.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/routes.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/schema_dedup.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/to_python.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/type_render.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/__init__.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/array.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/boolean.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/intersection.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/object.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/string.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/types.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/union.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/utils.py +0 -0
- {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
- {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
- {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
- {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
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_array.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_boolean.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_convert_schema.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_dependencies.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_generated_client.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_intersection.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_number.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_object.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_registry.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_string.py +0 -0
- {alt_stack_openapi_pydantic-1.6.4 → alt_stack_openapi_pydantic-1.7.1}/tests/test_to_python.py +0 -0
- {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.
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
94
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|