reflectapi-runtime 0.19.0__tar.gz → 0.20.0__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 (38) hide show
  1. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/PKG-INFO +17 -10
  2. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/README.md +9 -6
  3. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/pyproject.toml +8 -4
  4. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/__init__.py +9 -2
  5. reflectapi_runtime-0.20.0/src/reflectapi_runtime/serde.py +141 -0
  6. reflectapi_runtime-0.20.0/tests/test_enum_forward_compatibility.py +129 -0
  7. reflectapi_runtime-0.19.0/src/reflectapi_runtime/serde.py +0 -48
  8. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/.gitignore +0 -0
  9. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/auth.py +0 -0
  10. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/batch.py +0 -0
  11. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/client.py +0 -0
  12. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/duration.py +0 -0
  13. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/exceptions.py +0 -0
  14. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
  15. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/middleware.py +0 -0
  16. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/partial.py +0 -0
  17. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/response.py +0 -0
  18. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/sse.py +0 -0
  19. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/streaming.py +0 -0
  20. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/testing.py +0 -0
  21. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/transport.py +0 -0
  22. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/src/reflectapi_runtime/types.py +0 -0
  23. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/__init__.py +0 -0
  24. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_auth.py +0 -0
  25. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_auth_negative_cases.py +0 -0
  26. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_batch.py +0 -0
  27. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_client.py +0 -0
  28. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_codegen_regressions.py +0 -0
  29. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_edge_cases.py +0 -0
  30. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_enhanced_features.py +0 -0
  31. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_exceptions.py +0 -0
  32. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_middleware.py +0 -0
  33. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_partial.py +0 -0
  34. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_pydantic_serialization.py +0 -0
  35. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_response.py +0 -0
  36. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_sse.py +0 -0
  37. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_streaming.py +0 -0
  38. {reflectapi_runtime-0.19.0 → reflectapi_runtime-0.20.0}/tests/test_testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: reflectapi-runtime
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: Runtime library for ReflectAPI Python clients
5
5
  Project-URL: Homepage, https://github.com/thepartly/reflectapi
6
6
  Project-URL: Repository, https://github.com/thepartly/reflectapi
@@ -19,9 +19,13 @@ Classifier: Topic :: Internet :: WWW/HTTP
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.12
22
- Requires-Dist: httpx>=0.25.0
23
- Requires-Dist: pydantic>=2.0.0
24
- Requires-Dist: typing-extensions>=4.4.0
22
+ Requires-Dist: anyio>=4.14.2
23
+ Requires-Dist: httpcore>=1.0.9
24
+ Requires-Dist: httpx>=0.25.2
25
+ Requires-Dist: idna>=3.15
26
+ Requires-Dist: pydantic>=2.10.0; python_version < '3.14'
27
+ Requires-Dist: pydantic>=2.12.0; python_version >= '3.14'
28
+ Requires-Dist: typing-extensions>=4.5.0
25
29
  Provides-Extra: dev
26
30
  Requires-Dist: hypothesis>=6.0.0; extra == 'dev'
27
31
  Requires-Dist: mypy>=1.5.0; extra == 'dev'
@@ -81,12 +85,15 @@ against the declared item model. The wire format is Server-Sent Events
81
85
  do not enter the iterator. Breaking out of the loop or calling
82
86
  `stream.aclose()` releases the underlying HTTP connection.
83
87
 
84
- Validation is strict: an event whose payload doesn't match the item model
85
- (for example, an unknown discriminated-union variant added by a newer
86
- server) raises `ValidationError` mid-stream and the iterator terminates.
87
- Items received before the bad event are still delivered.
88
+ Events are validated against the generated model. Closed response enums reject
89
+ unfamiliar variants; enums declared with `#[reflectapi(open)]` retain them as
90
+ an unknown case with the tag and parsed JSON, and `#[serde(other)]` uses the
91
+ declared fallback. A malformed known variant raises `ValidationError`
92
+ mid-stream and terminates the iterator. Earlier items remain delivered.
88
93
 
