alt-stack-openapi-pydantic 1.6.3__tar.gz → 1.6.4__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 (47) hide show
  1. {alt_stack_openapi_pydantic-1.6.3/src/alt_stack_openapi_pydantic.egg-info → alt_stack_openapi_pydantic-1.6.4}/PKG-INFO +1 -1
  2. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/pyproject.toml +1 -1
  3. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/emission.py +2 -1
  4. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/rendering.py +53 -0
  5. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4/src/alt_stack_openapi_pydantic.egg-info}/PKG-INFO +1 -1
  6. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_convert_schema.py +27 -0
  7. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_to_python.py +37 -0
  8. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/LICENSE +0 -0
  9. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/README.md +0 -0
  10. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/setup.cfg +0 -0
  11. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/__init__.py +0 -0
  12. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/cli.py +0 -0
  13. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/dependencies.py +0 -0
  14. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/lowering.py +0 -0
  15. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/py.typed +0 -0
  16. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/registry.py +0 -0
  17. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/routes.py +0 -0
  18. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/schema_dedup.py +0 -0
  19. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/to_python.py +0 -0
  20. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/type_render.py +0 -0
  21. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/types/__init__.py +0 -0
  22. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/types/array.py +0 -0
  23. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/types/boolean.py +0 -0
  24. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/types/intersection.py +0 -0
  25. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/types/number.py +0 -0
  26. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/types/object.py +0 -0
  27. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/types/string.py +0 -0
  28. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/types/types.py +0 -0
  29. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/types/union.py +0 -0
  30. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic/utils.py +0 -0
  31. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic.egg-info/SOURCES.txt +0 -0
  32. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic.egg-info/dependency_links.txt +0 -0
  33. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic.egg-info/entry_points.txt +0 -0
  34. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic.egg-info/requires.txt +0 -0
  35. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/src/alt_stack_openapi_pydantic.egg-info/top_level.txt +0 -0
  36. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_array.py +0 -0
  37. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_boolean.py +0 -0
  38. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_dependencies.py +0 -0
  39. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_generated_client.py +0 -0
  40. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_intersection.py +0 -0
  41. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_master_openapi.py +0 -0
  42. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_number.py +0 -0
  43. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_object.py +0 -0
  44. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_registry.py +0 -0
  45. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_route_typing.py +0 -0
  46. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/tests/test_string.py +0 -0
  47. {alt_stack_openapi_pydantic-1.6.3 → alt_stack_openapi_pydantic-1.6.4}/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.6.3
