alt-stack-openapi-pydantic 1.6.2__tar.gz → 1.6.3__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 (48) hide show
  1. {alt_stack_openapi_pydantic-1.6.2/src/alt_stack_openapi_pydantic.egg-info → alt_stack_openapi_pydantic-1.6.3}/PKG-INFO +1 -1
  2. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/pyproject.toml +1 -1
  3. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/rendering.py +4 -5
  4. alt_stack_openapi_pydantic-1.6.3/src/alt_stack_openapi_pydantic/type_render.py +38 -0
  5. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/types/boolean.py +4 -2
  6. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/types/number.py +6 -6
  7. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/types/string.py +4 -4
  8. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3/src/alt_stack_openapi_pydantic.egg-info}/PKG-INFO +1 -1
  9. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_boolean.py +6 -0
  10. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_convert_schema.py +9 -0
  11. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_number.py +25 -0
  12. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_string.py +5 -0
  13. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_to_python.py +52 -0
  14. alt_stack_openapi_pydantic-1.6.2/src/alt_stack_openapi_pydantic/type_render.py +0 -15
  15. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/LICENSE +0 -0
  16. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/README.md +0 -0
  17. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/setup.cfg +0 -0
  18. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/__init__.py +0 -0
  19. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/cli.py +0 -0
  20. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/dependencies.py +0 -0
  21. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/emission.py +0 -0
  22. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/lowering.py +0 -0
  23. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/py.typed +0 -0
  24. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/registry.py +0 -0
  25. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/routes.py +0 -0
  26. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/schema_dedup.py +0 -0
  27. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/to_python.py +0 -0
  28. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/types/__init__.py +0 -0
  29. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/types/array.py +0 -0
  30. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/types/intersection.py +0 -0
  31. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/types/object.py +0 -0
  32. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/types/types.py +0 -0
  33. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/types/union.py +0 -0
  34. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic/utils.py +0 -0
  35. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic.egg-info/SOURCES.txt +0 -0
  36. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic.egg-info/dependency_links.txt +0 -0
  37. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic.egg-info/entry_points.txt +0 -0
  38. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic.egg-info/requires.txt +0 -0
  39. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/src/alt_stack_openapi_pydantic.egg-info/top_level.txt +0 -0
  40. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_array.py +0 -0
  41. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_dependencies.py +0 -0
  42. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_generated_client.py +0 -0
  43. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_intersection.py +0 -0
  44. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_master_openapi.py +0 -0
  45. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_object.py +0 -0
  46. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_registry.py +0 -0
  47. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/tests/test_route_typing.py +0 -0
  48. {alt_stack_openapi_pydantic-1.6.2 → alt_stack_openapi_pydantic-1.6.3}/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.2
3
+ Version: 1.6.3
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.2"
3
+ version = "1.6.3"
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"
@@ -8,7 +8,7 @@ from .registry import (
8
8
  get_schema_exported_variable_name_for_primitive_type,
9
9
  get_schema_exported_variable_name_for_string_format,
10
10
  )
11
- from .type_render import format_openapi_metadata
11
+ from .type_render import format_openapi_metadata, literal_type_expr
12
12
  from .types import AnySchema
13
13
  from .types.array import convert_openapi_array_to_pydantic
14
14
  from .types.boolean import convert_openapi_boolean_to_pydantic
@@ -135,10 +135,9 @@ def schema_to_type_expr(
135
135
  )
136
136
  return wrap_nullable(expr, schema)
137
137
 
138
- if isinstance(schema.get("enum"), list):
139
- values = ", ".join(repr(value) for value in schema["enum"])
140
- expr = f"Literal[{values}]"
141
- return wrap_nullable(expr, schema)
138
+ literal = literal_type_expr(schema)
139
+ if literal is not None:
140
+ return wrap_nullable(literal, schema)
142
141
 
143
142
  return wrap_nullable("Any", schema)
144
143
 
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def format_openapi_metadata(meta: dict[str, Any]) -> str:
7
+ ordered = {key: meta[key] for key in sorted(meta)}
8
+ return f"json_schema_extra={{'openapi': {ordered!r}}}"
9
+
10
+
11
+ def wrap_annotated(base: str, metadata: list[str]) -> str:
12
+ if not metadata:
13
+ return base
14
+ parts = ", ".join(metadata)
15
+ return f"Annotated[{base}, {parts}]"
16
+
17
+
18
+ def literal_type_expr(schema: dict[str, Any]) -> str | None:
19
+ """Render ``enum`` values, or a single-value ``const``, as ``Literal[...]``.
20
+
21
+ OpenAPI 3.1 documents (and Pydantic-exported schemas) express a fixed value with
22
+ ``const``; treating it as a one-member enum keeps discriminated unions valid, because
23
+ Pydantic requires the discriminator field of every member to be a ``Literal``.
24
+ """
25
+ values = schema.get("enum")
26
+ if not isinstance(values, list):
27
+ if "const" not in schema:
28
+ return None
29
+ values = [schema["const"]]
30
+ rendered = ", ".join(repr(value) for value in values)
31
+ return f"Literal[{rendered}]"
32
+
33
+
34
+ def integer_bound(value: Any, base: str) -> Any:
35
+ """Render an integer field's bound as an ``int`` when the document spells it ``0.0``."""
36
+ if base == "int" and isinstance(value, float) and value.is_integer():
37
+ return int(value)
38
+ return value
@@ -3,11 +3,13 @@ from __future__ import annotations
3
3
  from typing import Any