89
94
  ## Compatibility
90
95
 
91
- Python 3.12+. The package targets the same `reflectapi` minor version as
92
- the schema you generate from; mismatches will surface at import time.
96
+ Python 3.12+. SDKs generated with ReflectAPI 0.20 require the 0.20 runtime.
97
+ Pydantic 2.10 or newer supports the generated generic models; Python 3.14
98
+ requires Pydantic 2.12 or newer. Installing the runtime resolves these requirements
99
+ and the supported HTTP dependencies automatically.
@@ -44,12 +44,15 @@ against the declared item model. The wire format is Server-Sent Events
44
44
  do not enter the iterator. Breaking out of the loop or calling
45
45
  `stream.aclose()` releases the underlying HTTP connection.
46
46
 
47
- Validation is strict: an event whose payload doesn't match the item model
48
- (for example, an unknown discriminated-union variant added by a newer
49
- server) raises `ValidationError` mid-stream and the iterator terminates.
50
- Items received before the bad event are still delivered.
47
+ Events are validated against the generated model. Closed response enums reject
48
+ unfamiliar variants; enums declared with `#[reflectapi(open)]` retain them as
49
+ an unknown case with the tag and parsed JSON, and `#[serde(other)]` uses the
50
+ declared fallback. A malformed known variant raises `ValidationError`
51
+ mid-stream and terminates the iterator. Earlier items remain delivered.
51
52
 
52
53
  ## Compatibility
53
54
 
54
- Python 3.12+. The package targets the same `reflectapi` minor version as
55
- the schema you generate from; mismatches will surface at import time.
55
+ Python 3.12+. SDKs generated with ReflectAPI 0.20 require the 0.20 runtime.
56
+ Pydantic 2.10 or newer supports the generated generic models; Python 3.14
57
+ requires Pydantic 2.12 or newer. Installing the runtime resolves these requirements
58
+ and the supported HTTP dependencies automatically.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "reflectapi-runtime"
7
- version = "0.19.0"
7
+ version = "0.20.0"
8
8
  description = "Runtime library for ReflectAPI Python clients"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -26,9 +26,13 @@ classifiers = [
26
26
  "Typing :: Typed",
27
27
  ]
28
28
  dependencies = [
29
- "pydantic>=2.0.0",
30
- "httpx>=0.25.0",
31
- "typing-extensions>=4.4.0",
29
+ "pydantic>=2.10.0; python_version < '3.14'",
30
+ "pydantic>=2.12.0; python_version >= '3.14'",
31
+ "httpx>=0.25.2",
32
+ "httpcore>=1.0.9",
33
+ "anyio>=4.14.2",
34
+ "idna>=3.15",
35
+ "typing-extensions>=4.5.0",
32
36
  ]
33
37
 
34
38
  [project.optional-dependencies]
