fastapi-streams 0.3.0__tar.gz → 0.3.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 (137) hide show
  1. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/CHANGELOG.md +35 -0
  2. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/PKG-INFO +1 -1
  3. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/docs/clients.md +9 -0
  4. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/docs/codegen.md +20 -0
  5. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/docs/development.md +29 -0
  6. {fastapi_streams-0.3.0/fastapi_streams/codegen/templates/python → fastapi_streams-0.3.1/examples/agent_runs/generated_python}/runtime.py +29 -31
  7. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/__init__.py +1 -1
  8. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/cli.py +65 -6
  9. {fastapi_streams-0.3.0/examples/agent_runs/generated_python → fastapi_streams-0.3.1/fastapi_streams/codegen/templates/python}/runtime.py +29 -31
  10. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/pyproject.toml +1 -1
  11. fastapi_streams-0.3.1/scripts/benchmark_runtime.mjs +93 -0
  12. fastapi_streams-0.3.1/scripts/benchmark_runtime.py +293 -0
  13. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/scripts/smoke_package.py +18 -7
  14. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_structured_diagnostics.py +67 -0
  15. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_unknown_events.py +50 -0
  16. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated_python/runtime.py +29 -31
  17. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/.gitattributes +0 -0
  18. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/.github/workflows/ci.yml +0 -0
  19. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/.github/workflows/compatibility.yml +0 -0
  20. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/.github/workflows/release.yml +0 -0
  21. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/.gitignore +0 -0
  22. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/.pre-commit-config.yaml +0 -0
  23. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/AGENTS.md +0 -0
  24. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/LICENSE +0 -0
  25. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/README.md +0 -0
  26. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/docs/README.md +0 -0
  27. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/docs/architecture.md +0 -0
  28. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/docs/native-transports.md +0 -0
  29. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/docs/quickstart.md +0 -0
  30. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/docs/releasing.md +0 -0
  31. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/__init__.py +0 -0
  32. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/__init__.py +0 -0
  33. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/consume.py +0 -0
  34. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/consume.ts +0 -0
  35. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/errors.py +0 -0
  36. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/errors.ts +0 -0
  37. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated/.fastapi-streams.json +0 -0
  38. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated/agent-runs/api.ts +0 -0
  39. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated/agent-runs/index.ts +0 -0
  40. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated/client.ts +0 -0
  41. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated/index.ts +0 -0
  42. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated/transport/errors.ts +0 -0
  43. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated/transport/sse.ts +0 -0
  44. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated/types.ts +0 -0
  45. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated_python/.fastapi-streams.json +0 -0
  46. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated_python/__init__.py +0 -0
  47. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated_python/agent_runs/__init__.py +0 -0
  48. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated_python/agent_runs/api.py +0 -0
  49. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated_python/client.py +0 -0
  50. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated_python/models.py +0 -0
  51. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/agent_runs/generated_python/py.typed +0 -0
  52. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/native_proxy.py +0 -0
  53. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/examples/rest_codegen.py +0 -0
  54. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/__init__.py +0 -0
  55. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/diagnostics.py +0 -0
  56. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/documentation.py +0 -0
  57. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/enums.py +0 -0
  58. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/ir.py +0 -0
  59. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/names.py +0 -0
  60. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/openapi.py +0 -0
  61. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/python.py +0 -0
  62. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/schemas/LICENSE +0 -0
  63. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/schemas/README.md +0 -0
  64. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/schemas/openapi-3.2.json +0 -0
  65. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/templates/errors.ts +0 -0
  66. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/templates/python/api.py.j2 +0 -0
  67. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/templates/python/client.py.j2 +0 -0
  68. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/templates/sse.ts +0 -0
  69. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/templates/typescript/api.ts.j2 +0 -0
  70. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/templates/typescript/client.ts.j2 +0 -0
  71. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/templating.py +0 -0
  72. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/typescript.py +0 -0
  73. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/codegen/writer.py +0 -0
  74. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/py.typed +0 -0
  75. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/fastapi_streams/router.py +0 -0
  76. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/package-lock.json +0 -0
  77. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/package.json +0 -0
  78. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/scripts/check_package.py +0 -0
  79. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/scripts/check_release.py +0 -0
  80. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/scripts/codegen.py +0 -0
  81. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/scripts/test_compat.py +0 -0
  82. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/__init__.py +0 -0
  83. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/helpers.py +0 -0
  84. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_additional_properties.py +0 -0
  85. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_codegen.py +0 -0
  86. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_contract_boundaries.py +0 -0
  87. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_diagnostics.py +0 -0
  88. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_discriminators.py +0 -0
  89. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_documentation.py +0 -0
  90. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_error_contracts.py +0 -0
  91. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_field_names.py +0 -0
  92. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_http_backends.py +0 -0
  93. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_intersections.py +0 -0
  94. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_literal_fields.py +0 -0
  95. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_native_contract.py +0 -0
  96. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_nested_models.py +0 -0
  97. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_python_client.py +0 -0
  98. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_python_typing.py +0 -0
  99. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_request_validation.py +0 -0
  100. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_required_properties.py +0 -0
  101. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_scalar_constraints.py +0 -0
  102. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_schema_matrix.py +0 -0
  103. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/codegen/test_stream_validation_details.py +0 -0
  104. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/contracts/test_openapi.py +0 -0
  105. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/README.md +0 -0
  106. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/__init__.py +0 -0
  107. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/agent_runs.openapi.json +0 -0
  108. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/agent_runs.typescript.diagnostics.json +0 -0
  109. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/compat-client.ts +0 -0
  110. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated/.fastapi-streams.json +0 -0
  111. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated/client.ts +0 -0
  112. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated/fixtures/api.ts +0 -0
  113. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated/fixtures/index.ts +0 -0
  114. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated/index.ts +0 -0
  115. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated/transport/errors.ts +0 -0
  116. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated/transport/sse.ts +0 -0
  117. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated/types.ts +0 -0
  118. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated_python/.fastapi-streams.json +0 -0
  119. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated_python/__init__.py +0 -0
  120. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated_python/client.py +0 -0
  121. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated_python/fixtures/__init__.py +0 -0
  122. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated_python/fixtures/api.py +0 -0
  123. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated_python/models.py +0 -0
  124. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/generated_python/py.typed +0 -0
  125. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/integration_app.py +0 -0
  126. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/models.openapi.json +0 -0
  127. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/fixtures/models_app.py +0 -0
  128. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/integration/test_end_to_end.py +0 -0
  129. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/router/test_router.py +0 -0
  130. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/test_release.py +0 -0
  131. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/typescript/errors.test.ts +0 -0
  132. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/typescript/live-client.ts +0 -0
  133. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/typescript/models.test.ts +0 -0
  134. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/typescript/narrowing.ts +0 -0
  135. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/typescript/sse.test.ts +0 -0
  136. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/typescript/tsconfig.json +0 -0
  137. {fastapi_streams-0.3.0 → fastapi_streams-0.3.1}/tests/typing/client_usage.py +0 -0