4
4
 
5
5
  from ..registry import get_schema_exported_variable_name_for_primitive_type
6
- from ..type_render import wrap_annotated
6
+ from ..type_render import literal_type_expr, wrap_annotated
7
7
 
8
8
 
9
9
  def convert_openapi_boolean_to_pydantic(schema: dict[str, Any]) -> str:
10
- _ = schema
10
+ literal = literal_type_expr(schema)
11
+ if literal is not None:
12
+ return literal
11
13
  custom = get_schema_exported_variable_name_for_primitive_type("boolean")
12
14
  if custom:
13
15
  return custom
@@ -3,13 +3,13 @@ from __future__ import annotations
3
3
  from typing import Any
4
4
 
5
5
  from ..registry import get_schema_exported_variable_name_for_primitive_type
6
- from ..type_render import format_openapi_metadata, wrap_annotated
6
+ from ..type_render import format_openapi_metadata, integer_bound, literal_type_expr, wrap_annotated
7
7
 
8
8
 
9
9
  def convert_openapi_number_to_pydantic(schema: dict[str, Any]) -> str:
10
- if isinstance(schema.get("enum"), list):
11
- values = ", ".join(repr(value) for value in schema["enum"])
12
- return f"Literal[{values}]"
10
+ literal = literal_type_expr(schema)
11
+ if literal is not None:
12
+ return literal
13
13
 
14
14
  schema_type = schema.get("type")
15
15
  if schema_type in {"number", "integer", "boolean"}:
@@ -24,9 +24,9 @@ def convert_openapi_number_to_pydantic(schema: dict[str, Any]) -> str:
24
24
 
25
25
  field_args: list[str] = ["strict=True"]
26
26
  if isinstance(minimum, (int, float)):
27
- field_args.append(f"ge={minimum}")
27
+ field_args.append(f"ge={integer_bound(minimum, base)}")
28
28
  if isinstance(maximum, (int, float)):
29
- field_args.append(f"le={maximum}")
29
+ field_args.append(f"le={integer_bound(maximum, base)}")
30
30
 
31
31
  meta: dict[str, Any] = {}
32
32
  if isinstance(minimum, (int, float)):
@@ -3,13 +3,13 @@ from __future__ import annotations
3
3
  from typing import Any
4
4
 
5
5
  from ..registry import get_schema_exported_variable_name_for_string_format
6
- from ..type_render import format_openapi_metadata, wrap_annotated
6
+ from ..type_render import format_openapi_metadata, literal_type_expr, wrap_annotated
7
7
 
8
8
 
9
9
  def convert_openapi_string_to_pydantic(schema: dict[str, Any]) -> str:
10
- if isinstance(schema.get("enum"), list):
11
- values = ", ".join(repr(value) for value in schema["enum"])
12
- return f"Literal[{values}]"
10
+ literal = literal_type_expr(schema)
11
+ if literal is not None:
12
+ return literal
13
13
 
14
14
  fmt = schema.get("format")
15
15
  if isinstance(fmt, str) and fmt:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alt-stack-openapi-pydantic
3
- Version: 1.6.2
3
+ Version: 1.6.3
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
@@ -8,3 +8,9 @@ def test_boolean_basic() -> None:
8
8
  convert_openapi_boolean_to_pydantic({"type": "boolean"})
9
9
  == "Annotated[bool, Field(strict=True)]"
10
10
  )
11
+
12
+
13
+ def test_const_boolean_is_a_single_literal() -> None:
14
+ assert (
15
+ convert_openapi_boolean_to_pydantic({"type": "boolean", "const": True}) == "Literal[True]"
16
+ )
@@ -160,3 +160,12 @@ def test_string_constraints() -> None:
160
160
  )
161
161
  def test_string_formats(schema: dict, expected: str) -> None:
162
162
  assert convert_schema_to_pydantic_string(schema) == expected
163
+
164
+
165
+ def test_untyped_const_and_enum_render_as_literals() -> None:
166
+ assert convert_schema_to_pydantic_string({"const": "only"}) == "Literal['only']"
167
+ assert convert_schema_to_pydantic_string({"enum": ["a", 1]}) == "Literal['a', 1]"
168
+ assert (
169
+ convert_schema_to_pydantic_string({"const": "only", "nullable": True})
170
+ == "Optional[Literal['only']]"
171
+ )
@@ -27,6 +27,31 @@ def test_integer_enum() -> None:
27
27
  assert result == "Literal[100, 200]"
