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.
Files changed (51) hide show
  1. {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
  2. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/README.md +17 -3
  3. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/pyproject.toml +1 -1
  4. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/emission.py +30 -28
  5. alt_stack_openapi_pydantic-1.7.1/src/alt_stack_openapi_pydantic/identifiers.py +55 -0
  6. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/rendering.py +7 -2
  7. {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
  8. {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
  9. alt_stack_openapi_pydantic-1.7.1/tests/test_identifiers.py +138 -0
  10. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_master_openapi.py +13 -8
  11. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_route_typing.py +3 -1
  12. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/LICENSE +0 -0
  13. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/setup.cfg +0 -0
  14. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/__init__.py +0 -0
  15. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/brands.py +0 -0
  16. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/cli.py +0 -0
  17. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/dependencies.py +0 -0
  18. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/lowering.py +0 -0
  19. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/py.typed +0 -0
  20. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/registry.py +0 -0
  21. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/routes.py +0 -0
  22. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/schema_dedup.py +0 -0
  23. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/to_python.py +0 -0
  24. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/type_render.py +0 -0
  25. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/__init__.py +0 -0
  26. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/array.py +0 -0
  27. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/boolean.py +0 -0
  28. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/intersection.py +0 -0
  29. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/number.py +0 -0
  30. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/object.py +0 -0
  31. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/string.py +0 -0
  32. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/types.py +0 -0
  33. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/types/union.py +0 -0
  34. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/src/alt_stack_openapi_pydantic/utils.py +0 -0
  35. {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
  36. {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
  37. {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
  38. {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
  39. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_array.py +0 -0
  40. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_boolean.py +0 -0
  41. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_brands.py +0 -0
  42. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_convert_schema.py +0 -0
  43. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_dependencies.py +0 -0
  44. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_generated_client.py +0 -0
  45. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_intersection.py +0 -0
  46. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_number.py +0 -0
  47. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_object.py +0 -0
  48. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_registry.py +0 -0
  49. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_string.py +0 -0
  50. {alt_stack_openapi_pydantic-1.7.0 → alt_stack_openapi_pydantic-1.7.1}/tests/test_to_python.py +0 -0
  51. {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.0
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 run --project packages/python-pydantic-openapi --extra dev pytest packages/python-pydantic-openapi/tests
108
- uv run --project packages/python-pydantic-openapi --extra dev ruff check packages/python-pydantic-openapi
109
- uv run --project packages/python-pydantic-openapi --extra dev ty check packages/python-pydantic-openapi
120
+ uv sync --project packages/python-pydantic-openapi --extra dev
121
+ packages/python-pydantic-openapi/.venv/bin/pytest packages/python-pydantic-openapi/tests -q
122
+ packages/python-pydantic-openapi/.venv/bin/ruff check packages/python-pydantic-openapi
123
+ packages/python-pydantic-openapi/.venv/bin/ty check packages/python-pydantic-openapi
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 run --project packages/python-pydantic-openapi --extra dev pytest packages/python-pydantic-openapi/tests
84
- uv run --project packages/python-pydantic-openapi --extra dev ruff check packages/python-pydantic-openapi
85
- uv run --project packages/python-pydantic-openapi --extra dev ty check packages/python-pydantic-openapi
96
+ uv sync --project packages/python-pydantic-openapi --extra dev
97
+ packages/python-pydantic-openapi/.venv/bin/pytest packages/python-pydantic-openapi/tests -q
98
+ packages/python-pydantic-openapi/.venv/bin/ruff check packages/python-pydantic-openapi
99
+ packages/python-pydantic-openapi/.venv/bin/ty check packages/python-pydantic-openapi
86
100
  ```
87
101
 
88
102
  ## Documentation
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alt-stack-openapi-pydantic"
3
- version = "1.7.0"
3
+ version = "1.7.1"
4
4
  description = "Generate Pydantic models, typed route maps, and an asyncio client from OpenAPI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- lowering_context = LoweringContext(set(schemas.keys()))
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(lower_named_schema(lowering_context, name, schema))
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(_generate_request_response_objects(routes, schema_name_to_canonical))
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 decode_component_ref(part["$ref"]) in state.render_context.root_model_names
197
+ and state.render_context.resolve_ref(part["$ref"]) in state.render_context.root_model_names
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(all_of: list[Any]) -> tuple[list[str], dict[str, Any]]:
228
+ def _merge_allof_object_schema(
229
+ all_of: list[Any], context: RenderContext
230
+ ) -> tuple[list[str], dict[str, Any]]:
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(decode_component_ref(part["$ref"]))
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 = _dedupe_name(_sanitize_identifier(prop_name), used_names)
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 = f" = Field(default=None, alias={alias!r})" if alias else " = None"
302
+ default_expr = (
303
+ f" = Field(default=None, alias={alias!r})" if alias is not None else " = None"
304
+ )
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 decode_component_ref(ref)
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 = decode_component_ref(schema["$ref"])
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 = decode_component_ref(ref)
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.0
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 run --project packages/python-pydantic-openapi --extra dev pytest packages/python-pydantic-openapi/tests
108
- uv run --project packages/python-pydantic-openapi --extra dev ruff check packages/python-pydantic-openapi
109
- uv run --project packages/python-pydantic-openapi --extra dev ty check packages/python-pydantic-openapi
120
+ uv sync --project packages/python-pydantic-openapi --extra dev
121
+ packages/python-pydantic-openapi/.venv/bin/pytest packages/python-pydantic-openapi/tests -q
122
+ packages/python-pydantic-openapi/.venv/bin/ruff check packages/python-pydantic-openapi
123
+ packages/python-pydantic-openapi/.venv/bin/ty check packages/python-pydantic-openapi
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})
@@ -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" / "openapi.json"
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
- def test_master_openapi_fixture() -> None:
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
- type_to_name = {getattr(module, name): name for name in schemas}
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 = getattr(module, name)
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
- getattr(module, name),
319
+ models[name],
315
320
  type_to_name,
316
321
  self_name=name,
317
322
  schema_fingerprint_to_name=schema_fingerprint_to_name,
@@ -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" / "openapi.json"
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