@@ -3,6 +3,41 @@
3
3
  All notable changes and consumer upgrade steps are recorded here. Entries marked
4
4
  unreleased describe prepared source versions that have not been published.
5
5
 
6
+ ## 0.3.1 - 2026-10-18
7
+
8
+ ### Added
9
+
10
+ - Repeatable Python and Node benchmarks for generated SDK request preparation,
11
+ decoding/validation, SSE framing, complete consumption, fragmented chunks and
12
+ Python unknown-event ignoring. Reports include individual timing samples and
13
+ optional separate Python peak-memory measurements; no CI timing thresholds.
14
+ - Add generation provenance and validation capabilities to version 1 diagnostic
15
+ reports: canonical schema SHA-256, generator version, target/HTTP backend and
16
+ request/event/error validation phases. Existing diagnostic fields, IDs and
17
+ exact reviewed allowances remain unchanged. Additional report object keys are
18
+ additive; the metadata does not introduce runtime checks or full JSON Schema
19
+ guarantees.
20
+
21
+ ### Performance
22
+
23
+ - Reuse the JSON-value adapter for skipped unknown events and HTTP-error JSON
24
+ decoding. Cache top-level discriminator metadata per generated event adapter
25
+ instead of rebuilding its JSON Schema for every ignore-policy stream.
26
+ - Prepare sanitized validation details only for errors that are actually raised.
27
+ Known events still use direct Pydantic JSON validation; malformed known events,
28
+ missing/non-string tags, invalid JSON and unknown nested variants still fail.
29
+ Public signatures, coercion, validation timing and wire encoding are unchanged.
30
+
31
+ ### Upgrade from 0.3.0
32
+
33
+ 1. Upgrade the generator to 0.3.1 and regenerate with the existing HTTP backend
34
+ to obtain the Python runtime improvements. Existing generated SDKs keep working.
35
+ 2. Diagnostic-report readers should tolerate the new `generation` and `validation`
36
+ keys. Existing exact diagnostic allowances need no migration.
37
+ 3. Continue validating server requests and domain invariants at their own
38
+ boundaries; consumers should reuse parsed events instead of validating the
39
+ same schema again. TypeScript remains static-only with no added dependency.
40
+
6
41
  ## 0.3.0 - 2026-10-18
