Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b2d16c7
codegen(python): drop ReflectapiOption, use model_fields_set for part…
hardbyte May 12, 2026
13987db
codegen(python): fix four dangling-reference bugs + strict rebuild
hardbyte May 12, 2026
82fc592
chore: gitignore coverage + dist artefacts (drop from prior commit)
hardbyte May 12, 2026
9e9aad9
codegen(python): widen coverage and harden against fragile inputs
hardbyte May 13, 2026
c22f87f
chore(demo): drop leftover adversarial namespace from earlier regen
hardbyte May 13, 2026
25e62c0
ci: pin ruff to 0.15.8 in codegen smoke test
hardbyte May 13, 2026
688e9a3
ci: stop the ruff-action from linting unrelated files in the smoke test
hardbyte May 13, 2026
6e50e23
codegen(python): don't emit dead classes for the reflectapi::Option ADT
hardbyte May 13, 2026
10ecb79
codegen(python): bind every top-level namespace into every other for …
hardbyte May 13, 2026
2905c89
codegen(python): destutter monomorphized arg names from namespaced types
hardbyte May 13, 2026
c001e4c
codegen(python): rename fields that shadow deprecated BaseModel methods
hardbyte May 13, 2026
ab53177
codegen(python): emit string literals with the quote style that needs…
hardbyte May 13, 2026
c0c8f0d
derive: read `#[doc]` literals via LitStr::value()
hardbyte May 13, 2026
aa4b580
codegen(ts) + runtime(py): flush SSE parsers on stream close
hardbyte May 13, 2026
5b75435
codegen(python): preserve exclude-none for plain models + move covera…
hardbyte May 13, 2026
993788b
chore: untrack coverage-client build artefacts + tidy comments
hardbyte May 13, 2026
700f7c0
docs: changelog entry for the Python codegen overhaul + TS SSE flush
hardbyte May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,56 @@ jobs:
- name: Run tests
working-directory: reflectapi-python-runtime
run: uv run --extra dev pytest -q --no-cov

# Regenerates the demo Python client from the in-repo schema and
# imports it. The generated package's strict `_rebuild_models()`
# runs at import time and raises on any dangling type reference, so
# this job catches the class of bug where the codegen emits a
# Python annotation pointing at a symbol it never defined.
python-codegen-smoke:
name: Python codegen smoke test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: astral-sh/setup-uv@v3
# The Python codegen shells out to `ruff format`; different ruff
# versions wrap long lines differently, so the snapshot-drift
# check below is meaningless unless CI and local devs use the
# same ruff version. Pin it explicitly.
- uses: astral-sh/ruff-action@v3
with:
version: "0.15.8"
# `args` short-circuits the action's default `ruff check` —
# we only want ruff on PATH so the codegen can shell out to
# `ruff format`, not for the action itself to lint the repo.
args: "--version"
- name: Regenerate demo Python client from schema
run: |
cargo run -p reflectapi-cli --quiet -- codegen \
-s reflectapi-demo/reflectapi.json \
-o reflectapi-demo/clients/python/api_client \
-l python --python-package-name api_client --python-sync -f
- name: Import demo client (triggers strict model_rebuild)
working-directory: reflectapi-demo/clients/python
run: uv run python -c "import api_client; print('demo client import OK')"
- name: Demo snapshot is committed (no drift)
run: git diff --exit-code reflectapi-demo/clients/python/api_client/
# Codegen coverage fixtures (recursive types, Python keyword
# field names, Pydantic-reserved names, ...) live in the
# integration test at `reflectapi-demo/tests/codegen_coverage.rs`.
# The test writes a Python client to
# `target/codegen-coverage-client/`; the next step imports it
# so `_rebuild_models()` raises on any dangling reference.
- name: Generate codegen coverage client
run: cargo test -p reflectapi-demo --test codegen_coverage
- name: Import codegen coverage client
working-directory: reflectapi-demo/clients/python
run: |
uv run python -c "
import sys, pathlib
sys.path.insert(0, str(pathlib.Path('../../target/codegen-coverage-client').resolve()))
import codegen_coverage_client
print('coverage client import OK')
"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
/target
reflectapi-demo/target/
docs/book
.vscode/settings.json
*.pyc
__pycache__/

# Python coverage artefacts
reflectapi-python-runtime/.coverage
reflectapi-python-runtime/coverage.xml
reflectapi-python-runtime/dist/
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,56 @@
# Changelog

## Unreleased

### Python — partial fields without a wrapper class

The hand-rolled `ReflectapiOption[T]` runtime class is gone. Generated clients now express `reflectapi::Option<T>` fields as plain `T | None` on a `ReflectapiPartialModel` base class, with the absent-vs-null distinction carried by Pydantic's `model_fields_set`:

```python
# Field omitted from the wire if you don't pass it
Item(name="rex").model_dump_json() == '{"name":"rex"}'

# Null on the wire if you pass None explicitly
Item(name="rex", kind=None).model_dump_json() == '{"name":"rex","kind":null}'

# "Was this field on the wire?" — analogue of TypeScript `obj.kind !== undefined`
"kind" in item.model_fields_set
```

