Changelog¶
All notable changes to this project are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning.
[Unreleased]¶
[0.8.0] — 2026-10-15¶
The public contract is frozen ahead of 1.0 (versioning and deprecation policy, tests that pin the command lines,
the binary's options and variables, the error codes and the JSON of check), after a second security review of
the runtime. The behaviour follows the project's Python from 3.12 to 3.15. Popular libraries are translated
natively (orjson, PyYAML, croniter, Unidecode, ecs-logging, aio-pika) and the binary applies the project's alembic
migrations itself (<binary> migrate). Upgrading is recommended: several fixes below close denials of service
that, depending on the application, need no authentication.
Added¶
- Versioning and deprecation: the public
contract semver covers from 1.0 (command lines, the binary's options and environment variables, the supported
subset, the JSON of
check, the error codes) and how a deprecation works. The JSON output ofcheckis published as a JSON Schema (reference/check-schema.json); tests freeze the contract. - orjson 3.13:
loads(the decoder's messages and positions, its depth and memory limits) anddumpswith everyOPT_*,default=and the types orjson serializes natively; FastAPI'sORJSONResponse(returned,response_class=,default_response_class=). - Unidecode 1.4:
unidecode,unidecode_expect_ascii,unidecode_expect_nonascii,errors=,UnidecodeError(tables generated from Unidecode bytools/gen_unidecode.py). - ecs-logging 2.3:
StdlibFormatter, called or fromdictConfig. - PyYAML 6.0 native (
import yaml):safe_load/load(Loader=yaml.SafeLoader)of a str, bytes or file object andsafe_dump/dump/safe_dump_all/dump_allwith all their options, ported from PyYAML's pure-Python loader and dumper: same values, same text byte for byte, same exceptions (str(),problem,context, marks and snippets). The other loaders, libyaml's classes and the extension API are refused at translation; the differences (integers beyond 64 bits, recursion depth,yaml.dumpof!!python/objectvalues) are documented. - aio-pika 10: exchanges of every type (
declare_exchange,get_exchange,bind/unbind),queue.consume/cancel,queue.iterator(),message.process(),ack/nack/reject,purge,delete,set_qos, the broker's channel errors as aiormq's classes; a published message gets aiormq's defaults (delivery mode 1, priority 0, amessage_id). - croniter 6.2 native (
dynrt/croniter.rs, a port of croniter.py):croniter(...)and itsget_next/get_prev/get_current/set_current/next()/all_next()/all_prev(),is_valid,match,match_range,expand,croniter_range, its exceptions; 5, 6 and 7 fields,L,W,#, names, aliases,Hhashing, time zones and DST transitions (timezone,ZoneInfo).R(random) fields are refused (see the known differences). datetime(the class) as a value:ret_type=datetime,type(x) is datetime, called later.datetime.replace(fold=);replace()keeps the fold of the datetime, as CPython does.asyncio.Future(loop.create_future()),asyncio.Event,asyncio.wait_forof a future or task held in a variable.- A plain class's
__new__(a factory returning something else, a singleton throughsuper().__new__(cls)/object.__new__(cls)), andcls.Xread or assigned on a class held as a value. <binary> migrate: the project's alembic migrations applied by the binary, rendered at translation time by alembic's offline mode (docs/advanced/migrations.md); revisions that read the database are refused with file:line. New error codeP2A0510(an alembic option or layoutmigratecannot reproduce); the binary's subcommands (migrate [upgrade [head|<revision>]],migrate current,migrate --sql) join the frozen public contract.str.isprintable().
Changed¶
unicodedata.normalize/combiningandstr.isalpha()... follow the Unicode version of the project's Python (15.0 to 17.0), even when the translating Python is newer.re.erroris namedPatternErrorfrom Python 3.13 (type(e).__name__, tracebacks);re.PatternErroris refused for a 3.12 project.socket.create_connection(timeout=)reads its timeout like CPython'ssettimeout:Decimal/Fractionfrom 3.15, each version's messages (down to the patch release for an out-of-range delay, probed like the other backported fixes, so a distribution's Python is matched),ValueError/OverflowErrorfor NaN and huge values (they made the request panic).- A synchronous generator consumed synchronously (
list(),for,next(), a comprehension) is refused at translation (P2A0118); it answered 500.StreamingResponse(gen())is unchanged.
Deprecated¶
--backendnow prints a deprecation warning (it has no effect since 0.4); it is removed in 1.0, with the keybackendof the JSON ofcheck.--report: an internal coverage report, outside the stable interface; usepy2axum check --format jsonor--format markdown. It prints a deprecation warning.
Fixed¶
check --helplistsmountamong the values of--python-side(it was accepted, not shown).check: the first reason of a route refused for several reasons, the order of its blockers and of the whole-application errors no longer change from run to run (hash order of the call graph): closest to the route first, then source order.StreamingResponse(content)started iterating its content as soon as the response object was built: the generator could read state before the endpoint finished or before aDepends(scope="function")dependency ran its exit code (seen as an intermittent conformance difference). It now starts when the body is sent, as Starlette does.asyncio.sleep(delay, result)returnsresult;asyncio.sleep/wait_forread their delay like asyncio (TypeErrorfor astr, or aDecimaloutside uvloop; a timeout <= 0 raisesTimeoutErrorwithout running the awaitable, which used to run once).- SQLAlchemy: objects INSERTed then rolled back are transient again, with their attributes and loaded relationships; pending objects a failed flush did not reach are transient too.
email.utils.formataddrraisesUnicodeEncodeErrorfor a non-ASCII address, as CPython.- Memory: a finished task stored in the request's state no longer keeps the request alive; anonymous subqueries
are numbered per statement (
anon_1...), so a query is the same SQL at every request. repr()of a string escapes non-printable characters as CPython does ('\xa0','\u200b','\U000e0001'); only ASCII control characters were escaped.str()of a Pydantic model is its fields separated by spaces (BaseModel.__str__), not its repr.exc.__cause__/exc.__context__of an exception without a chain isNone(was anAttributeError).- A dict keeps the first object of a key set again under an equal key (
{1: "a", True: "b"}is{1: "b"}). int(b"42").repr(int)/str(int)of a builtin type is<class 'int'>;type(zoneinfo.ZoneInfo(...)).__name__isZoneInfo.
Security¶
rustls0.23.46 in the shipped lock file (was 0.23.45): RUSTSEC-2026-0346 to RUSTSEC-2026-0349.- Second review of the runtime, see Security:
str.zfill/ljust/rjust/center, format specs and%-formatting with a width or precision from the request raiseMemoryError/ValueErrorlike CPython (a negative width panicked, a huge one aborted);range()near the 64-bit edge no longer loops for ever, and a range too large to hold isMemoryError;Fraction(text)appliesint()'s 4300-digit limit; a JSON integer past 4300 digits is a 400.- Raw ASGI apps and middlewares:
sendwaits for a client that does not read (it buffered the whole response); a stream the application leaves unfinished closes the connection instead of holding it. WebSocketsendwaits likewise (the outgoing queue was unbounded). - A raw ASGI middleware next to WebSocket routes is refused at translation unless it hands non-HTTP scopes straight on (the binary does not run raw middlewares for WebSocket connections: an auth or rate-limit check written for websockets would have been skipped).
- uvicorn's proxy headers: the last
X-Forwarded-Protois used,*trusts everyone only alone, networks with host bits set are literals (as uvicorn);regexp_match(flags=)binds its flags.
[0.7.1] — 2026-10-14¶
Security release. Upgrading is strongly recommended.
Security¶
- Denial of service fixed (all earlier versions): a request reaching a
Decimalfield with constraints, orDecimalarithmetic in the application, could make a generated binary spend minutes of CPU and then exhaust its memory until the process was killed, without authentication.Decimalnow follows CPython's default context and its limits. Upgrading is strongly recommended. - MCP (
mcpSDK, stateless server): aGETevent stream now ends when the client leaves. Before, each disconnect kept a pooled database connection open in a transaction until the pool was exhausted (all earlier versions serving MCP natively).
[0.7.0] — 2026-10-14¶
Pydantic documentation corpus at 90.8 % and SQLAlchemy ORM documentation corpus at 93.8 % (both past the 1.0 threshold, none differing), logging identical to Python's, Python 3.15, and memory leaks found by a long soak.
Added¶
- Pydantic:
validation_alias(AliasPath,AliasChoices),serialization_alias,alias_generator(evaluated at translation),validate_by_alias/validate_by_name/serialize_by_alias;Field(exclude=, exclude_if=, frozen=, multiple_of=, union_mode=, strict=, discriminator=); strict mode (config and field); discriminated unions (by aLiteralfield or byDiscriminator(fn)withTag);AfterValidator/BeforeValidator/PlainValidator/WrapValidator,@field_validator(mode="plain"|"wrap"),@field_serializer,@model_serializer,PlainSerializer/WrapSerializer; specialised generic models,RootModel,SecretStr,UUID1..UUID8,Fraction,complex,deque,Counter,OrderedDict,Sequence, fixed tuples,default_factorytaking the validated data,PydanticCustomError. Pydantic documentation corpus 52.1 → 90.8 % (216/238); the messages of pydantic 2.12 and 2.13 are reproduced by the locked version. - SQLAlchemy ORM inheritance: single table (
polymorphic_on/polymorphic_identity), joined table (one table per class, flushed table by table),of_type(),with_polymorphic(),selectin_polymorphic(),polymorphic_load="selectin"|"inline". - SQLAlchemy ORM: deferred columns (
load_only,defer,undefer,undefer_group,defaultload,deferred(group=), raise-loading),composite(),@hybrid_property/@hybrid_method(with.expressionand.setter), write-only collections (WriteOnlyMapped),eager_defaults=True,Session(engine)/AsyncSession(engine)in an endpoint,session.expire(obj[, names]),__init__on a mapped class,filter_by()on hybrids and composites,and_()/with_parent()/add_columns()/aliased(name=), and CoreTable()objects used as values (t.c,alias(),bindparam(), executemany,insert().values([...]), FULL OUTER JOIN).dataclasses.astuple()/asdict(),typing.cast(). - Logging identical to Python's: the
logginghierarchy (levels, propagation, handlers,Formatterwith%,{and$styles, filters,LoggerAdapter,basicConfig,dictConfig, python-json-logger 4.0), uvicorn's startup, shutdown and access lines and its command-line options, and tracebacks with the project's frames, source lines and carets (raise ... from,__context__). The mcp SDK's own log lines are reproduced. Module and factory statements that configure logging are translated. See Logging. - SQLAlchemy ORM:
relationship(secondary=<Table>)(many-to-many: joins, lazy andselectinloading,any(), association rows written at flush and deleted with their owner;back_populates/backref, cascades andorder_by=on it are refused),session.expunge()/expunge_all()(DetachedInstanceError),expire_all(),obj in session, relationship criteria (Child.parent == obj,Parent.children.contains(obj),join(Target, Model.rel), aliases),execution_options(populate_existing=, autoflush=, yield_per=); bulkexecute(insert(Model), [dicts])(and withreturning(Model)), bulkupdate(Model)by primary key,insert().values([dicts])as SQLAlchemy builds it; results are exhausted and closed like SQLAlchemy's (ResourceClosedError).Model.__mapper__and Core use ofModel.__table__are refused at translation. - SQLAlchemy documentation corpus: the doctests of the 2.1.4 documentation replayed as routes against the binary; ORM scope 93.8 % identical (243/276, the 1.0 threshold is 90 %), Core 88.7 %, none differing (coverage).
- Python 3.15 is supported (3.12 to 3.15; Python versions
reviews every change of 3.14 and 3.15). The binary follows the project's Python:
--python X.Y, else its.python-version, else the Python running py2axum whenrequires-pythonallows it (it used to take the lowest Pythonrequires-pythonallowed).sys.version_infois folded against it at translation, and a branch it rules out is never translated (version-gated imports work). - Python 3.14 and 3.15 constructs: comprehension unpacking (
[*x for x in xs],{**m for m in ms}),lazy importof libraries,case +1:,with var.set(x):,Path.copy()/move(),uuid.uuid7(),re.prefixmatch(),\z,Counter ^ Counter,bytes.replace(count=),format()separators in the fractional part, json's exception notes,add_note()/__notes__; and the messages that changed between 3.12 and 3.15 (ZeroDivisionError, unpacking,dict(seq), context managers,strptime('%d'), base32, unpaddedurlsafe_b64decode,fromtimestamp,os.urandom). Template strings and lazy imports of project modules are refused.
Security¶
nh3.clean()runs onammonia4.2.3 (was 4.2.1): RUSTSEC-2021-0157 (url()escaping in CSS style attributes) and RUSTSEC-2021-0158 (SVG animation tags), both XSS in the sanitizer.
Fixed¶
- Memory: a nested function that calls itself no longer keeps its closure alive (113 KiB per request with 100 KB
captured); ORM objects left by a failed flush, a rollback or a delete are released with their session; a
streamed response with a
yielddependency cut by the client released its connection only at shutdown (each cut kept a connection idle in transaction: 200 cuts blocked every route using the database); an abandoned raw ASGI app is completed. - Sentry: an exception already captured for a request is not sent a second time by the
loggingintegration (uvicorn logs it after the response), and a record'sextraleaves out sentry-sdk's common record attributes. session.close()did nothing: objects are now detached and the transaction rolled back, like SQLAlchemy; a session dropped with an open transaction rolls it back at once. Reserved table and column names ("user","order") are quoted.base64.b64decode(and python-jose's padding) follows the padding rules CPython rewrote in its 3.13.13 and 3.14.4 security releases (data after the padding is decoded, excess=skipped), by the project's patch release. Tracebacks in the logs print the exception's notes, and theValueErroran enum's_missing_chains as__context__('x' is not a valid E, "During handling of the above exception...").socket.getaddrinfo,socket.create_connectionandasyncio.open_connectioncheck the host like CPython'sidnacodec before any lookup (a label of 64 characters or an empty one:UnicodeEncodeError,UnicodeErroron 3.12); the binary passed it to the C library.UnicodeErrorexists as the base of the encode/decode errors.- Behaviours that changed inside a Python minor follow the project's patch release:
strptime('%e')is a bad directive before 3.13.6. The patch is the one--python X.Y.Zor.python-versionnames, else the Python running py2axum when it runs the project's minor (PY2AXUM_PYTHON_VERSIONoverrides it at start). - Differences with Python on every version:
"a".count()or[1].count()without an argument aborted the request,str.replace(count=)was ignored,bytes.replacewas missing,base64.b32decode(map01=)was ignored and the base64 functions dropped unknown keyword arguments,f"{n:_}"grouped with commas, the builtinformat()was not translated,FileResponseguessed the media type from a table of 20 extensions (now CPython's table of the project's Python and the host'smime.types, asmimetypesreads them), and the types named by operator and context-manager errors (Counter,_thread.lock). The text of a type expression (repr(int | str)) is refused instead of<GenericAlias>. - Async sessions: reading an attribute that needs IO (expired or deferred column, unloaded relationship) raises
what the locked SQLAlchemy raises:
MissingGreenletup to 2.0, aStatementErrorwrapping it since 2.1 (except MissingGreenletis now translated, and does not catch it).e.origis readable on SQLAlchemy errors (the psycopg exception of a database error, the cause of a missing bind parameter or an unknown enum value). The primary key of an object expired as a whole (commit,expire_all()) was read from memory; it now needs IO like the other columns. - The declarative base's
type_annotation_mapwas ignored:Mapped[datetime]became aDateTimewithout time zone where the map said otherwise. It is applied, andMapped[Decimal]/Mapped[time]map toNumeric/Time.
[0.6.0] — 2026-10-09¶
FastAPI documentation corpus at 90.6 %, raw ASGI middlewares three times cheaper, error codes and py2axum
watch, a builder Docker image, and the constructs the remaining routes of real applications needed.
Performance¶
- Raw ASGI middlewares: the cost per layer drops from ~2.7 µs to ~0.85 µs. The binary advances the middleware's
future itself instead of spawning a task per layer (code left after the response, or a client gone, still
finishes in its own task, never aborted), the ASGI channels are a queue plus a waker, and a known-length body is
forwarded without a oneshot. On the reference app with 11 layers,
POST /describegoes from ~24k to ~40k req/s.
Added¶
- Error codes: every refusal has a stable code (
P2A0201: library call not supported, ...) and the command line prints it with thefile:line, why the construct is refused, what to do and a link to its entry in the new error code reference.py2axum check --explain CODEprints the same in a terminal. py2axum check: a suggestion per refused route (the alternative py2axum knows, the--python-sideflag that leaves it to Python), a summary of what would make the most routes native,--format text|json|markdown(--jsonstays a synonym).py2axum watch: regenerate, rebuild (debug profile, only the generated crate) and restart the binary on each change of the project, with debounce; a failed cycle keeps the previous binary running.-
Guide: Migrating an existing application, from
checkto a reversible switch in production. -
Coverage of the FastAPI documentation corpus: 79.6 % → 90.6 % of the exercised examples identical, none differing (coverage). Newly native:
yielddependencies withtry/exceptaround theyield(the request's exception is raised there, as FastAPI does; one swallowed isFastAPIError),Depends(scope="function"), an instance with__call__as a dependency,app.dependency_overrides[f] = gat module level, generator endpoints (JSON Lines,EventSourceResponsewithServerSentEvent,StreamingResponse),response_model_include=/_exclude=/_exclude_unset=/_exclude_none=, aResponseclass as the return annotation,FastAPI(default_response_class=),FastAPI(root_path=), unannotated parameters (Any),fastapi.exception_handlersdefaults called from an application's handler,TrustedHostMiddleware,HTTPSRedirectMiddleware,model_configval_json_bytes/ser_json_bytes, pwdlib's Argon2PasswordHash.recommended(),anyio.sleep. -
SQLAlchemy's JSON index and its typed accessors, on
JSONandJSONBcolumns:col["k"]/col[0]withas_string(),as_integer(),as_float(),as_boolean(),as_numeric(precision, scale),as_json()(CAST(col ->> 'k' AS VARCHAR), as SQLAlchemy's PostgreSQL compiler renders it);sqlalchemy.true()andfalse(). A JSON path index,col[("a", "b")], is refused at translation. -
Constructs of real applications: Pydantic
serialization_alias=/validation_alias=(distinct input and output names;AliasChoices/AliasPathrefused), closures with cells as CPython builds them (late binding, comprehensions included) andnonlocal,TypedDictcalled as adict(refused as aresponse_model),Model.__table__(columns and their real SQLAlchemy types, computed at translation),StaleDataErrorwhen a flushedUPDATEmatches no row,html.unescapewith CPython's HTML5 tables, icalendar 7.0 (Calendar/Event/Alarm,add,add_component,to_ical, byte-identical output),os.remove/os.unlink; the runtime'sOSErrors carryerrno,strerror,filenameand the subclass of their errno. - SQLAlchemy:
with engine.connect()/engine.begin()on a synchronouscreate_engine,executemanythroughexecute(text(...), [dicts]),||for a string+on SQL expressions;execute/scalars/scalar(stmt, params)outsidetext(), which failed at run time, is refused at translation withfile:line. - asyncpg 0.32 in the supported range.
- Docker:
ghcr.io/larrymotalavigne/py2axum, a builder image (Python 3.14, py2axum, a pinned Rust toolchain and the runtime's crates precompiled with the release profile), multi-arch, with SBOM and provenance; a multi-stage example whose final image holds the binary alone (Docker image). - SQLAlchemy documentation corpus: the doctests of the 2.1.4 documentation replayed as routes against the binary (480 examples, none differing; the starting point of the 0.7 work).
Fixed¶
- A value stored in a
JSONcolumn (notJSONB, which PostgreSQL normalizes) was written as compact JSON,{"a":1}, where SQLAlchemy writesjson.dumps' text,{"a": 1};->>on an object or a list returned the other text. - A request-scoped
yielddependency of a streamed response closed before the stream instead of after it (without a session dependency). - An
HTTPExceptionwith a no-body status (204, 304) lost itsheaders=. - Generator endpoints under FastAPI < 0.141 follow that version: JSON Lines and Server-Sent Events responses
ignore
status_code=(200), and the items of a route declared on anAPIRouterare neither validated nor filtered by theirAsyncIterable[T]type (include_routerdrops it), as FastAPI does. x in yon a value that is no container raises Python 3.14's message ("is not a container or iterable") when the project runs on 3.14.
[0.5.1] — 2026-10-09¶
Fixed¶
- itsdangerous
loads(..., return_timestamp=True)answered 500. The keyword was accepted at transpile time and refused only at run time, so magic-link and 2FA logins built on it failed in production.URLSafeTimedSerializer.loadsnow takess, max_age, return_timestamp, saltpositionally or by keyword and returns(payload, datetime)with an aware UTC datetime, like itsdangerous 2.2;BadSignature.payload,BadTimeSignature.date_signedandSignatureExpired.date_signedare set as the library sets them. Any other argument is refused at transpile time withfile:line. isinstance(x, int | float)(andX | None,Optional[X],Union[X, Y]) raised "cannot be a parameterized generic" in the binary: a 2FA login of a production app answered 500. A union is now tested member by member, as CPython 3.10+ does.- Arguments the runtime dropped or ignored are now implemented or refused at transpile time (an audit of the library map after the bug above):
str.encode(encoding, errors)always produced UTF-8; it now implements utf-8, utf-8-sig, latin-1 and ascii witherrors=strict, ignore or replace (UnicodeEncodeErroras CPython).str(b, encoding)decodes likebytes.decodeinstead of lossy UTF-8.str.find/rfind/index/rindex/count/startswith/endswith(sub, start, end)andlist/tuple.index(x, start, stop)ignored their bounds;set.update(*others)read only the first;Match.groups/groupdict(default=)ignored the keyword.- Starlette responses given positional arguments read
FileResponse'sfilenameandcontent_disposition_typeat wrong indexes and dropped a positionalbackground;background=,stat_result=andmethod=are refused. MIMEApplication(data, Name=...),MIMEMultipart(..., **params)dropped their Content-Type parameters;policy=,boundary=,_subparts=,_encoder=and arguments ofas_string()/as_bytes()are refused.Path.read_text/write_text(encoding=, errors=, newline=)were ignored (andread_textdecoded lossily).- csv:
DictReader(restval=, restkey=)were ignored; a positional dialect is refused. - redis:
scan_iter("pattern:*")ignored a positional pattern and scanned every key;_type=andset(px=timedelta)were dropped. model_validate_json(strict=...)ignored its keywords; class methods such asModel.model_validate(...)now go through the transpile-time keyword check.- HTTP client responses (
resp.text(encoding=),resp.json(loads=)...), SQLcol.like(pattern, escape)given positionally, a second positional ofdequemethods andPattern.search(s, pos)raise instead of ignoring the argument.
[0.5.0] — 2026-10-09¶
Security¶
- Denial of service fixed (all earlier versions): a small request carrying a deeply nested JSON body could
overflow the stack of a generated binary and abort the process, without authentication. The decoder is now
iterative (400 past 10 000 levels, as CPython), the runtime threads get a larger stack (
PY2AXUM_STACK_SIZE), and recursive walks raiseRecursionErrorinstead of overflowing, so the worst outcome is a 500, never an abort. Upgrading is strongly recommended. - Security review of the runtime before 1.0: threat model, guarantees and differences with uvicorn/Starlette in
the new docs/security.md. Hardening of SQL identifiers given as strings, request input
handling (nesting, sizes, multipart limits, cookies),
Path.resolve(), response headers, bcrypt off the event loop, the--python-siderelay and the multiprocess Prometheus files. Upgrading is recommended. - New settings:
PY2AXUM_MAX_BODY(optional 413 on large bodies, off by default) andPY2AXUM_STACK_SIZE. - CI audits the runtime's
Cargo.lockwithcargo audit(tools/cargo_audit.sh).
Added¶
- Documentation site (MkDocs Material, https://larrymotalavigne.github.io/py2axum/), organised like FastAPI's:
getting started, tutorial, advanced, reference, release notes. Its examples (
docs_src/) are tested against the binary in CI. - pydantic 2.14 / pydantic-core 2.50 supported (range
pydantic>=2.12.0,<2.15,pydantic-core>=2.41.1,<2.51). The binary reproduces the minor the project locks: error URLs…/2.14/v/…; UUID messages of the uuid crate 1.23.4 (0-based positions,invalid length: found N, the requested form);EmailStrrefusing CR/LF;Decimalmax_digits/decimal_placescounted withoutDecimal.normalize()'s 28-digit rounding; an Enum_missing_that raises something other than aValueErrorpropagates. Projects on 2.12/2.13 keep their messages. See docs/supported.md, "Pydantic behaviours that follow the project's minor version". - The project's library version is also read from an
==pin inrequirements*.txt/pyproject.toml(onlyuv.lockwas read), or chosen inside the project's specifier when the installed one is outside it. - CI: the
versionsmatrix runs pydantic 2.12 (min), 2.13 (python -m py2axum.versions pydantic2.13) and 2.14 (max).
Fixed¶
x is Enum.MEMBERbetween Enum members was alwaysFalse, and a header parameterx: list[str] = Header()answered 422 instead of collecting every occurrence: two silent wrong answers found by the documentation's examples.model_config'sstr_strip_whitespace/str_to_lower/str_to_upperwere ignored onEmailStrfields.- An Enum
_missing_raising aValueErrorgave avalue_errorinstead of pydantic'senumerror; returning a value that is neitherNonenor a member gave theenumerror instead of Enum'sTypeError. Decimaldigit bounds compared only the normalized value: a value whose written form fits passes, as in pydantic.- Refused instead of mistranslated: a
default_factorytaking the validated data (lambda data: ..., called without arguments before), length constraints onIterable[T].
[0.4.0] — 2026-10-08¶
Breaking¶
- The typed backend is removed (
--backend typed,codegen.py,body.py,runtime/rt.rs). It emitted statically-typed Rust for simple CRUD handlers only and covered none of the real applications measured; every application already went through the general backend.--backend typednow exits with an error ("removed in 0.4: use the default backend");--backend autoand--backend dynare accepted until 1.0 and change nothing.py2axum checkand--reportno longer try it first;check --jsonkeeps"backend": "dyn".
Added¶
- Large list responses streamed at constant memory (what only the typed backend did): an endpoint with
response_model=list[Schema]returning(await session.execute(stmt)).scalars().all()(orsession.scalars, or through a local) reads the rows with a cursor and sends the JSON array in 64 KiB blocks — 57 MB served with 13–16 MiB of RSS instead of 1.6 GiB, first byte after 16 ms instead of 2.9 s, identical bytes. Only when nothing observable changes (the session wrote nothing, one mapped class without loader options, a schema of plain columns...);PY2AXUM_STREAM_CHUNK,PY2AXUM_STREAM_MIN_ROWSand--no-streamkeep their meaning. See docs/supported.md, "Large list responses". app.state(set in the lifespan, read throughrequest.app.state).- Raw ASGI middleware: a project class with
__init__(self, app, ...)andasync def __call__(self, scope, receive, send), registered withapp.add_middleware(...)orFastAPI(middleware=[Middleware(...)]), runs natively in Starlette's stack around the router (shared scope, uvicorn'sreceive,sendchunked without acontent-length, rewritten scope as a new request, wrappedreceive/send, short-circuit answers, exceptions raised through the stack).starlette_context'sRawContextMiddlewarewithRequestIdPluginandCorrelationIdPlugin,FastAPI(strict_content_type=)andtraceback.format_exception(...)/format_exception_only(exc)are translated too. An application without raw middleware gets no extra layer; each one costs about 2.5 µs per request. See docs/supported.md. create_engine(connect_args=...)/create_async_engine(connect_args=...)applied to every pooled connection: libpqoptions(-c name=value,-cname=value,--name=value, split like libpq,\escapes) and asyncpg'sserver_settings. ATimeZonesetting overrides the time zone discovered from the server, so timestamps decode in the session's zone as psycopg does (they kept the server's zone before). Any other literal libpq option is refused at transpile time.- PyJWT 2.15 (
import jwt) translated natively, like python-jose:jwt.encode(algorithm=,headers=,sort_headers=;exp/iat/nbfdatetimes encoded from a copy),jwt.decode/decode_complete(everyoptions=key,audience=,issuer=,subject=,leeway=as a number or a timedelta),get_unverified_header, with HS256/HS384/HS512 andnone: identical tokens, the same checks in the same order, thejwt.exceptionshierarchy and messages. An asymmetric algorithm written as a literal,json_encoder=,detached_payload=,verify=andPyJWK/PyJWKClientare refused at transpile time.
Changed¶
- Assigning another module's attribute (
config.LIMIT = 2) is refused at transpile time withfile:line; the binary used to fail at startup. json.loads(bytes)with invalid UTF-8 raises CPython's exactUnicodeDecodeErrormessage.JSONBcolumns:contains(@>),contained_by(<@),has_key(?),has_any(?|) andhas_all(?&) are compiled to PostgreSQL's operators, their argument typed as SQLAlchemy types it. They raised aTypeErrorat run time (a 500 where Python answered).- A path left to Python (
--python-side) answers FastAPI's 404 whenPY2AXUM_PYTHON_URLis unset, as documented; it used to fall through to a translated route whose pattern also matched (/books/export.zipreached/books/{book_id}and answered 422). - An exact pin equal to the lowest version a feature needs is accepted:
starlette==1.7.0was refused for WebSocket routes ("need starlette>=1.7.0"). tests/conformance.py --scenario path/to/scenario.py(andtests/difftest.py) load a scenario file from anywhere, so an application can keep its scenario in its own repository.- New example,
examples/bookshelf: users, JWT Bearer authentication (PyJWT), CRUD, relationships, 422s, a WebSocket and one route left to Python; its conformance scenario (75 steps) andcompare.shrun in CI.examples/docker: a reference multi-stage Dockerfile, the Python sidecar and a Compose file for the hybrid mode.bench/bench.py --target bookshelfmeasures it against FastAPI. - Documentation: a getting-started guide (
docs/getting-started.md), the conformance guide rewritten for users, the README,docs/supported.mdanddocs/how-it-works.mdreviewed for first-time readers. - Translated natively:
validate_assignment=Truewith field and model validators (before, type, after withinfo.data, modelafter, as pydantic-core; refused with a modelbeforevalidator),Field(min_items=, max_items=),File(default=[]), a SQL column name other than the attribute (Column("metadata", JSON)), theundefer()loader option (top level,session.get(options=),selectinload(...).options(undefer(...))),exists().where(...)with SQLAlchemy's auto-correlation,regexp_match,delete().returning(columns),...as a sentinel value,collections.Counter,asyncio.open_connection(asyncio's or uvloop's messages, after the project's lock),socket.create_connection,cryptographyRSA key generation and serialization (DKIM), andzipfile.ZipFile(io.BytesIO(), "w")with CPython's bytes. - A pydantic
ValidationErrorraised by an assignment carries the model'stitle(andstr(e)names it). - A crate generated from an application with
GZipMiddlewarefailedcargo build --locked: the shippedCargo.lockdid not listtower-httpamong the root crate's dependencies. It is now always declared. - Fixed: a list of
UploadFileholding an empty string was treated as absent; FastAPI reports it (422).str.splitlines()split on\nand\r\nonly (CPython's line boundaries now:\r,\v,\f,\x1c-\x1e,\x85, U+2028/2029); iterating anio.BytesIO(aStreamingResponseover one) raised.
[0.3.1] — 2026-10-08¶
- An Enum member given to a scalar field is validated as pydantic-core does: an ORM enum column read into
status: stranswered 500 (string_type) where Python returned the member's value (found in production). Astr/intsubclass member is its value forstr,int,float,boolandLiteral(anintone isstr(value)in astr); a plain member isstr(value)in astr, its value unchecked in an unconstrainedint. - Graceful shutdown in every generated binary (it only existed with a lifespan or
sentry_sdk; otherwise SIGTERM killed in-flight requests): as uvicorn 0.54, new connections refused, idle keep-alive connections closed, in-flight requests finished withconnection: close, WebSocket sessions closed with 1012 on both sides, a second signal forces the exit, and the process ends by the signal it received (status 143, uvicorn re-raises it; it was 0). The pool is closed before the exit. - Memory leaked at every call (dyn backend), found by the endurance test: an MCP
tools/call(its arguments' validator),column.op("...")(value)(the operator) andjson.dumps(separators=...)(the separators). Each is now built once per distinct value; a test forbidsBox::leakoutside those caches. bench/bench.py --target dynapp --compare: a performance guard comparing a generated binary with a reference.- Differential testing (
tests/difftest.py, extradifftest):replayreplays traffic recorded on the Python app (py2axum.record, an optional ASGI recorder writing anonymised JSONL) against both servers from the same database snapshot, with ddmin minimisation;gengenerates valid and invalid requests from the OpenAPI schema (schemathesis 4 + hypothesis, fixed seed) plus a corpus of edge cases per operation. - asyncpg (0.31) as the database driver, read from
DATABASE_URL;on_conflict_do_nothing/update(constraint=). BaseHTTPMiddlewarewithout its own__init__;deferred()columns; Jinja2env.filters[name] = f;p: Model = Depends()(one query parameter per field);text(...).bindparams();super()in a model's classmethod;aiosmtplib.send(sender=, recipients=);desc("label")and labels in GROUP BY / ORDER BY;update(...).returning(Model)refreshes the session's objects;session.refresh()keeps loaded relationships;StreamingResponseover a synchronous iterable.
Fixed¶
- A route reading a module global filled by a function that does not translate (a registry filled in place by
module-level statements: a loop,
REG[k] = lambda: f()) was reported native and the error lost: it is now blocked (--python-side automoves it). Module-level item and attribute assignments run at startup; an assignment to an unmapped library's attribute (stripe.api_key = ...) stays on the Python side. - Request bodies decoded as CPython's
json.loadsdoes (BOM, UTF-16/32, NaN, exact positions and messages, too deep nesting); anullbody is absent; FastAPI'sstrict_content_type; Starlette's form limits and multipart error message follow the locked Starlette version (1.4, 1.7); Starlette's redirect slashes. - Pydantic: bool from float/Decimal/bytes, int from str,
int_parsing_sizeandbool_typebeyond 64 bits, timestamps parsed as speedate does, empty URL;exclude_noneon extras;response_modelserialised throughdump_json(NaN and infinities becomenull). - Dates: years 1–9999 (
OverflowError),timedelta.days,date()/datetime()checks and messages of CPython 3.12/3.14;format()of-0.0,nan,inf; Python whitespace U+001C–U+001F;str.is*()from the translating Python's Unicode data; paths containing NUL. - SQL: an integer out of the column's range raises
DataErroras psycopg does, the exact psycopg class per SQLSTATE,tuple_(...).in_(...)without a cast; JSON columns keep float round-trips. request.url.pathwithout tabs and newlines; AMQP names validated as pamqp does (no panic); a status code outside 100–599 drops the connection as uvicorn does.
[0.3.0] — 2026-10-08¶
py2axum check <package>: every route marked native, python-side or refused with the reason atfile:line, the blockers ranked by routes touched, without generating a crate;--json, and an exit status for CI (1 when generation would fail,--fail-under PCTon the share of native routes).-
Supported version ranges (FastAPI 0.137–0.142, Starlette 1.0–1.7 (1.7 for WebSocket routes), Pydantic 2.12–2.13, pydantic-settings 2.11–2.15, SQLAlchemy 2.0.44–2.1, psycopg 3.2.12–3.3, httpx 0.28, aiohttp 3.13–3.14, mcp 2.2, Python 3.12–3.14), tested at both ends in CI. A project locking or pinning a version outside them (
uv.lock,requirements*.txt,pyproject.toml) is refused withfile:line;--allow-untested-versionsoverrides. -
await conn.run_sync(Base.metadata.create_all)(dyn backend), e.g. in the lifespan: the DDL is compiled at translation time by SQLAlchemy from the mapped classes rebuilt statically (no project code runs), then replayed with SQLAlchemy's checkfirst (enum types, tables, indexes, comments, foreign keys of cycles).async with engine.begin() as conncommits on exit.--python-side lifespanis no longer needed for it. - WebSocket routes (dyn backend):
@app.websocket/@router.websocketwith dependencies and path/query/header parameters, Starlette'sWebSocket(accept, receive/send in every form, close,iter_*, denial responses, states), uvicorn's handshake outcomes (403, 500, 1006), exception handlers called with the WebSocket;async forover async iterators. The typed backend refuses@router.websocket(it was skipped silently). -
x: Annotated[T, Query()/Header()] = defaultis optional (it was required);request.url.pathis percent-decoded, as Starlette builds it fromscope["path"]. -
sentry_sdkreports to Sentry (it was a no-op): a native client on the Rust SDK sends the same events as the Python SDK (scopes per request, FastAPI/Starlette and logging integrations, transactions, scrubbing,before_send), each taggedpy2axum.sourcewith the route'sfile.py:line. Options the binary cannot reproduce (transport=,before_breadcrumb=, profiling...) are refused. An app factory'sinit_sentry()runs at startup.tests/sentry_check.pycompares what a fake Sentry server receives. -
dateutil.relativedelta(relative fields),xmltodict.parse(default options, namespaces) withExpatError, Pydantic private attributes (PrivateAttr,_name),session.connection()and itsclose()(readiness probes),__getattr__/__setattr__on plain classes (object.__setattr__), enum members with list/tuple values (strmixin:("A",)is"A"), a module-level endpoint shadowed by a later function of the same name. -
Applications served next to Starlette:
response_class=wraps the returned value (HTML, plain text, redirect, file);uuid.UUIDvalidated as pydantic-core does andUuidcolumns read and written asuuid.UUID;HTTPBasic;Jinja2Templates(directory=)andTemplateResponse;importlib.metadata.version("literal")resolved at compile time frompyproject.tomlanduv.lock. An app wrapped in a project ASGI class (app = Middleware(api)) is refused (it was served without the wrapper). app.mount(...)registered last can stay on the Python side (--python-side mount, orauto).- Synchronous SQLAlchemy (already in 0.2.0, not listed there):
sessionmaker, a synchronous generator dependency, real lazy loads (also while aresponse_modelreads the object), the legacysession.queryAPI. Now also: self-referential relationships (remote_side=), bound parameters shared by an expression reused inGROUP BY, ORM@propertys read by a response model,awaiton a synchronousSession(CPython'sTypeError). model_json_schema()computed at translation time;sqlalchemy.inspect(x, raiseerr=False);unicodedata.normalize/combining;json.dumps(sort_keys=, indent=);bytes.decodeand bytes methods;BaseModel.dict();request.base_url;join(subquery, on);zip(strict=);date/datetime.replaceand their methods read as values; f-string format specs through__format__; class attributes read on a plain or Pydantic class;Annotated[T, Header()] = default; python-side routes under a prefix read from the settings.collections.dequeanddefaultdict(deque); single inheritance of plain classes,abc.ABCand@abstractmethod;nh3(clean,clean_text,is_html) on theammoniacrate;socket.getaddrinfo,ipaddress.ip_address/ip_network;email.mime.base.MIMEBaseandemail.encoders.encode_base64;Fernet.generate_key();string.Template(substitute,safe_substitute).break/continueleaving atry/finallyor awith: thefinally(or__exit__) runs first, as in CPython.
Fixed¶
- Length constraints of
list/set/tuple/dictfields and keyword arguments of builtins were ignored silently: applied as pydantic-core does, refused otherwise. A validator's error carries the raw input. email.encoders.encode_base64()of a message without payload raises CPython'sTypeError(it encoded nothing).- An app factory's
init_sentry()is refused only for the functions it reaches (functions queued by other code were blamed on it).
Changed¶
sentry_sdkis no longer a no-op (see above): a project whoseinit_sentry()passestransport=(a Python transport class) is refused at translation time; it used to be skipped silently.
[0.2.0] — 2026-10-07¶
FastAPI(lifespan=...)is translated: the code beforeyieldruns before the server listens, the rest after a graceful shutdown on SIGTERM/SIGINT (PY2AXUM_SHUTDOWN_TIMEOUT). Async generators run in lockstep with their consumer (anext,asend,athrow,aclose),@asynccontextmanager,contextlib.suppress,contextvars.ContextVar,Task.cancel().- Raw ASGI routes:
app.add_route(path, obj)with an object whose class definesasync __call__(scope, receive, send);Request(scope, receive), response objects called as ASGI apps. - MCP servers (
mcp2.2):MCPServer,@server.tool(async, structureddict[str, Any]output),ToolError, streamable HTTP without state and with JSON responses, the JSON-RPC envelope validated like the SDK. Tool argument schemas are computed at compile time (pydantic 2.13 JSON schema,py2axum/jsonschema.py). -
str(ValidationError)in pydantic-core's format where the model is known. -
Replacing
aiohttp.ClientSession._request/httpx.AsyncClient.requestwith a wrapper (timing outgoing calls): the clients call it; other assignments to library attributes are refused. -
A project function given the app in the factory (
configure(app)) runs while the middleware stack is built:app.add_middlewareof projectBaseHTTPMiddlewaresubclasses,app.add_route(it was skipped). Routing objects as values:request.app,app.router.routes,route.matches(scope),Match,request.scope.timeit.default_timer. -
--python-side auto: every route that does not translate (and rawadd_routeroutes with a literal path) is left to the Python application, each printed with the error that blocked it. -
Module-level
try/if/for/while/withstatements run at startup (they were skipped), their bindings becoming module variables; import fallbacks (try: import x except ImportError: ...). -
sys.settrace/threading.settrace_all_threads: the project's functions report call/return/exception events to the trace function (function call metrics);traceback.extract_tb. Module-level functions are single objects; nested functions may name themselves. - prometheus_client:
start_http_server, OpenMetrics output, exemplars, name escapings, restricted registries. - httpx / aiohttp responses keep their
Content-Encodingheader (the body is decoded by the runtime, not reqwest). - prometheus_client multiprocess mode: the per-process files (shared with Python workers) and
MultiProcessCollector,mark_process_dead. -
prometheus_client 0.26:
Counter,Gauge,Summary,Histogram,Info,Enum, labels,time(),count_exceptions(),track_inprogress(),CollectorRegistry,REGISTRY,generate_latest(text format byte for byte). -
Typed backend: request bodies above 2 MB are read like Starlette does (axum answered 413).
[0.1.0] — 2026-10-07, first public release¶
- Ahead-of-time compilation of FastAPI + SQLAlchemy 2.0 (async, PostgreSQL) + Pydantic v2 applications to Rust (axum, tokio, sqlx): the dyn backend (general) and the typed backend (simple CRUD).
- Runtime reproducing CPython, Pydantic v2, SQLAlchemy and Starlette semantics, plus a closed list of standard and third-party libraries (docs/supported.md).
--report: per-route coverage report with the first blocking construct (file:line).--python-side: paths andlifespanleft to the Python application, relayed by the binary whenPY2AXUM_PYTHON_URLis set.- Differential conformance harness (
tests/conformance.py) and reference applications. pip install-able package with apy2axumcommand; example application with a multi-stage Dockerfile.- aio-pika 10: connect, channels, durable queues, publishing with confirms,
queue.get(). json.loadsaccepts bytes (UTF-8/16/32 detection, like CPython).importlib.import_moduleof a literal project module (module objects),callable().collections.defaultdict(builtin type factories),string.Formatter().vformat/format;str.formatgains!r/!sand CPython's error messages.- tenacity 9 (
@retryand its strategies).g/e/G/E/X/o/bpresentations and#informat()and%;ConnectionErrorand its subclasses;logginglevel constants andLogger.log.