3
+ Version: 1.6.4
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alt-stack-openapi-pydantic"
3
- version = "1.6.3"
3
+ version = "1.6.4"
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"
@@ -9,6 +9,7 @@ from .rendering import (
9
9
  RenderContext,
10
10
  decode_component_ref,
11
11
  extra_field_annotation,
12
+ is_nullable_schema,
12
13
  is_object_model_schema,
13
14
  root_model_annotation,
14
15
  schema_to_type_expr,
@@ -266,7 +267,7 @@ def _build_model_lines(
266
267
  if is_required:
267
268
  default_expr = f" = Field(alias={alias!r})" if alias else ""
268
269
  else:
269
- if not isinstance(prop_schema, dict) or prop_schema.get("nullable") is not True:
270
+ if not is_nullable_schema(prop_schema):
270
271
  type_expr = f"Annotated[Optional[{type_expr}], _omit_not_null]"
271
272
  default_expr = f" = Field(default=None, alias={alias!r})" if alias else " = None"
272
273
 
@@ -79,6 +79,13 @@ def schema_to_type_expr(
79
79
 
80
80
  render_context = context or RenderContext()
81
81
 
82
+ # OpenAPI 3.1 spells "may be null" three ways; all of them render as Optional[...].
83
+ if schema.get("type") == "null":
84
+ return "None"
85
+ without_null = strip_null_member(schema)
86
+ if without_null is not None:
87
+ return f"Optional[{schema_to_type_expr(without_null, context=render_context)}]"
88
+
82
89
  if isinstance(schema.get("$ref"), str):
83
90
  ref = schema["$ref"]
84
91
  if not ref.startswith("#/components/schemas/"):
@@ -148,6 +155,52 @@ def wrap_nullable(expr: str, schema: dict[str, Any]) -> str:
148
155
  return expr
149
156
 
150
157
 
158
+ def _is_null_schema(schema: Any) -> bool:
159
+ return isinstance(schema, dict) and schema.get("type") == "null" and len(schema) <= 2
160
+
161
+
162
+ def strip_null_member(schema: dict[str, Any]) -> dict[str, Any] | None:
163
+ """Return the schema without its null alternative, or None when it has none.
164
+
165
+ Handles ``type: ["string", "null"]`` and ``anyOf``/``oneOf`` compositions that include a
166
+ ``{"type": "null"}`` member. A composition left with one member collapses to that member so
167
+ the caller renders ``Optional[Member]`` rather than ``Optional[Union[Member]]``.
168
+ """
169
+ type_value = schema.get("type")
170
+ if isinstance(type_value, list) and "null" in type_value:
171
+ remaining = [item for item in type_value if item != "null"]
172
+ stripped = dict(schema)
173
+ if len(remaining) == 1:
174
+ stripped["type"] = remaining[0]
175
+ else:
176
+ stripped.pop("type")
177
+ stripped["anyOf"] = [{"type": item} for item in remaining]
178
+ return stripped
179
+ for keyword in ("anyOf", "oneOf"):
180
+ members = schema.get(keyword)
181
+ if isinstance(members, list) and any(_is_null_schema(member) for member in members):
182
+ remaining = [member for member in members if not _is_null_schema(member)]
183
+ stripped = {key: value for key, value in schema.items() if key != keyword}
184
+ if len(remaining) == 1 and isinstance(remaining[0], dict):
185
+ merged = dict(remaining[0])
186
+ for key in ("description", "title", "default"):
187
+ if key in stripped and key not in merged:
188
+ merged[key] = stripped[key]
189
+ return merged
190
+ stripped[keyword] = remaining
191
+ return stripped
192
+ return None
193
+
194
+
195
+ def is_nullable_schema(schema: Any) -> bool:
196
+ """True when the schema admits null: ``nullable: true``, ``type: "null"``, or a null member."""
197
+ if not isinstance(schema, dict):
198
+ return False
199
+ if schema.get("nullable") is True or schema.get("type") == "null":
200
+ return True
201
+ return strip_null_member(schema) is not None
202
+
203
+
151
204
  def registered_output_alias(schema: AnySchema, context: RenderContext | None = None) -> str | None:
152
205
  if not isinstance(schema, dict):
153
206
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alt-stack-openapi-pydantic
3
- Version: 1.6.3
3
+ Version: 1.6.4
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
@@ -169,3 +169,30 @@ def test_untyped_const_and_enum_render_as_literals() -> None:
169
169
  convert_schema_to_pydantic_string({"const": "only", "nullable": True})
170
170
  == "Optional[Literal['only']]"
171
171
  )
172
+
173
+
174
+ def test_null_members_render_as_optional() -> None:
175
+ assert (
176
+ convert_schema_to_pydantic_string({"anyOf": [{"type": "string"}, {"type": "null"}]})
177
+ == "Optional[Annotated[str, Field(strict=True)]]"
178
+ )
179
+ assert (
180
+ convert_schema_to_pydantic_string({"oneOf": [{"type": "integer"}, {"type": "null"}]})
181
+ == "Optional[Annotated[int, Field(strict=True)]]"
182
+ )
183
+ assert (
184
+ convert_schema_to_pydantic_string({"type": ["string", "null"]})
185
+ == "Optional[Annotated[str, Field(strict=True)]]"
186
+ )
187
+ assert (
188
+ convert_schema_to_pydantic_string(
189
+ {"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]}
190
+ )
191
+ == "Optional[Union[Annotated[str, Field(strict=True)], Annotated[int, Field(strict=True)]]]"
192
+ )
193
+ assert (
194
+ convert_schema_to_pydantic_string(
195
+ {"anyOf": [{"$ref": "#/components/schemas/User"}, {"type": "null"}]}
196
+ )
197
+ == "Optional[User]"
198
+ )
@@ -292,6 +292,43 @@ def test_optional_non_nullable_fields_reject_explicit_none() -> None:
292
292
  module.Item.model_validate({"description": None})
293
293
 
294
294
 
295
+ def test_optional_null_member_fields_accept_explicit_none() -> None:
296
+ openapi = {
297
+ "components": {
298
+ "schemas": {
299
+ "Bundle": {
300
+ "type": "object",
301
+ "properties": {
302
+ "access_token": {"type": "string"},
303
+ "refresh_token": {"anyOf": [{"type": "string"}, {"type": "null"}]},
304
+ "expires_in": {"type": ["integer", "null"]},
305
+ "scope": {"type": "string"},
306
+ },
307
+ "required": ["access_token"],
308
+ "additionalProperties": False,
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ code = openapi_to_pydantic_code(openapi)
315
+ assert "refresh_token: Optional[Annotated[str, Field(strict=True)]] = None" in code
316
+ assert "expires_in: Optional[Annotated[int, Field(strict=True)]] = None" in code
317
+ assert (
318
+ "scope: Annotated[Optional[Annotated[str, Field(strict=True)]], _omit_not_null] = None"
319
+ in code
320
+ )
321
+
322
+ module = _load_module(code)
323
+ bundle = module.Bundle.model_validate(
324
+ {"access_token": "a", "refresh_token": None, "expires_in": None}
325
+ )
326
+ assert bundle.refresh_token is None and bundle.expires_in is None
327
+ assert module.Bundle.model_validate({"access_token": "a"}).refresh_token is None
328
+ with pytest.raises(ValidationError):
329
+ module.Bundle.model_validate({"access_token": "a", "scope": None})
330
+
331
+
295
332
  def test_allof_wrapper_around_root_model_imports() -> None:
296
333
  openapi = {
297
334
  "components": {