7
42
 
8
43
  ### Breaking changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fastapi-streams
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Typed FastAPI streams with generated TypeScript and Python clients.
5
5
  Project-URL: Documentation, https://github.com/mathisarends/canon/tree/main/packages/fastapi-streams/docs
6
6
  Project-URL: Source, https://github.com/mathisarends/canon/tree/main/packages/fastapi-streams
@@ -121,6 +121,15 @@ remain ordinary members of the user-defined union.
121
121
 
122
122
  ## HTTP error contracts
123
123
 
124
+ Keep schema validation at transport boundaries. Python prepares and validates
125
+ requests at the method call and validates received events during iteration.
126
+ Pass those parsed event models onward instead of serializing them to dictionaries
127
+ and validating the same schema again. Server request validation remains necessary
128
+ regardless of SDK checks; authentication, authorization and domain invariants have
129
+ separate responsibilities. Canon composition does not need an additional event
130
+ validator around Streams. TypeScript keeps its documented static-only behavior;
131
+ applications needing runtime event checks can validate once before using a value.
132
+
124
133
  Python request parameters and JSON bodies are validated when calling the method;
125
134
  catch Pydantic `ValidationError` around that call. Network and event errors occur
126
135
  while iterating. See the [validation matrix and CI policy](codegen.md#validation-support-matrix).
@@ -191,6 +191,26 @@ including inline schemas and referenced component definitions. Shared definition
191
191
  identify each affected operation; unused components do not generate warnings.
192
192
  SSE framing metadata is outside generated event-model validation.
193
193
 
194
+ The version 1 report also carries additive `generation` and `validation` objects.
195
+ Consumers should tolerate additional object keys. `generation` records the
196
+ generator name/version, target, selected Python HTTP library (null for TypeScript)
197
+ and `schema_sha256`. The hash covers the complete parsed OpenAPI document,
198
+ including unused definitions. Its `canonical-json-v1` encoding is Python
199
+ `json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False)`
200
+ encoded as UTF-8. Whitespace and object-key order in the input file do not affect
201
+ the hash; array order and values do. It is a content identifier, not a signature
202
+ or proof of schema enforcement.
203
+
204
+ `validation` distinguishes parameter/body validation at `method_call`, event
205
+ decoding at `iteration` and HTTP-error handling at `consumer_controlled`.
206
+ TypeScript parameter/body schema checks are `not_performed`. Each boundary has
207
+ a `schema_validation` boolean, with `json_decoding: true` for events. Python's
208
+ `runtime_scope` is `generated_types_and_supported_constraints`; TypeScript's is
209
+ `json_decoding_only`. Both set `full_json_schema: false`. These target capabilities
210
+ describe supported boundaries, even if an operation has no parameters or body;
211
+ source-specific limitations remain in `diagnostics` with their accepted status.
212
+ The report is generated only when requested and adds no SDK runtime checks.
213
+
194
214
  `unsupported-constraint` identifies an unenforced rule for the selected target;
195
215
  TypeScript also diagnoses Python-supported scalar constraints because TypeScript
196
216
  has no runtime schema validator. Python's `optional-discriminator` diagnoses event
@@ -88,6 +88,35 @@ outside the checkout they invoke the installed `fastapi-streams` command, exerci
88
88
  and consume an event through the generated Python SDK. Metadata and `__version__`
89
89
  must match the release version in `pyproject.toml`.
90
90
 
91
+ ## Generated SDK performance
92
+
93
+ From the workspace root, use the locked environment and installed Node tooling:
94
+
95
+ ```console
96
+ uv run --locked python packages/fastapi-streams/scripts/benchmark_runtime.py --memory --output dist/streams-baseline.json
97
+ ```
98
+
99
+ The harness generates both SDKs from the same small discriminated-union contract.
100
+ It measures request preparation separately from decoding, framing and complete
101
+ HTTP/SSE consumption with an in-memory HTTPX transport or Fetch response. Cases
102
+ cover small token events, nested batches, seven-byte fragments and Python's
103
+ unknown-event ignore policy. Python validates generated models; TypeScript only
104
+ decodes JSON. Their decoding results do not represent equivalent guarantees.
105
+
106
+ Reports record source location, generator/runtime versions, platform, payload
107
+ sizes, individual samples, median CPU/wall time per request or input event, and
108
+ throughput. Ignore-policy throughput counts skipped input events too. `--memory`
109
+ adds a separate Python traced peak-memory sample; it does not instrument timings
110
+ or count total allocations. There are three warmup iterations per case. These
111
+ are warmed batch measurements, not individual-event latency percentiles, cold
112
+ startup measurements, real-network results or Hermes deployment evidence.
113
+
114
+ Use `--runtime python` or `--runtime typescript` for focused runs. Adjust
115
+ `--events`, `--iterations` and `--rounds` for longer samples. For a comparison,
116
+ run the same harness with `--source-dir` pointing to the other revision's Streams
117
+ package directory, keeping the interpreter, dependencies and arguments fixed.
118
+ Repeat runs to assess noise; ordinary CI does not enforce timing thresholds.
119
+
91
120
  ## Supported scope and roadmap
92
121
 
93
122
  GET/POST JSON SSE data; scalar path and header parameters; scalar/array query parameters;
@@ -5,12 +5,15 @@ import codecs
5
5
  from collections.abc import AsyncGenerator, Mapping, Sequence
6
6
  from dataclasses import dataclass
7
7
  from enum import Enum
8
+ from functools import cache
8
9
  from typing import Literal, cast
9
10
 
10
11
  import httpx
11
12
  from pydantic import JsonValue, TypeAdapter, ValidationError
12
13
  from pydantic_core import ErrorDetails
13
14
 
15
+ _JSON_ADAPTER = TypeAdapter[JsonValue](JsonValue)
16
+
14
17
 
15
18
  class TransportError(Exception):
16
19
  pass
@@ -180,19 +183,11 @@ class Transport:
180
183
  options: RequestOptions | None = None,
181
184
  ) -> AsyncGenerator[T, None]:
182
185
  discriminator: str | None = None
183
- known_tags: set[str] = set()
186
+ known_tags: frozenset[str] = frozenset()
184
187
  if options and options.unknown_events not in {"error", "ignore"}:
185
188
  raise ValueError("unknown_events must be 'error' or 'ignore'")
186
189
  if options and options.unknown_events == "ignore":
187
- schema = adapter.json_schema()
188
- policy = schema.get("discriminator", {})
189
- discriminator = policy.get("propertyName")
190
- known_tags = set(policy.get("mapping", {}))
191
- if not isinstance(discriminator, str) or not known_tags:
192
- raise ValueError(
193
- "unknown_events='ignore' requires a top-level "
194
- "discriminated event union"
195
- )
190
+ discriminator, known_tags = _unknown_event_policy(adapter)
196
191
  request_headers = self.headers.copy()
197
192
  request_headers.update(headers)
198
193
  if options and options.headers:
@@ -223,11 +218,7 @@ class Transport:
223
218
  failure = exc
224
219
  text = raw.decode("utf-8", errors="replace")
225
220
  try:
226
- details = (
227
- TypeAdapter[JsonValue](JsonValue).validate_json(text)
228
- if text
229
- else None
230
- )
221
+ details = _JSON_ADAPTER.validate_json(text) if text else None
231
222
  except ValidationError:
232
223
  details = text
233
224
  raise HttpError(response, details) from failure
@@ -245,6 +236,16 @@ class Transport:
245
236
  try:
246
237
  yield adapter.validate_json(payload)
247
238
  except ValidationError as exc:
239
+ if discriminator is not None:
240
+ try:
241
+ value = _JSON_ADAPTER.validate_json(payload)
242
+ except ValidationError:
243
+ pass
244
+ else:
245
+ if isinstance(value, dict):
246
+ tag = value.get(discriminator)
247
+ if isinstance(tag, str) and tag not in known_tags:
248
+ continue
248
249
  # Exclude input and arbitrary validator context from public
249
250
  # diagnostics. The original cause remains available explicitly.
250
251
  details = exc.errors(
@@ -257,22 +258,6 @@ class Transport:
257
258
  if any(error["type"] == "json_invalid" for error in details)
258
259
  else "invalid_event_schema"
259
260
  )
260
- if discriminator is not None:
261
- try:
262
- value = TypeAdapter[JsonValue](JsonValue).validate_json(
263
- payload
264
- )
265
- except ValidationError:
266
- raise StreamParseError(
267
- payload,
268
- operation_id=operation_id,
269
- category="invalid_json",
270
- validation_errors=details,
271
- ) from exc
272
- if isinstance(value, dict):
273
- tag = value.get(discriminator)
274
- if isinstance(tag, str) and tag not in known_tags:
275
- continue
276
261
  raise StreamParseError(
277
262
  payload,
278
263
  operation_id=operation_id,
@@ -285,6 +270,19 @@ class Transport:
285
270
  raise TransportError("Invalid UTF-8 in SSE stream") from exc
286
271
 
287
272
 
273
+ @cache
274
+ def _unknown_event_policy[T](adapter: TypeAdapter[T]) -> tuple[str, frozenset[str]]:
275
+ schema = adapter.json_schema()
276
+ policy = schema.get("discriminator", {})
277
+ discriminator = policy.get("propertyName")
278
+ known_tags = frozenset(policy.get("mapping", {}))
279
+ if not isinstance(discriminator, str) or not known_tags:
280
+ raise ValueError(
281
+ "unknown_events='ignore' requires a top-level discriminated event union"
282
+ )
283
+ return discriminator, known_tags
284
+
285
+
288
286
  def _preserve_numbers[T](original: object, validated: T) -> T:
289
287
  if (
290
288
  type(original) is int
@@ -1,4 +1,4 @@
1
1
  from fastapi_streams.router import StreamRouter
2
2
 
3
3
  __all__ = ["StreamRouter"]
4
- __version__ = "0.3.0"
4
+ __version__ = "0.3.1"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import hashlib
4
5
  import importlib
5
6
  import json
6
7
  import sys
@@ -9,6 +10,7 @@ from typing import TYPE_CHECKING, cast
9
10
 
10
11
  from fastapi import FastAPI
11
12
 
13
+ from fastapi_streams import __version__
12
14
  from fastapi_streams.codegen.openapi import ContractError, Document, export_schema
13
15
  from fastapi_streams.codegen.writer import write_files
14
16
 
@@ -103,6 +105,7 @@ def main(argv: list[str] | None = None) -> int:
103
105
  document = json.loads(args.schema.read_text(encoding="utf-8"))
104
106
  if not isinstance(document, dict):
105
107
  raise ContractError("The OpenAPI document must be an object")
108
+ document = cast(Document, document)
106
109
  if args.command == "check" and args.app:
107
110
  expected = export_schema(_load_app(args.app))
108
111
  if expected != document:
@@ -112,12 +115,17 @@ def main(argv: list[str] | None = None) -> int:
112
115
  )
113
116
  return 1
114
117
  diagnostics: list[GenerationDiagnostic] = []
115
- contract = build_ir(
116
- cast(Document, document), target=args.language, diagnostics=diagnostics
117
- )
118
+ contract = build_ir(document, target=args.language, diagnostics=diagnostics)
118
119
  # Reports precede strict-policy failures, so CI can review new IDs.
119
120
  if args.diagnostics:
120
- _write_diagnostics(args.diagnostics, diagnostics, set())
121
+ _write_diagnostics(
122
+ args.diagnostics,
123
+ diagnostics,
124
+ set(),
125
+ document,
126
+ args.language,
127
+ args.python_http_client or "httpx",
128
+ )
121
129
  accepted: set[str] = (
122
130
  accepted_diagnostics(
123
131
  diagnostics, json.loads(args.allow_diagnostics.read_text("utf-8"))
@@ -126,7 +134,14 @@ def main(argv: list[str] | None = None) -> int:
126
134
  else set()
127
135
  )
128
136
  if args.diagnostics:
129
- _write_diagnostics(args.diagnostics, diagnostics, accepted)
137
+ _write_diagnostics(
138
+ args.diagnostics,
139
+ diagnostics,
140
+ accepted,
141
+ document,
142
+ args.language,
143
+ args.python_http_client or "httpx",
144
+ )
130
145
  pending = [item for item in diagnostics if item.id not in accepted]
131
146
  emit_diagnostics(pending)
132
147
  if args.strict and pending:
@@ -168,13 +183,57 @@ def _load_app(value: str) -> FastAPI:
168
183
 
169
184
 
170
185
  def _write_diagnostics(
171
- path: Path, diagnostics: list[GenerationDiagnostic], accepted: set[str]
186
+ path: Path,
187
+ diagnostics: list[GenerationDiagnostic],
188
+ accepted: set[str],
189
+ document: Document,
190
+ target: str,
191
+ http_client: str,
172
192
  ) -> None:
193
+ # Canonical JSON binds reports to schema content, independent of file layout.
194
+ encoded = json.dumps(
195
+ document, sort_keys=True, separators=(",", ":"), ensure_ascii=False
196
+ ).encode("utf-8")
197
+ python = target == "python"
198
+ request_phase = "method_call" if python else "not_performed"
173
199
  path.parent.mkdir(parents=True, exist_ok=True)
174
200
  path.write_text(
175
201
  json.dumps(
176
202
  {
177
203
  "version": 1,
204
+ "generation": {
205
+ "generator": "fastapi-streams",
206
+ "generator_version": __version__,
207
+ "schema_sha256": hashlib.sha256(encoded).hexdigest(),
208
+ "schema_hash_encoding": "canonical-json-v1",
209
+ "target": target,
210
+ "python_http_client": http_client if python else None,
211
+ },
212
+ "validation": {
213
+ "full_json_schema": False,
214
+ "runtime_scope": (
215
+ "generated_types_and_supported_constraints"
216
+ if python
217
+ else "json_decoding_only"
218
+ ),
219
+ "parameters": {
220
+ "phase": request_phase,
221
+ "schema_validation": python,
222
+ },
223
+ "request_body": {
224
+ "phase": request_phase,
225
+ "schema_validation": python,
226
+ },
227
+ "events": {
228
+ "phase": "iteration",
229
+ "schema_validation": python,
230
+ "json_decoding": True,
231
+ },
232
+ "http_error_bodies": {
233
+ "phase": "consumer_controlled",
234
+ "schema_validation": False,
235
+ },
236
+ },
178
237
  "diagnostics": [
179
238
  item.to_dict(accepted=item.id in accepted) for item in diagnostics
180
239
  ],
@@ -5,12 +5,15 @@ import codecs
5
5
  from collections.abc import AsyncGenerator, Mapping, Sequence
6
6
  from dataclasses import dataclass
7
7
  from enum import Enum
8
+ from functools import cache
8
9
  from typing import Literal, cast
9
10
 
10
11
  import httpx
11
12
  from pydantic import JsonValue, TypeAdapter, ValidationError
12
13
  from pydantic_core import ErrorDetails
13
14
 
15
+ _JSON_ADAPTER = TypeAdapter[JsonValue](JsonValue)
16
+
14
17
 
15
18
  class TransportError(Exception):
16
19
  pass
@@ -180,19 +183,11 @@ class Transport:
180
183
  options: RequestOptions | None = None,
181
184
  ) -> AsyncGenerator[T, None]:
182
185
  discriminator: str | None = None
183
- known_tags: set[str] = set()
186
+ known_tags: frozenset[str] = frozenset()
184
187
  if options and options.unknown_events not in {"error", "ignore"}:
185
188
  raise ValueError("unknown_events must be 'error' or 'ignore'")
186
189
  if options and options.unknown_events == "ignore":
187
- schema = adapter.json_schema()
188
- policy = schema.get("discriminator", {})
189
- discriminator = policy.get("propertyName")
190
- known_tags = set(policy.get("mapping", {}))
191
- if not isinstance(discriminator, str) or not known_tags:
192
- raise ValueError(
193
- "unknown_events='ignore' requires a top-level "
194
- "discriminated event union"
195
- )
190
+ discriminator, known_tags = _unknown_event_policy(adapter)
196
191
  request_headers = self.headers.copy()
197
192
  request_headers.update(headers)
198
193
  if options and options.headers:
@@ -223,11 +218,7 @@ class Transport:
223
218
  failure = exc
224
219
  text = raw.decode("utf-8", errors="replace")
225
220
  try:
226
- details = (
227
- TypeAdapter[JsonValue](JsonValue).validate_json(text)
228
- if text
229
- else None
230
- )
221
+ details = _JSON_ADAPTER.validate_json(text) if text else None
231
222
  except ValidationError:
232
223
  details = text
233
224
  raise HttpError(response, details) from failure
@@ -245,6 +236,16 @@ class Transport:
245
236
  try:
246
237
  yield adapter.validate_json(payload)
247
238
  except ValidationError as exc:
239
+ if discriminator is not None:
240
+ try:
241
+ value = _JSON_ADAPTER.validate_json(payload)
242
+ except ValidationError:
243
+ pass
244
+ else:
245
+ if isinstance(value, dict):
246
+ tag = value.get(discriminator)
247
+ if isinstance(tag, str) and tag not in known_tags:
248
+ continue
248
249
  # Exclude input and arbitrary validator context from public
249
250
  # diagnostics. The original cause remains available explicitly.
250
251
  details = exc.errors(
@@ -257,22 +258,6 @@ class Transport:
257
258
  if any(error["type"] == "json_invalid" for error in details)
258
259
  else "invalid_event_schema"
259
260
  )
260
- if discriminator is not None:
261
- try:
262
- value = TypeAdapter[JsonValue](JsonValue).validate_json(
263
- payload
264
- )
265
- except ValidationError:
266
- raise StreamParseError(
267
- payload,
268
- operation_id=operation_id,
269
- category="invalid_json",
270
- validation_errors=details,
271
- ) from exc
272
- if isinstance(value, dict):
273
- tag = value.get(discriminator)
274
- if isinstance(tag, str) and tag not in known_tags:
275
- continue
276
261
  raise StreamParseError(
277
262
  payload,
278
263
  operation_id=operation_id,
@@ -285,6 +270,19 @@ class Transport:
285
270
  raise TransportError("Invalid UTF-8 in SSE stream") from exc
286
271
 
287
272
 
273
+ @cache
274
+ def _unknown_event_policy[T](adapter: TypeAdapter[T]) -> tuple[str, frozenset[str]]:
275
+ schema = adapter.json_schema()
276
+ policy = schema.get("discriminator", {})
277
+ discriminator = policy.get("propertyName")
278
+ known_tags = frozenset(policy.get("mapping", {}))
279
+ if not isinstance(discriminator, str) or not known_tags:
280
+ raise ValueError(
281
+ "unknown_events='ignore' requires a top-level discriminated event union"
282
+ )
283
+ return discriminator, known_tags
284
+
285
+
288
286
  def _preserve_numbers[T](original: object, validated: T) -> T:
289
287
  if (
290
288
  type(original) is int
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-streams"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Typed FastAPI streams with generated TypeScript and Python clients."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,93 @@
1
+ // Invoked by benchmark_runtime.py against a freshly generated, compiled SDK.
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ const [sdk, payloadPath, eventArg, iterationArg, roundArg] = process.argv.slice(2);
7
+ const events = Number(eventArg);
8
+ const iterations = Number(iterationArg);
9
+ const rounds = Number(roundArg);
10
+ const { createClient } = await import(pathToFileURL(resolve(sdk, "index.js")).href);
11
+ const { parseSSE } = await import(pathToFileURL(resolve(sdk, "transport/sse.js")).href);
12
+ const payloads = JSON.parse(await readFile(payloadPath, "utf8"));
13
+ const encoder = new TextEncoder();
14
+ let activeChunks = [];
15
+ let sink;
16
+
17
+ function body() {
18
+ return new ReadableStream({
19
+ start(controller) {
20
+ for (const chunk of activeChunks) controller.enqueue(chunk);
21
+ controller.close();
22
+ },
23
+ });
24
+ }
25
+
26
+ const client = createClient({
27
+ baseUrl: "http://benchmark.test",
28
+ fetch: async () => new Response(body(), { headers: { "content-type": "text/event-stream" } }),
29
+ });
30
+
31
+ async function measure(operation, repeats, units) {
32
+ for (let i = 0; i < 3; i++) await operation();
33
+ const wall = [];
34
+ const cpu = [];
35
+ for (let round = 0; round < rounds; round++) {
36
+ const started = process.hrtime.bigint();
37
+ const cpuStarted = process.cpuUsage();
38
+ for (let i = 0; i < repeats; i++) await operation();
39
+ const elapsedCpu = process.cpuUsage(cpuStarted);
40
+ cpu.push((elapsedCpu.user + elapsedCpu.system) / repeats / units);
41
+ wall.push(Number(process.hrtime.bigint() - started) / repeats / units / 1000);
42
+ }
43
+ const median = values => {
44
+ const sorted = [...values].sort((a, b) => a - b);
45
+ const middle = Math.floor(sorted.length / 2);
46
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
47
+ };
48
+ return {
49
+ iterations_per_round: repeats,
50
+ units_per_iteration: units,
51
+ median_us_per_unit: median(wall),
52
+ cpu_median_us_per_unit: median(cpu),
53
+ units_per_second: 1_000_000 / median(wall),
54
+ samples_us_per_unit: wall,
55
+ };
56
+ }
57
+
58
+ const results = {};
59
+ results.request_preparation = await measure(async () => {
60
+ sink = client.bench.events({ runId: "run_abc", limit: 100 });
61
+ }, iterations * events, 1);
62
+
63
+ for (const name of ["small", "nested"]) {
64
+ results[`decode_static_${name}`] = await measure(async () => {
65
+ sink = JSON.parse(payloads[name]);
66
+ }, iterations * events, 1);
67
+ }
68
+
69
+ for (const name of ["small", "nested", "fragmented"]) {
70
+ const payload = payloads[name === "nested" ? "nested" : "small"];
71
+ const raw = encoder.encode(`data: ${payload}\n\n`.repeat(events));
72
+ activeChunks = name === "fragmented"
73
+ ? Array.from({ length: Math.ceil(raw.length / 7) }, (_, index) => raw.subarray(index * 7, index * 7 + 7))
74
+ : [raw];
75
+ results[`stream_${name}`] = await measure(async () => {
76
+ let received = 0;
77
+ for await (const event of client.bench.events({ runId: "run_abc" })) {
78
+ sink = event;
79
+ received++;
80
+ }
81
+ if (received !== events) throw new Error("Incomplete benchmark stream");
82
+ }, iterations, events);
83
+ results[`frame_decode_${name}`] = await measure(async () => {
84
+ for await (const event of parseSSE(body())) sink = event;
85
+ }, iterations, events);
86
+ }
87
+
88
+ if (sink === undefined) throw new Error("Benchmark did not execute");
89
+ process.stdout.write(JSON.stringify({
90
+ version: process.version,
91
+ event_validation: "JSON decoding and static types; no runtime schema validation",
92
+ results,
93
+ }));