Migration for hand-written consumer code that touched the wrapper API:

| Before | After |
|---|---|
| `ReflectapiOption(Undefined)` | don't set the field |
| `ReflectapiOption(None)` | `field=None` |
| `ReflectapiOption(value)` | `field=value` |
| `opt.is_undefined` | `"field" not in model.model_fields_set` |
| `opt.is_none` | `model.field is None and "field" in model.model_fields_set` |
| `opt.unwrap_or(default)` | `model.field if "field" in model.model_fields_set else default` |

The runtime drops `ReflectapiOption`, `Undefined`, `Option`, `some`, `none`, `undefined`, and `serialize_option_dict`. `ReflectapiDuration` is added as the wire-format adapter for `std::time::Duration` (`{secs, nanos}` ↔ `timedelta`).

Other Python codegen fixes in the same pass:

- `Vec<u8>` renders as `list[int]` to match serde's default JSON shape (was `bytes`, which Pydantic rejected against the wire form).
- `PhantomData<T>` fields are dropped from generated models (Rust-only type marker, no wire data).
- `std::tuple::TupleN` types render as Python `tuple[…]`.
- Field names that exact-match Pydantic methods (`model_dump`, `schema`, `json`, …) are renamed with a trailing underscore and aliased to the wire name; `model_*` field prefixes work without warnings via `protected_namespaces=()`.
- `_rebuild_models()` raises a structured `RuntimeError` listing every model whose annotations don't resolve, instead of silently swallowing the failures.

### TypeScript — SSE final-event flush

The generated `__EventSourceParserStream` gains a `flush()` that feeds `'\n\n'` to the parser on stream close. Without it, a server that closes the connection immediately after the final `data:` line — without the spec's trailing blank-line terminator — silently dropped the last event. Regenerated clients pick up the fix.

### Rust

No generated-code changes.

### Internals

- OpenAPI codegen now tracks in-progress conversions by full monomorphization, so generic self-referential types no longer recurse forever.
- `reflectapi-derive` reads `#[doc]` literals via `LitStr::value()` so doc comments with literal quotes land in the schema without source-level `\"` escaping.

## 0.17.2 — transport-shape refactor

The Rust, TypeScript, and Python clients now share a single `Request` DTO shape: `{ path, headers, body }`. The base URL lives on the transport, not on the generated `Interface`.
Expand Down
2 changes: 1 addition & 1 deletion reflectapi-demo/clients/python/api_client/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
# Runtime imports
from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse
from reflectapi_runtime import ReflectapiEmpty
from reflectapi_runtime import ReflectapiOption
from reflectapi_runtime import ReflectapiPartialModel
from reflectapi_runtime import (
parse_externally_tagged as _parse_externally_tagged,
serialize_externally_tagged as _serialize_externally_tagged,
Expand Down
14 changes: 12 additions & 2 deletions reflectapi-demo/clients/python/api_client/_rebuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def rebuild_models() -> None:
myapi.model.input.myapi = myapi
myapi.model.output.myapi = myapi
myapi.proto.myapi = myapi
errors: list[str] = []
for _model in [
MyapiHealthCheckFail,
MyapiModelBehavior,
Expand All @@ -82,7 +83,16 @@ def rebuild_models() -> None:
MyapiProtoValidationA,
MyapiProtoValidationError,
]:
if not hasattr(_model, "model_rebuild"):
continue
try:
_model.model_rebuild()
except Exception:
pass
except Exception as exc:
errors.append(f" - {_model.__name__}: {type(exc).__name__}: {exc}")
if errors:
raise RuntimeError(
"reflectapi: failed to rebuild "
+ str(len(errors))
+ " generated model(s). This usually means the codegen emitted an annotation pointing at a symbol that was never defined (a dangling type reference). Fix the codegen rather than catching this error.\n"
+ "\n".join(errors)
)
8 changes: 5 additions & 3 deletions reflectapi-demo/clients/python/api_client/myapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
# Runtime imports
from reflectapi_runtime import AsyncClientBase, ClientBase, ApiResponse
from reflectapi_runtime import ReflectapiEmpty
from reflectapi_runtime import ReflectapiOption
from reflectapi_runtime import ReflectapiPartialModel
from reflectapi_runtime import (
parse_externally_tagged as _parse_externally_tagged,
serialize_externally_tagged as _serialize_externally_tagged,
Expand All @@ -50,7 +50,9 @@


class MyapiHealthCheckFail(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
model_config = ConfigDict(
extra="ignore", populate_by_name=True, protected_namespaces=()
)


# Public aliases for this module
Expand All @@ -63,7 +65,7 @@ class MyapiHealthCheckFail(BaseModel):
from .._rebuild import rebuild_models as _rebuild_models

_rebuild_models()
except Exception:
except ImportError:
pass

__all__ = ["HealthCheckFail", "MyapiHealthCheckFail", "model", "proto"]
Loading
Loading