alt-stack-openapi-pydantic 1.7.0__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.7.0/src/alt_stack_openapi_pydantic.egg-info → alt_stack_openapi_pydantic-1.7.1}/PKG-INFO +18 -4
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/README.md +17 -3
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/pyproject.toml +1 -1
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/emission.py +30 -28
- alt_stack_openapi_pydantic-1.7.1/src/alt_stack_openapi_pydantic/identifiers.py +55 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/rendering.py +7 -2
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1/src/alt_stack_openapi_pydantic.egg-info}/PKG-INFO +18 -4
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/SOURCES.txt +2 -0
- alt_stack_openapi_pydantic-1.7.1/tests/test_identifiers.py +138 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_master_openapi.py +13 -8
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_route_typing.py +3 -1
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/LICENSE +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/setup.cfg +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/__init__.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/brands.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/cli.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/dependencies.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/lowering.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/py.typed +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/registry.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/routes.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/schema_dedup.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/to_python.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/type_render.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/__init__.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/array.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/boolean.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/intersection.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/number.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/object.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/string.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/types.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/union.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/utils.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/dependency_links.txt +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/entry_points.txt +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/requires.txt +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic.egg-info/top_level.txt +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_array.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_boolean.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_brands.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_convert_schema.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_dependencies.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_generated_client.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_intersection.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_number.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_object.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_registry.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_string.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_to_python.py +0 -0
- {alt_stack_openapi_pydantic-1.7.0 → 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.7.
|
|
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
|
|
@@ -101,12 +101,26 @@ Python requires a class as a `NewType` base. Literal brands use their primitive
|
|
|
101
101
|
base; composite unions use `object` with the original Pydantic validator retained
|
|
102
102
|
in `Annotated` metadata.
|
|
103
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
|
+
|
|
104
117
|
## Development
|
|
105
118
|
|
|
106
119
|
```bash
|
|
107
|
-
uv
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
110
124
|
```
|
|
111
125
|
|
|
112
126
|
## Documentation
|
|
@@ -77,12 +77,26 @@ Python requires a class as a `NewType` base. Literal brands use their primitive
|
|
|
77
77
|
base; composite unions use `object` with the original Pydantic validator retained
|
|
78
78
|
in `Annotated` metadata.
|
|
79
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
|
+
|
|
80
93
|
## Development
|
|
81
94
|
|
|
82
95
|
```bash
|
|
83
|
-
uv
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
86
100
|
```
|
|
87
101
|
|
|
88
102
|
## Documentation
|
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
5
5
|
|
|
6
6
|
from .brands import newtype_base, prepare_brand_schemas
|
|
7
7
|
from .dependencies import topological_sort_schemas
|
|
8
|
+
from .identifiers import component_identifiers, field_identifiers
|
|
8
9
|
from .lowering import LoweringContext, NamedSchema, lower_named_schema
|
|
9
10
|
from .rendering import (
|
|
10
11
|
RenderContext,
|
|
@@ -34,8 +35,6 @@ from .schema_dedup import (
|
|
|
34
35
|
)
|
|
35
36
|
from .types import AnySchema
|
|
36
37
|
|
|
37
|
-
_VALID_IDENTIFIER_CHARS = set("_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
|
38
|
-
|
|
39
38
|
|
|
40
39
|
@dataclass(slots=True)
|
|
41
40
|
class EmissionState:
|
|
@@ -60,16 +59,19 @@ def openapi_to_pydantic_code(
|
|
|
60
59
|
schemas, routes, schema_to_type_expr, is_object_model_schema
|
|
61
60
|
)
|
|
62
61
|
|
|
63
|
-
|
|
62
|
+
component_names = component_identifiers(schemas)
|
|
63
|
+
lowering_context = LoweringContext(set(component_names.values()))
|
|
64
64
|
lowered_components: list[NamedSchema] = []
|
|
65
65
|
for name in topological_sort_schemas(schemas):
|
|
66
66
|
schema = schemas.get(name)
|
|
67
67
|
if schema is None:
|
|
68
68
|
continue
|
|
69
|
-
lowered_components.extend(
|
|
69
|
+
lowered_components.extend(
|
|
70
|
+
lower_named_schema(lowering_context, component_names[name], schema)
|
|
71
|
+
)
|
|
70
72
|
|
|
71
73
|
lines = _build_module_preamble(custom_import_lines)
|
|
72
|
-
state = EmissionState()
|
|
74
|
+
state = EmissionState(render_context=RenderContext(component_names=component_names))
|
|
73
75
|
registry = create_schema_registry()
|
|
74
76
|
|
|
75
77
|
for named_schema in lowered_components:
|
|
@@ -96,7 +98,11 @@ def openapi_to_pydantic_code(
|
|
|
96
98
|
lines.extend(_emit_model_rebuilds(state.rebuild_names))
|
|
97
99
|
if route_lines:
|
|
98
100
|
lines.append("")
|
|
99
|
-
lines.extend(
|
|
101
|
+
lines.extend(
|
|
102
|
+
_generate_request_response_objects(
|
|
103
|
+
routes, schema_name_to_canonical, state.render_context
|
|
104
|
+
)
|
|
105
|
+
)
|
|
100
106
|
return _finalize_module(lines, state)
|
|
101
107
|
|
|
102
108
|
lines.extend(_emit_model_rebuilds(state.rebuild_names))
|
|
@@ -188,7 +194,7 @@ def _has_root_model_allof_base(schema: AnySchema, state: EmissionState) -> bool:
|
|
|
188
194
|
return any(
|
|
189
195
|
isinstance(part, dict)
|
|
190
196
|
and isinstance(part.get("$ref"), str)
|
|
191
|
-
and
|
|
197
|
+
and state.render_context.resolve_ref(part["$ref"]) in state.render_context.root_model_names
|
|
192
198
|
for part in all_of
|
|
193
199
|
)
|
|
194
200
|
|
|
@@ -196,7 +202,7 @@ def _has_root_model_allof_base(schema: AnySchema, state: EmissionState) -> bool:
|
|
|
196
202
|
def _emit_object_model(name: str, schema: AnySchema, state: EmissionState) -> list[str]:
|
|
197
203
|
all_of = schema.get("allOf") if isinstance(schema, dict) else None
|
|
198
204
|
if isinstance(all_of, list):
|
|
199
|
-
base_classes, merged_schema = _merge_allof_object_schema(all_of)
|
|
205
|
+
base_classes, merged_schema = _merge_allof_object_schema(all_of, state.render_context)
|
|
200
206
|
model_lines = _build_model_lines(
|
|
201
207
|
name, merged_schema, context=state.render_context, base_classes=base_classes or None
|
|
202
208
|
)
|
|
@@ -219,7 +225,9 @@ def _emit_root_model(name: str, schema: AnySchema, state: EmissionState) -> list
|
|
|
219
225
|
]
|
|
220
226
|
|
|
221
227
|
|
|
222
|
-
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]]:
|
|
223
231
|
base_classes: list[str] = []
|
|
224
232
|
inline_properties: dict[str, Any] = {}
|
|
225
233
|
inline_required: list[str] = []
|
|
@@ -229,7 +237,7 @@ def _merge_allof_object_schema(all_of: list[Any]) -> tuple[list[str], dict[str,
|
|
|
229
237
|
if not isinstance(part, dict):
|
|
230
238
|
continue
|
|
231
239
|
if isinstance(part.get("$ref"), str):
|
|
232
|
-
base_classes.append(
|
|
240
|
+
base_classes.append(context.resolve_ref(part["$ref"]))
|
|
233
241
|
continue
|
|
234
242
|
|
|
235
243
|
if part.get("type") == "object" or "properties" in part:
|
|
@@ -275,23 +283,25 @@ def _build_model_lines(
|
|
|
275
283
|
|
|
276
284
|
alias_used = False
|
|
277
285
|
field_lines: list[str] = []
|
|
278
|
-
used_names: set[str] = set()
|
|
279
286
|
|
|
280
287
|
if isinstance(properties, dict):
|
|
288
|
+
attr_names = field_identifiers(properties)
|
|
281
289
|
for prop_name, prop_schema in properties.items():
|
|
282
|
-
attr_name =
|
|
290
|
+
attr_name = attr_names[prop_name]
|
|
283
291
|
alias = prop_name if attr_name != prop_name else None
|
|
284
|
-
if alias:
|
|
292
|
+
if alias is not None:
|
|
285
293
|
alias_used = True
|
|
286
294
|
|
|
287
295
|
type_expr = schema_to_type_expr(prop_schema, context=context)
|
|
288
296
|
is_required = prop_name in required
|
|
289
297
|
if is_required:
|
|
290
|
-
default_expr = f" = Field(alias={alias!r})" if alias else ""
|
|
298
|
+
default_expr = f" = Field(alias={alias!r})" if alias is not None else ""
|
|
291
299
|
else:
|
|
292
300
|
if not is_nullable_schema(prop_schema):
|
|
293
301
|
type_expr = f"Annotated[Optional[{type_expr}], _omit_not_null]"
|
|
294
|
-
default_expr =
|
|
302
|
+
default_expr = (
|
|
303
|
+
f" = Field(default=None, alias={alias!r})" if alias is not None else " = None"
|
|
304
|
+
)
|
|
295
305
|
|
|
296
306
|
field_lines.append(f" {attr_name}: {type_expr}{default_expr}")
|
|
297
307
|
|
|
@@ -361,7 +371,7 @@ def _emit_route_schemas(
|
|
|
361
371
|
)
|
|
362
372
|
|
|
363
373
|
def register_route_schema(name: str, schema: AnySchema) -> None:
|
|
364
|
-
if _route_schema_ref_target(schema):
|
|
374
|
+
if _route_schema_ref_target(schema, state.render_context):
|
|
365
375
|
return
|
|
366
376
|
register_named_schema_batch(lower_named_schema(lowering_context, name, schema))
|
|
367
377
|
|
|
@@ -446,12 +456,13 @@ RouteMap = dict[str, dict[str, dict[str, str]]]
|
|
|
446
456
|
def _generate_request_response_objects(
|
|
447
457
|
routes: list[RouteInfo],
|
|
448
458
|
schema_name_to_canonical: dict[str, str],
|
|
459
|
+
context: RenderContext,
|
|
449
460
|
) -> list[str]:
|
|
450
461
|
request_paths: RouteMap = {}
|
|
451
462
|
response_paths: RouteMap = {}
|
|
452
463
|
|
|
453
464
|
def resolve_schema_name(name: str, schema: AnySchema) -> str:
|
|
454
|
-
ref_target = _route_schema_ref_target(schema)
|
|
465
|
+
ref_target = _route_schema_ref_target(schema, context)
|
|
455
466
|
if ref_target:
|
|
456
467
|
return ref_target
|
|
457
468
|
return schema_name_to_canonical.get(name, name)
|
|
@@ -692,7 +703,7 @@ def _build_openapi_object_schema(params: list[dict[str, Any]]) -> dict[str, Any]
|
|
|
692
703
|
}
|
|
693
704
|
|
|
694
705
|
|
|
695
|
-
def _route_schema_ref_target(schema: AnySchema) -> str | None:
|
|
706
|
+
def _route_schema_ref_target(schema: AnySchema, context: RenderContext) -> str | None:
|
|
696
707
|
if not isinstance(schema, dict):
|
|
697
708
|
return None
|
|
698
709
|
ref = schema.get("$ref")
|
|
@@ -700,7 +711,7 @@ def _route_schema_ref_target(schema: AnySchema) -> str | None:
|
|
|
700
711
|
return None
|
|
701
712
|
if set(schema.keys()) != {"$ref"}:
|
|
702
713
|
return None
|
|
703
|
-
return
|
|
714
|
+
return context.resolve_ref(ref)
|
|
704
715
|
|
|
705
716
|
|
|
706
717
|
def _rewrite_schema_refs(schema: AnySchema, aliases: dict[str, str]) -> AnySchema:
|
|
@@ -727,15 +738,6 @@ def _rewrite_schema_refs(schema: AnySchema, aliases: dict[str, str]) -> AnySchem
|
|
|
727
738
|
return rewritten
|
|
728
739
|
|
|
729
740
|
|
|
730
|
-
def _sanitize_identifier(name: str) -> str:
|
|
731
|
-
result = "".join(ch if ch in _VALID_IDENTIFIER_CHARS else "_" for ch in name)
|
|
732
|
-
if result and result[0].isupper() and result[1:].islower():
|
|
733
|
-
result = result[0].lower() + result[1:]
|
|
734
|
-
if not result or result[0].isdigit():
|
|
735
|
-
result = f"_{result}"
|
|
736
|
-
return f"{result}_" if result in {"class", "from", "global", "pass"} else result
|
|
737
|
-
|
|
738
|
-
|
|
739
741
|
def _dedupe_name(name: str, used: set[str]) -> str:
|
|
740
742
|
if name not in used:
|
|
741
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
|
|
@@ -24,6 +24,11 @@ from .types.union import convert_openapi_union_to_pydantic
|
|
|
24
24
|
class RenderContext:
|
|
25
25
|
root_model_names: set[str] = field(default_factory=set)
|
|
26
26
|
uses_json: bool = False
|
|
27
|
+
component_names: dict[str, str] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def resolve_ref(self, ref: str) -> str:
|
|
30
|
+
name = decode_component_ref(ref)
|
|
31
|
+
return self.component_names.get(name, name)
|
|
27
32
|
|
|
28
33
|
|
|
29
34
|
def decode_component_ref(ref: str) -> str:
|
|
@@ -83,7 +88,7 @@ def schema_to_type_expr(
|
|
|
83
88
|
|
|
84
89
|
brand_schema = schema.get("_altstack_brand_schema")
|
|
85
90
|
if isinstance(brand_schema, dict):
|
|
86
|
-
name =
|
|
91
|
+
name = render_context.resolve_ref(schema["$ref"])
|
|
87
92
|
expr = schema_to_type_expr(brand_schema, context=render_context)
|
|
88
93
|
return wrap_nullable(branded_annotation(name, expr), schema)
|
|
89
94
|
brand = get_brand_name(schema)
|
|
@@ -103,7 +108,7 @@ def schema_to_type_expr(
|
|
|
103
108
|
ref = schema["$ref"]
|
|
104
109
|
if not ref.startswith("#/components/schemas/"):
|
|
105
110
|
return wrap_nullable("Any", schema)
|
|
106
|
-
expr =
|
|
111
|
+
expr = render_context.resolve_ref(ref)
|
|
107
112
|
return wrap_nullable(expr, schema)
|
|
108
113
|
|
|
109
114
|
if isinstance(schema.get("oneOf"), list):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alt-stack-openapi-pydantic
|
|
3
|
-
Version: 1.7.
|
|
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
|
|
@@ -101,12 +101,26 @@ Python requires a class as a `NewType` base. Literal brands use their primitive
|
|
|
101
101
|
base; composite unions use `object` with the original Pydantic validator retained
|
|
102
102
|
in `Annotated` metadata.
|
|
103
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
|
+
|
|
104
117
|
## Development
|
|
105
118
|
|
|
106
119
|
```bash
|
|
107
|
-
uv
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
110
124
|
```
|
|
111
125
|
|
|
112
126
|
## Documentation
|
|
@@ -6,6 +6,7 @@ src/alt_stack_openapi_pydantic/brands.py
|
|
|
6
6
|
src/alt_stack_openapi_pydantic/cli.py
|
|
7
7
|
src/alt_stack_openapi_pydantic/dependencies.py
|
|
8
8
|
src/alt_stack_openapi_pydantic/emission.py
|
|
9
|
+
src/alt_stack_openapi_pydantic/identifiers.py
|
|
9
10
|
src/alt_stack_openapi_pydantic/lowering.py
|
|
10
11
|
src/alt_stack_openapi_pydantic/py.typed
|
|
11
12
|
src/alt_stack_openapi_pydantic/registry.py
|
|
@@ -36,6 +37,7 @@ tests/test_brands.py
|
|
|
36
37
|
tests/test_convert_schema.py
|
|
37
38
|
tests/test_dependencies.py
|
|
38
39
|
tests/test_generated_client.py
|
|
40
|
+
tests/test_identifiers.py
|
|
39
41
|
tests/test_intersection.py
|
|
40
42
|
tests/test_master_openapi.py
|
|
41
43
|
tests/test_number.py
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import keyword
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from alt_stack_openapi_pydantic import openapi_to_pydantic_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize(
|
|
13
|
+
("wire_name", "python_name"),
|
|
14
|
+
[
|
|
15
|
+
("Rule-Output", "Rule_Output"),
|
|
16
|
+
("Order.Input", "Order_Input"),
|
|
17
|
+
("123Value", "_123Value"),
|
|
18
|
+
("class", "class_"),
|
|
19
|
+
],
|
|
20
|
+
)
|
|
21
|
+
def test_component_identifiers_resolve_references_and_route_maps(
|
|
22
|
+
wire_name: str, python_name: str
|
|
23
|
+
) -> None:
|
|
24
|
+
ref = {"$ref": f"#/components/schemas/{wire_name}"}
|
|
25
|
+
schema = {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {"name": {"type": "string", "enum": [wire_name]}},
|
|
28
|
+
"required": ["name"],
|
|
29
|
+
"additionalProperties": False,
|
|
30
|
+
}
|
|
31
|
+
spec = {
|
|
32
|
+
"openapi": "3.1.0",
|
|
33
|
+
"info": {"title": "Identifiers", "version": "1"},
|
|
34
|
+
"components": {
|
|
35
|
+
"schemas": {
|
|
36
|
+
wire_name: schema,
|
|
37
|
+
"Alias": ref,
|
|
38
|
+
"Envelope": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"item": ref,
|
|
42
|
+
"items": {"type": "array", "items": ref},
|
|
43
|
+
"values": {"type": "object", "additionalProperties": ref},
|
|
44
|
+
"choice": {"anyOf": [ref, {"type": "null"}]},
|
|
45
|
+
},
|
|
46
|
+
"required": ["item", "items", "values", "choice"],
|
|
47
|
+
},
|
|
48
|
+
"Extended": {"allOf": [ref]},
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"paths": {
|
|
52
|
+
"/items": {
|
|
53
|
+
"post": {
|
|
54
|
+
"requestBody": {"content": {"application/json": {"schema": ref}}},
|
|
55
|
+
"responses": {"200": {"content": {"application/json": {"schema": ref}}}},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
namespace: dict[str, Any] = {}
|
|
61
|
+
exec(openapi_to_pydantic_code(spec, options={"include_routes": True}), namespace)
|
|
62
|
+
|
|
63
|
+
model = namespace[python_name]
|
|
64
|
+
payload = {"name": wire_name}
|
|
65
|
+
assert model.model_validate(payload).model_dump(by_alias=True) == payload
|
|
66
|
+
assert namespace["Alias"].model_validate(payload).root == model.model_validate(payload)
|
|
67
|
+
envelope = namespace["Envelope"].model_validate(
|
|
68
|
+
{"item": payload, "items": [payload], "values": {"key": payload}, "choice": payload}
|
|
69
|
+
)
|
|
70
|
+
assert isinstance(envelope.item, model)
|
|
71
|
+
assert isinstance(envelope.items[0], model)
|
|
72
|
+
assert isinstance(envelope.values["key"], model)
|
|
73
|
+
assert isinstance(envelope.choice, model)
|
|
74
|
+
assert issubclass(namespace["Extended"], model)
|
|
75
|
+
assert namespace["Request"]["/items"]["POST"]["body"] is model
|
|
76
|
+
assert namespace["Response"]["/items"]["POST"]["200"] is model
|
|
77
|
+
assert spec["components"]["schemas"][wire_name] == schema
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize(
|
|
81
|
+
"names", [("Rule-Output", "Rule_Output"), ("class", "class_"), ("1", "_1")]
|
|
82
|
+
)
|
|
83
|
+
def test_component_identifier_collisions_fail_generation(names: tuple[str, str]) -> None:
|
|
84
|
+
spec = {"components": {"schemas": {name: {"type": "string"} for name in names}}}
|
|
85
|
+
|
|
86
|
+
with pytest.raises(ValueError, match="same Python identifier") as error:
|
|
87
|
+
openapi_to_pydantic_code(spec)
|
|
88
|
+
|
|
89
|
+
assert all(repr(name) in str(error.value) for name in names)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize(
|
|
93
|
+
"names",
|
|
94
|
+
[
|
|
95
|
+
["_id", "id_", "id__2", "id"],
|
|
96
|
+
["_id", "id_", "id__2", "id", "__", "", "1st", "display-name", *keyword.kwlist],
|
|
97
|
+
],
|
|
98
|
+
ids=["underscore", "invalid-and-keyword"],
|
|
99
|
+
)
|
|
100
|
+
def test_public_property_aliases_validate_and_round_trip_without_collisions(
|
|
101
|
+
names: list[str],
|
|
102
|
+
) -> None:
|
|
103
|
+
properties = {name: {"type": "string"} for name in names}
|
|
104
|
+
properties["_meta"] = {"type": "string"}
|
|
105
|
+
spec = {
|
|
106
|
+
"components": {
|
|
107
|
+
"schemas": {
|
|
108
|
+
"Item": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": properties,
|
|
111
|
+
"required": names,
|
|
112
|
+
"additionalProperties": False,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
namespace: dict[str, Any] = {}
|
|
118
|
+
exec(openapi_to_pydantic_code(spec), namespace)
|
|
119
|
+
model = namespace["Item"]
|
|
120
|
+
|
|
121
|
+
assert len(model.model_fields) == len(properties)
|
|
122
|
+
assert all(not name.startswith("_") for name in model.model_fields)
|
|
123
|
+
assert all(name.isidentifier() and not keyword.iskeyword(name) for name in model.model_fields)
|
|
124
|
+
payload = {name: f"value:{name}" for name in properties}
|
|
125
|
+
instance = model.model_validate(payload)
|
|
126
|
+
assert instance.id_ == payload["id_"]
|
|
127
|
+
assert instance.model_dump(by_alias=True) == payload
|
|
128
|
+
assert model.model_validate_json(instance.model_dump_json(by_alias=True)) == instance
|
|
129
|
+
assert model.model_validate({name: payload[name] for name in names}).model_dump(
|
|
130
|
+
by_alias=True, exclude_unset=True
|
|
131
|
+
) == {name: payload[name] for name in names}
|
|
132
|
+
|
|
133
|
+
with pytest.raises(ValidationError):
|
|
134
|
+
model.model_validate({**payload, "_id": 123})
|
|
135
|
+
with pytest.raises(ValidationError):
|
|
136
|
+
model.model_validate({name: value for name, value in payload.items() if name != "_id"})
|
|
137
|
+
with pytest.raises(ValidationError):
|
|
138
|
+
model.model_validate({**payload, "_meta": 123})
|
{alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_master_openapi.py
RENAMED
|
@@ -28,8 +28,8 @@ from alt_stack_openapi_pydantic.schema_dedup import get_schema_fingerprint
|
|
|
28
28
|
from alt_stack_openapi_pydantic.to_python import openapi_to_pydantic_code
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def _load_fixture() -> dict[str, Any]:
|
|
32
|
-
spec_path = Path(__file__).resolve().parents[2] / "openapi-test-spec" /
|
|
31
|
+
def _load_fixture(filename: str) -> dict[str, Any]:
|
|
32
|
+
spec_path = Path(__file__).resolve().parents[2] / "openapi-test-spec" / filename
|
|
33
33
|
return json.loads(spec_path.read_text(encoding="utf-8"))
|
|
34
34
|
|
|
35
35
|
|
|
@@ -280,22 +280,27 @@ def _to_openapi_schema(
|
|
|
280
280
|
return schema
|
|
281
281
|
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
@pytest.mark.parametrize("filename", ["openapi.json", "python-identifiers.json"])
|
|
284
|
+
def test_master_openapi_fixture(filename: str) -> None:
|
|
284
285
|
clear_pydantic_schema_registry()
|
|
285
|
-
spec = _load_fixture()
|
|
286
|
+
spec = _load_fixture(filename)
|
|
286
287
|
code = openapi_to_pydantic_code(spec, options={"include_routes": True})
|
|
287
288
|
module = _load_module(code)
|
|
288
289
|
|
|
289
290
|
schemas = spec.get("components", {}).get("schemas", {})
|
|
290
|
-
|
|
291
|
+
python_names = {"Rule-Output": "Rule_Output", "Order-Input": "Order_Input"}
|
|
292
|
+
models = {name: getattr(module, python_names.get(name, name)) for name in schemas}
|
|
293
|
+
type_to_name = {model: name for name, model in models.items()}
|
|
291
294
|
|
|
292
295
|
for name, schema in schemas.items():
|
|
293
296
|
examples = schema.get("x-altstack-examples")
|
|
294
297
|
if not isinstance(examples, dict):
|
|
295
298
|
continue
|
|
296
|
-
model =
|
|
299
|
+
model = models[name]
|
|
297
300
|
for value in examples.get("valid", []):
|
|
298
|
-
model.model_validate(value)
|
|
301
|
+
instance = model.model_validate(value)
|
|
302
|
+
if filename == "python-identifiers.json":
|
|
303
|
+
assert instance.model_dump(by_alias=True, exclude_unset=True) == value
|
|
299
304
|
for value in examples.get("invalid", []):
|
|
300
305
|
with pytest.raises(ValidationError):
|
|
301
306
|
model.model_validate(value)
|
|
@@ -311,7 +316,7 @@ def test_master_openapi_fixture() -> None:
|
|
|
311
316
|
|
|
312
317
|
for name in schemas:
|
|
313
318
|
actual["components"]["schemas"][name] = _to_openapi_schema(
|
|
314
|
-
|
|
319
|
+
models[name],
|
|
315
320
|
type_to_name,
|
|
316
321
|
self_name=name,
|
|
317
322
|
schema_fingerprint_to_name=schema_fingerprint_to_name,
|
{alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_route_typing.py
RENAMED
|
@@ -394,13 +394,15 @@ def test_route_maps_infer_exact_models_with_pyrefly(
|
|
|
394
394
|
pytest.param(_check_with_pyrefly, PYREFLY_PATTERN, id="pyrefly"),
|
|
395
395
|
],
|
|
396
396
|
)
|
|
397
|
+
@pytest.mark.parametrize("filename", ["openapi.json", "python-identifiers.json"])
|
|
397
398
|
def test_master_spec_route_maps_type_check(
|
|
398
399
|
check: Callable[[Path], str],
|
|
399
400
|
pattern: str,
|
|
401
|
+
filename: str,
|
|
400
402
|
) -> None:
|
|
401
403
|
"""The full test spec's route maps produce a type-clean module (no name collisions)."""
|
|
402
404
|
clear_pydantic_schema_registry()
|
|
403
|
-
spec_path = Path(__file__).resolve().parents[2] / "openapi-test-spec" /
|
|
405
|
+
spec_path = Path(__file__).resolve().parents[2] / "openapi-test-spec" / filename
|
|
404
406
|
spec = json.loads(spec_path.read_text(encoding="utf-8"))
|
|
405
407
|
code = openapi_to_pydantic_code(spec, options={"include_routes": True})
|
|
406
408
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_convert_schema.py
RENAMED
|
File without changes
|
{alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_dependencies.py
RENAMED
|
File without changes
|
{alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_generated_client.py
RENAMED
|
File without changes
|
{alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_intersection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_registry.py
RENAMED
|
File without changes
|
|
File without changes
|
{alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_to_python.py
RENAMED
|
File without changes
|
|
File without changes
|