28
28
 
29
29
 
30
+ def test_const_integer_is_a_single_literal() -> None:
31
+ result = convert_openapi_number_to_pydantic({"type": "integer", "const": 1})
32
+ assert result == "Literal[1]"
33
+
34
+
35
+ def test_integer_bounds_written_as_floats_render_as_ints() -> None:
36
+ result = convert_openapi_number_to_pydantic(
37
+ {"type": "integer", "minimum": 0.0, "maximum": 10.0}
38
+ )
39
+ expected = (
40
+ "Annotated[int, Field(strict=True, ge=0, le=10, "
41
+ "json_schema_extra={'openapi': {'maximum': 10.0, 'minimum': 0.0}})]"
42
+ )
43
+ assert result == expected
44
+
45
+
46
+ def test_number_bounds_keep_their_float_form() -> None:
47
+ result = convert_openapi_number_to_pydantic({"type": "number", "minimum": 0.0})
48
+ expected = (
49
+ "Annotated[float, Field(strict=True, ge=0.0, "
50
+ "json_schema_extra={'openapi': {'minimum': 0.0}})]"
51
+ )
52
+ assert result == expected
53
+
54
+
30
55
  def test_min_max() -> None:
31
56
  result = convert_openapi_number_to_pydantic({"type": "integer", "minimum": 1, "maximum": 10})
32
57
  expected = (
@@ -23,6 +23,11 @@ def test_enum_string() -> None:
23
23
  assert result == "Literal['red', 'green']"
24
24
 
25
25
 
26
+ def test_const_string_is_a_single_literal() -> None:
27
+ result = convert_openapi_string_to_pydantic({"type": "string", "const": "plaintext"})
28
+ assert result == "Literal['plaintext']"
29
+
30
+
26
31
  def test_format_email() -> None:
27
32
  result = convert_openapi_string_to_pydantic({"type": "string", "format": "email"})
28
33
  assert (
@@ -1006,3 +1006,55 @@ def test_intersection_helper_is_inlined_only_when_used() -> None:
1006
1006
 
1007
1007
  plain = openapi_to_pydantic_code({"components": {"schemas": {}}, "paths": {}})
1008
1008
  assert "def all_of" not in plain
1009
+
1010
+
1011
+ def test_const_discriminators_generate_a_valid_discriminated_union() -> None:
1012
+ openapi = {
1013
+ "components": {
1014
+ "schemas": {
1015
+ "Plaintext": {
1016
+ "type": "object",
1017
+ "additionalProperties": False,
1018
+ "properties": {
1019
+ "mode": {"type": "string", "const": "plaintext"},
1020
+ "v": {"type": "integer", "const": 1},
1021
+ "data": {"type": "string"},
1022
+ },
1023
+ "required": ["mode", "v", "data"],
1024
+ },
1025
+ "Encrypted": {
1026
+ "type": "object",
1027
+ "additionalProperties": False,
1028
+ "properties": {
1029
+ "mode": {"type": "string", "const": "kms"},
1030
+ "v": {"type": "integer", "const": 1},
1031
+ "ct": {"type": "string"},
1032
+ },
1033
+ "required": ["mode", "v", "ct"],
1034
+ },
1035
+ "Envelope": {
1036
+ "oneOf": [
1037
+ {"$ref": "#/components/schemas/Plaintext"},
1038
+ {"$ref": "#/components/schemas/Encrypted"},
1039
+ ],
1040
+ "discriminator": {
1041
+ "propertyName": "mode",
1042
+ "mapping": {
1043
+ "plaintext": "#/components/schemas/Plaintext",
1044
+ "kms": "#/components/schemas/Encrypted",
1045
+ },
1046
+ },
1047
+ },
1048
+ }
1049
+ },
1050
+ "paths": {},
1051
+ }
1052
+
1053
+ module = _load_module(openapi_to_pydantic_code(openapi))
1054
+
1055
+ envelope = module.Envelope.model_validate({"mode": "plaintext", "v": 1, "data": "x"})
1056
+ assert isinstance(envelope.root, module.Plaintext)
1057
+ with pytest.raises(ValidationError):
1058
+ module.Envelope.model_validate({"mode": "other", "v": 1, "data": "x"})
1059
+ with pytest.raises(ValidationError):
1060
+ module.Plaintext.model_validate({"mode": "plaintext", "v": 2, "data": "x"})
@@ -1,15 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any
4
-
5
-
6
- def format_openapi_metadata(meta: dict[str, Any]) -> str:
7
- ordered = {key: meta[key] for key in sorted(meta)}
8
- return f"json_schema_extra={{'openapi': {ordered!r}}}"
9
-
10
-
11
- def wrap_annotated(base: str, metadata: list[str]) -> str:
12
- if not metadata:
13
- return base
14
- parts = ", ".join(metadata)
15
- return f"Annotated[{base}, {parts}]"