@@ -39,7 +39,12 @@ from .hypothesis_strategies import (
39
39
  from .middleware import AsyncMiddleware
40
40
  from .partial import ReflectapiPartialModel
41
41
  from .response import ApiResponse, ApiStream, AsyncApiStream, TransportMetadata
42
- from .serde import parse_externally_tagged, serialize_externally_tagged
42
+ from .serde import (
43
+ UnknownEnumValue,
44
+ classify_tagged_enum,
45
+ parse_externally_tagged,
46
+ serialize_externally_tagged,
47
+ )
43
48
  from .streaming import AsyncStreamingClient, StreamingResponse
44
49
  from .testing import (
45
50
  AsyncCassetteMiddleware,
@@ -50,7 +55,7 @@ from .testing import (
50
55
  )
51
56
  from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible
52
57
 
53
- __version__ = "0.19.0"
58
+ __version__ = "0.20.0"
54
59
 
55
60
  __all__ = [
56
61
  # Authentication
@@ -93,6 +98,8 @@ __all__ = [
93
98
  "ReflectapiPartialModel",
94
99
  "parse_externally_tagged",
95
100
  "serialize_externally_tagged",
101
+ "UnknownEnumValue",
102
+ "classify_tagged_enum",
96
103
  "StreamingResponse",
97
104
  "ApiStream",
98
105
  "AsyncApiStream",
@@ -0,0 +1,141 @@
1
+ """Serialization helpers used by generated ReflectAPI clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, model_serializer, model_validator
9
+
10
+ VariantHandler = Callable[[Any], Any] | str
11
+ VariantSerializers = dict[str, tuple[Callable[[Any], bool], Callable[[Any], Any]]]
12
+
13
+
14
+ class UnknownEnumValue(BaseModel):
15
+ """An output enum case whose wire tag was not in the generated schema.
16
+
17
+ Generated clients construct this after checking the wire discriminator. A
18
+ raw object cannot be validated directly as this case, which keeps malformed
19
+ known cases from silently falling through a Pydantic union.
20
+ """
21
+
22
+ tag: str
23
+ raw: Any
24
+
25
+ @model_validator(mode="before")
26
+ @classmethod
27
+ def _instance_only(cls, value: Any) -> Any:
28
+ if not isinstance(value, cls):
29
+ raise ValueError("Unknown enum cases are created from unfamiliar wire tags")
30
+ return value
31
+
32
+ @model_serializer(mode="plain")
33
+ def _serialize_raw(self) -> Any:
34
+ return self.raw
35
+
36
+
37
+ def classify_tagged_enum(
38
+ data: Any,
39
+ known_tags: frozenset[str],
40
+ *,
41
+ representation: str,
42
+ tag_field: str = "",
43
+ content_field: str = "",
44
+ fallback_tag: str | None = None,
45
+ unknown_type: type[UnknownEnumValue] | None = None,
46
+ unit_tags: frozenset[str] = frozenset(),
47
+ ) -> Any:
48
+ """Classify an unfamiliar valid tag without masking other validation errors."""
49
+ if isinstance(data, UnknownEnumValue):
50
+ return data
51
+ if representation == "external":
52
+ if isinstance(data, str):
53
+ tag = data
54
+ elif isinstance(data, dict) and len(data) == 1:
55
+ tag = next(iter(data))
56
+ else:
57
+ return data
58
+ elif isinstance(data, dict):
59
+ if tag_field not in data or not isinstance(data[tag_field], str):
60
+ raise ValueError("Tagged enum requires a string discriminator")
61
+ tag = data[tag_field]
62
+ else:
63
+ return data
64
+ if isinstance(tag, str) and tag in unit_tags and isinstance(data, dict):
65
+ if representation == "adjacent" and data.get(content_field) is not None:
66
+ raise ValueError("Unit variant cannot carry non-null content")
67
+ if representation == "external":
68
+ if data[tag] is not None:
69
+ raise ValueError("Unit variant cannot carry non-null content")
70
+ return tag
71
+ if not isinstance(tag, str) or tag in known_tags:
72
+ return data
73
+ if (
74
+ representation == "adjacent"
75
+ and unknown_type is None
76
+ and fallback_tag is not None
77
+ and data.get(content_field) is not None
78
+ ):
79
+ raise ValueError("Fallback unit variant cannot carry non-null content")
80
+ if unknown_type is not None:
81
+ return unknown_type.model_construct(tag=tag, raw=data)
82
+ if fallback_tag is not None:
83
+ if representation == "external":
84
+ return fallback_tag
85
+ return {tag_field: fallback_tag}
86
+ return data
87
+
88
+
89
+ def parse_externally_tagged(
90
+ data: Any,
91
+ variants: dict[str, VariantHandler],
92
+ types: tuple[type[Any], ...],
93
+ enum_name: str,
94
+ *,
95
+ fallback_tag: str | None = None,
96
+ unknown_type: type[UnknownEnumValue] | None = None,
97
+ strict_unit: bool = False,
98
+ ) -> Any:
99
+ """Parse an externally tagged enum from ``{"Variant": value}`` format."""
100
+ if types and isinstance(data, types):
101
+ return data
102
+ data = classify_tagged_enum(
103
+ data,
104
+ frozenset(variants),
105
+ representation="external",
106
+ fallback_tag=fallback_tag,
107
+ unknown_type=unknown_type,
108
+ )
109
+ if isinstance(data, UnknownEnumValue):
110
+ return data
111
+ if isinstance(data, str) and data in variants:
112
+ handler = variants[data]
113
+ if handler == "_unit":
114
+ return data
115
+ if isinstance(data, dict):
116
+ if len(data) != 1:
117
+ raise ValueError("Externally tagged enum must have exactly one key")
118
+ key, value = next(iter(data.items()))
119
+ if key in variants:
120
+ handler = variants[key]
121
+ if handler == "_unit":
122
+ if strict_unit and value is not None:
123
+ raise ValueError(f"Unit variant {key} cannot carry a payload")
124
+ return key
125
+ if callable(handler):
126
+ return handler(value)
127
+ raise ValueError(f"Unknown variant for {enum_name}: {data}")
128
+
129
+
130
+ def serialize_externally_tagged(
131
+ root: Any,
132
+ serializers: VariantSerializers,
133
+ enum_name: str,
134
+ ) -> Any:
135
+ """Serialize an externally tagged enum to ``{"Variant": value}`` format."""
136
+ if isinstance(root, UnknownEnumValue):
137
+ return root.raw
138
+ for _variant_name, (check, serialize) in serializers.items():
139
+ if check(root):
140
+ return serialize(root)
141
+ raise ValueError(f"Cannot serialize {enum_name} variant: {type(root)}")
@@ -0,0 +1,129 @@
1
+ """The runtime must preserve future response cases without hiding bad known cases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ import pytest
8
+ from pydantic import BaseModel, RootModel, ValidationError, model_validator
9
+
10
+ from reflectapi_runtime import (
11
+ UnknownEnumValue,
12
+ classify_tagged_enum,
13
+ parse_externally_tagged,
14
+ serialize_externally_tagged,
15
+ )
16
+
17
+
18
+ class Known(BaseModel):
19
+ kind: Literal["Known"]
20
+ value: int
21
+
22
+
23
+ class FutureUnknown(UnknownEnumValue):
24
+ pass
25
+
26
+
27
+ class OpenResult(RootModel[Known | FutureUnknown]):
28
+ root: Known | FutureUnknown
29
+
30
+ @model_validator(mode="before")
31
+ @classmethod
32
+ def _classify(cls, data):
33
+ return classify_tagged_enum(
34
+ data,
35
+ frozenset(("Known",)),
36
+ representation="tagged",
37
+ tag_field="kind",
38
+ unknown_type=FutureUnknown,
39
+ )
40
+
41
+
42
+ class Envelope(BaseModel):
43
+ results: list[OpenResult]
44
+
45
+
46
+ def test_open_case_round_trips_inside_nested_model():
47
+ wire = {"results": [{"kind": "Known", "value": 2}, {"kind": "Future", "x": [1]}]}
48
+ parsed = Envelope.model_validate(wire)
49
+ assert isinstance(parsed.results[0].root, Known)
50
+ unknown = parsed.results[1].root
51
+ assert isinstance(unknown, FutureUnknown)
52
+ assert unknown.tag == "Future"
53
+ assert unknown.raw == wire["results"][1]
54
+ assert parsed.model_dump() == wire
55
+ assert Envelope.model_validate_json(parsed.model_dump_json()).model_dump() == wire
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ "value",
60
+ [
61
+ {"kind": "Known", "value": "not an integer"},
62
+ {"value": 2},
63
+ {"kind": 42, "value": 2},
64
+ {"kind": None, "value": 2},
65
+ ],
66
+ )
67
+ def test_open_case_rejects_malformed_known_or_missing_tag(value):
68
+ with pytest.raises(ValidationError):
69
+ OpenResult.model_validate(value)
70
+
71
+
72
+ def test_other_fallback_discards_unfamiliar_payload():
73
+ assert classify_tagged_enum(
74
+ {"kind": "Future", "payload": {"x": 1}},
75
+ frozenset(("Known", "Other")),
76
+ representation="tagged",
77
+ tag_field="kind",
78
+ fallback_tag="Other",
79
+ ) == {"kind": "Other"}
80
+ assert classify_tagged_enum(
81
+ {"kind": "Known", "value": "bad"},
82
+ frozenset(("Known", "Other")),
83
+ representation="tagged",
84
+ tag_field="kind",
85
+ fallback_tag="Other",
86
+ ) == {"kind": "Known", "value": "bad"}
87
+
88
+
89
+ def test_external_unknown_string_and_single_key_object():
90
+ for wire in ("Future", {"Future": {"x": 1}}):
91
+ unknown = parse_externally_tagged(
92
+ wire,
93
+ {"Known": "_unit"},
94
+ (),
95
+ "Result",
96
+ unknown_type=FutureUnknown,
97
+ )
98
+ assert isinstance(unknown, FutureUnknown)
99
+ assert unknown.tag == "Future"
100
+ assert serialize_externally_tagged(unknown, {}, "Result") == wire
101
+
102
+
103
+ def test_external_unit_object_normalizes_to_known_literal():
104
+ assert classify_tagged_enum(
105
+ {"Known": None},
106
+ frozenset(("Known",)),
107
+ representation="external",
108
+ unit_tags=frozenset(("Known",)),
109
+ ) == "Known"
110
+ for payload in (1, {"bad": True}):
111
+ with pytest.raises(ValueError):
112
+ classify_tagged_enum(
113
+ {"Known": payload},
114
+ frozenset(("Known",)),
115
+ representation="external",
116
+ unit_tags=frozenset(("Known",)),
117
+ )
118
+
119
+
120
+ @pytest.mark.parametrize("wire", [{"A": 1, "B": 2}, {42: "value"}, None, 42])
121
+ def test_external_malformed_tags_do_not_become_unknown(wire):
122
+ with pytest.raises(ValueError):
123
+ parse_externally_tagged(
124
+ wire,
125
+ {"Known": "_unit"},
126
+ (),
127
+ "Result",
128
+ unknown_type=FutureUnknown,
129
+ )
@@ -1,48 +0,0 @@
1
- """Serialization helpers used by generated ReflectAPI clients."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Callable
6
- from typing import Any
7
-
8
-
9
- VariantHandler = Callable[[Any], Any] | str
10
- VariantSerializers = dict[str, tuple[Callable[[Any], bool], Callable[[Any], Any]]]
11
-
12
-
13
- def parse_externally_tagged(
14
- data: Any,
15
- variants: dict[str, VariantHandler],
16
- types: tuple[type[Any], ...],
17
- enum_name: str,
18
- ) -> Any:
19
- """Parse an externally tagged enum from ``{"Variant": value}`` format."""
20
- if types and isinstance(data, types):
21
- return data
22
- if isinstance(data, str) and data in variants:
23
- handler = variants[data]
24
- if handler == "_unit":
25
- return data
26
- if isinstance(data, dict):
27
- if len(data) != 1:
28
- raise ValueError("Externally tagged enum must have exactly one key")
29
- key, value = next(iter(data.items()))
30
- if key in variants:
31
- handler = variants[key]
32
- if handler == "_unit":
33
- return key
34
- if callable(handler):
35
- return handler(value)
36
- raise ValueError(f"Unknown variant for {enum_name}: {data}")
37
-
38
-
39
- def serialize_externally_tagged(
40
- root: Any,
41
- serializers: VariantSerializers,
42
- enum_name: str,
43
- ) -> Any:
44
- """Serialize an externally tagged enum to ``{"Variant": value}`` format."""
45
- for _variant_name, (check, serialize) in serializers.items():
46
- if check(root):
47
- return serialize(root)
48
- raise ValueError(f"Cannot serialize {enum_name} variant: {type(root)}")