Core
The server, the application contract, request/response, and the shared types.
Server
The base server: the applications it serves, ASGI dispatch, uvicorn boot.
BaseServer is the common substrate of every server (SPECIFICATION.md §4,
D2): it is composed with its applications (applications= kwarg, a list)
and keeps them in a dict keyed by each app’s code, plus a private index by
mount — the one demux mechanism of D3. The set of applications is fixed at
construction; registration is internal. At the base,
authenticate() answers nobody (None) and session() answers none
(None). It owns exactly one thread pool (D2): run_sync() dispatches a
blocking handler onto it via loop.run_in_executor while async handlers stay
on the loop; the pool is provisioned lazily on first use and torn down at
shutdown.
As an ASGI callable, __call__ dispatches on the scope type: http runs
the D3 demux — first path segment → the app mounted there with that segment
stripped; else the app on the site root with the full path; else a 307 from
/ to the declared default; else 404 — websocket runs
on_websocket,
whose DEFAULT is the empty socket of D7 (accepts nothing, closes cleanly with
code 1000); lifespan runs the Lifespan handler (ordered startup,
reverse shutdown, error isolation). Each http dispatch is registered in the
RequestRegistry (requests) for the span of the request — the current
request and the in-flight picture. serve() boots uvicorn programmatically.
Cooperative init (D16): peels its own kwargs (applications,
max_threads) and, as the end of the chain, raises TypeError naming any
leftover kwargs. Mixins go BEFORE BaseServer in the MRO.
Ownership channel (one direction): registering an application assigns
app.server = self; the app-side setter enforces exactly-once.
- genro_asgi.server.REFUSED_RETRY_AFTER_SECONDS = 5
The seconds a refused request is told to come back in.
- class genro_asgi.server.BaseServer(**kwargs)[source]
Bases:
objectBase server owning the applications it was composed with.
Constructor kwargs peeled here:
applications— the applications this server serves —default— thecodeof the application/redirects to when nothing answers the root (an unknown code raisesValueError) —max_threads— the pool’s worker count, handed toWorkPool(Nonekeeps the stdlib default) —websocket— the websocket options,{"origins": [...], "max_concurrent": 16}— andshutdown_timeout_seconds— how long uvicorn waits for open connections to finish before it cancels them at shutdown (5.0). Without a bound, one endless response — an SSE stream a client never closes — holds the server for ever and the lifespan shutdown never runs (measured 2026-09-08).- Parameters:
kwargs (Any)
- state
RUNNING,QUITTINGorSTOPPING— read by the entry point.
- shutdown_mode
What
statebecomes at the lifespan shutdown when nobody chose first.STOPPING— down dry — unless the trigger declares its exit saves: the--reloadlauncher setsQUITTINGhere, the deliberate command will setstateitself before the shutdown arrives.
- debug
False, True, or the parameters it was given.
A flag and nothing else (owner, 2026-08-25): the core branches on it nowhere. It exists so future readers — extra middleware, extra checks — can behave differently knowing the server runs in debug.
- Type:
The declared usage mode
- property applications: dict[str, BaseApplication]
The served applications keyed by their
code.
- property root_application: BaseApplication | None
The application on the site root (
mount == ""),Noneif there is none.It answers
/and every path no other mount claims. A server of mounts only has none: then/redirects to thedefaultif one is declared, and an unclaimed path is a 404.
- property default_application: BaseApplication | None
The application
/redirects to,Noneif nodefaultwas declared.It elects nothing: the redirect is the whole of its meaning, and it is only consulted when no application answers the root.
- application_at(mount)[source]
The application answering under the URL prefix
mount(Noneif none).- Return type:
- Parameters:
mount (str)
- register_application(app)[source]
Register
appunder itscodeand itsmount.Assigns the ownership channel (
app.server = self). Internal: the set of applications is fixed at construction, so the callers are__init__and the composition layers building a server. A claimed code and a claimed mount both raiseValueError.- Return type:
- Parameters:
app (BaseApplication)
- property requests: RequestRegistry
The registry of in-flight requests and the current one.
- async run_sync(fn, *args)[source]
Dispatch blocking
fnonto the pool (the app-side sync protocol).Apps call
self.server.run_sync(...)for blocking work so it runs off the event loop; async handlers simply stay on the loop and never touch the pool.
- get_middleware(middleware_class)[source]
Base answer: none (
None). The middleware capability overrides this.
- property websockets: WebSocketRegistry
The live websockets, and which one each page speaks on.
- async send_message(page_id, path, data=None)[source]
Write one message of the server’s own onto the socket a page speaks on.
- Parameters:
- Return type:
- Returns:
Truewhen the message was written to a socket,Falsewhen that page speaks on none or its socket is already closed.
The message has the shape of a request and carries NO
id: it is not an answer, and nobody answers it — a page that wants to reply sends an rpc of its own, on thereply_pathit asked for or on a path of its choosing. DELIVERED means written to the socket, never executed by the page: nothing here waits for anything.
- async send_serialized_message(page_id, path, payload)[source]
Forward an explicitly serialized application value to its page.
- property websocket_origins: list[str]
The Origins a handshake may come from; empty means same-origin only.
- property websocket_max_concurrent: int
How many messages of ONE connection may be in flight at once.
- demux(scope)[source]
D3 demux: pick what answers an http scope, and the scope it receives.
One rule, four branches: the first path segment matching a mount → that app, with the segment stripped from
path(the forwarded path is rebuilt from the same remainder used to find the segment, so//api/xforwards/x); else the application on the site root, with the full path unchanged; else, for/itself with adefaultdeclared, a 307 to that application’s mount carrying the query string over; else 404./on a server WITH a root application matches its empty mount in the first branch, which forwards the same/.- Return type:
tuple[Callable[[MutableMapping[str,Any],Callable[[],Awaitable[MutableMapping[str,Any]]],Callable[[MutableMapping[str,Any]],Awaitable[None]]],Awaitable[None]],MutableMapping[str,Any]]- Parameters:
scope (MutableMapping[str, Any])
- redirect_to_default(app, scope)[source]
A 307 to
app’s mount, preserving the query string.307 and not 301/302: the method and the body must survive the hop, so a
POST /reaches the default application as a POST.- Return type:
- Parameters:
app (BaseApplication)
scope (MutableMapping[str, Any])
- async on_websocket(scope, receive, send)[source]
Live one websocket connection: the motor, or the application’s own hands.
One
WsxConnectionper socket does the whole thing (#68): it judges the handshake, accepts it, turns every message into a request the demux routes like any other, and answers the ones that carry anid. The connection is registered inwebsocketsfor its whole life.The state is judged FIRST, above the demux, for every websocket: a server that is not
RUNNINGtakes no new connection in charge, and the handshake is turned away before the accept. The browser sees the handshake fail with no readable code — 1013 exists only after an accept, and in the raw mode the accept belongs to the application — but the state is the machine’s business and not the protocol’s, exactly as it is on the http branch.The exception is an application that wants the socket ITSELF: the handshake’s path names it through the same demux, and if it defines
serve_websocketit is handed the raw scope, receive and send, with the segment of its mount already taken off the path. Nothing else of the motor runs then — no accept, no Origin gate, no registry: an application that takes the socket takes all of it, and the core does not half-serve a connection it does not hold. It is the admitted mode of the design, the one a hosted framework with a websocket protocol of its own reaches the server by.- Return type:
- Parameters:
scope (MutableMapping[str, Any])
receive (Callable[[], Awaitable[MutableMapping[str, Any]]])
send (Callable[[MutableMapping[str, Any]], Awaitable[None]])
- property shutdown_timeout_seconds: float
How long uvicorn waits for open connections at shutdown before cancelling them.
- property uvicorn_server: Server | None
The uvicorn
Serveronceserve()has built it (elseNone).Callers that boot the server in a background thread read the bound port from
uvicorn_server.servers[0].sockets[0].getsockname()afteruvicorn_server.startedturns true.
- serve(host='127.0.0.1', port=0)[source]
Boot uvicorn programmatically, serving this server (blocking).
Builds
uvicorn.Config/uvicorn.Serverand runs it.port=0lets the OS assign an ephemeral port, discoverable viauvicorn_serveronce started.shutdown_timeout_secondsbounds uvicorn’s wait for open connections, so a response that never ends cannot keep the lifespan shutdown — and the applications’ own stop — from running.
AsgiServer — the shipped mono-process server composition (D22, D6, D16).
AsgiServer stacks every core capability mixin over BaseServer in one
MRO (CommunicationMixin, AuthMixin, SessionMixin, MiddlewareMixin,
PluginMixin, StorageMixin, TaskMixin, BaseServer): the complete mono-process
async server of D22. TaskMixin sits after StorageMixin (it needs
server.storage) and before BaseServer (its lifespan hook must wrap the
base Lifespan). The future internal (worker) server simply composes the SAME
base WITHOUT the auth mixin (D6 by construction — the base never learned about
the chain).
The server is SELF-CONFIGURING: AsgiServer(config=source) builds its own
read door — a ConfigurationHandler over a config.py path, a recipe class,
a recipe instance or a ready handler — derives its constructor kwargs from it and
then runs the ordinary D16 cooperative chain. Nothing materializes a server from
the outside; the class that needs the values reads them. Explicitly passed
kwargs WIN over the configured ones, wholesale per kwarg
(AsgiServer(config=Recipe, port=0) serves the recipe’s site on an
OS-assigned port), and the handler stays reachable as server.config — the
read door applications delegate to. A bare AsgiServer(...) has
config is None and behaves exactly as before.
Its cooperative __init__ peels the kwargs the frozen Macro 1 BaseServer
does not accept — host/port/external_url plus server_app (the
login-surface values of the authentication section) — and forwards
everything else (applications, auth, session_store/session_ttl,
middleware/middleware_registry, plugins/plugin_registry,
storage/storage_key, parent) down the D16 chain. The peeled
host/port become the defaults of serve, so a configured server
serves on its configured address unless the caller overrides it.
host/port are the LISTENER; external_url is the server’s PUBLIC
base address — the two differ behind a proxy and answer different questions.
The listener says where to bind; the public address is what the server calls
itself when it hands its own URL to a third party. Only one consumer needs it
today (an OIDC provider is given an absolute redirect_uri, RFC 6749
§3.1.2), and it is DECLARED rather than derived from a request: the URI must
match the one registered with the provider — a deployment fact known to
whoever installs — and deriving it from the client-supplied Host would
build a value the provider then rejects. Missing it with a provider
configured is a boot error (_check_oidc_external_url), not an opaque
provider error at the first login.
Once the chain has run, __init__ registers the automatic _server app
(_register_server_app, D4 “automatic, not configured”): a hand-built
AsgiServer(applications=[...]) exposes /_server/... exactly like a
configured one, and no configuration path special-cases it. The configured
databases are registered right after, over the live server.
- class genro_asgi.asgi_server.AsgiServer(config=None, **kwargs)[source]
Bases:
CommunicationMixin,AuthMixin,SessionMixin,MiddlewareMixin,PluginMixin,StorageMixin,TaskMixin,BaseServerThe shipped composition: communication + auth + sessions + chain + plugins + storage + base.
Constructor kwargs peeled here:
config— the configuration source this server reads itself from —hostandport(theservedefaults),external_url(the public base address, trailing slash stripped) andserver_app(the login-surface values forwarded to the automatically registered_serverapp). Every other kwarg flows to the capability mixins and the base (D16 cooperative init).- Parameters:
config (ConfigSource | None)
kwargs (Any)
- grammar
alias of
AsgiServerGrammar
- property config: ConfigurationHandler | None
The read door over this server’s configuration (
Nonewhen built bare).Callable as
server.config("server.host")— the four-layer read stack of theConfigurationHandler— and the door applications delegate to with their ownapplications.<code>.prefix.
- property login_enabled: bool
True when the
_serverapp carries a registered auth method.The challenge negotiation (
ErrorMiddleware) reads this to decide whether a 401 becomes a login redirect (browser) or alogin_urlbody (API). It reflects live state:ServerApplicationregisters the password method at construction, so its server has a login surface.
- property external_url: str | None
The server’s public base URL, without a trailing slash (
Noneif unset).What the server calls ITSELF when it hands its own address to a third party — distinct from the
host/portit binds to, which differ behind a proxy. Declared in the config’sserversection; the only consumer today is the OIDCredirect_uri, which must be absolute.
Applications (contract)
App-side contract: the base class every mountable application extends.
BaseApplication is what the server requires of an app (SPECIFICATION.md
§4, D7): an ASGI callable (__call__ implemented by concrete subclasses)
with an identity (code) and a placement (mount), a server
property assigned exactly once by the owning server at attach time (ownership
channel, one direction — a second assignment raises RuntimeError), and
lifecycle hooks on_startup/on_shutdown that subclasses may override
as sync OR async (the caller detects which at call time).
An application is a triplet code + instance + mount. code names it
(the key of server.applications); mount is the URL prefix it answers
under, and "" is the site root — a legitimate value, never a “missing”
one. Both are class attributes a subclass sets declaratively and a
constructor kwarg overrides per instance, so the same class can be installed
twice under different codes:
class Shop(RoutedApplication):
mount = "" # this app is a site root by design
Shop(code="outlet", mount="outlet")
Cooperative init (D16): every class in the family implements
__init__(self, **kwargs), peels ITS OWN kwargs and forwards the rest via
super().__init__(**rest). Mixins go BEFORE the base in the MRO; this base
is the end of the chain and raises TypeError naming any leftover kwargs.
Every application also carries its own CONFIGURATION GRAMMAR as the class
attribute grammar, inherited by MRO like code/mount: the site recipe
mounts it at the application(app_class=...) line (subbuilder by reference),
so the app declares its own vocabulary and the site dialect never validates it.
ApplicationGrammar is the minimal one every app inherits — a single
parameters element for free options — and a richer app subclasses it.
An app READS that subtree back through self.config(path), which prefixes
applications.<code>. and delegates to the server’s own read door: the app
never holds a slice of the tree, only an address in it.
- class genro_asgi.application.ApplicationGrammar[source]
Bases:
objectThe configuration grammar every application inherits.
One element,
parameters, for the free options a plain app needs: an application with nothing of its own still has a mountable grammar (an EMPTY grammar class is rejected by builders), and a richer app subclasses this to add its own vocabulary.- parameters = <genro_builders.builder._decorators._DeclarativeMarker object>
- class genro_asgi.application.BaseApplication(**kwargs)[source]
Bases:
objectBase class for applications attached to a
BaseServer.Constructor kwargs peeled here:
code— the application’s identity, empty meaning the class name lowercased — andmount— the URL prefix it answers under,Nonemeaning the same as the code. Both default to the class attributes below, so a subclass can set them declaratively.- Parameters:
kwargs (Any)
- grammar
alias of
ApplicationGrammar
- property server: BaseServer | None
The server that owns this app (
Noneuntil attached).
- config(path, default=<object object>)[source]
Read this application’s own configuration through the server’s read door.
Paths are relative to the application:
self.config("parameters.title")readsapplications.<code>.parameters.title, so an app addresses its own mounted subtree and never a slice of someone else’s. The four-layer read stack (written value → signature default → call-sitedefault→ noisyKeyError) is the handler’s, untouched.With nothing to read — no server, or a server built bare — the call-site
defaultanswers, and its absence raises the same noisyKeyError.
- property handshake_cookie: str | None
The cookie a websocket handshake must carry to reach this application.
- Returns:
The cookie’s name, or
None— this application gates nothing at the handshake, which is the base answer.
The path of a handshake names its home application, and the server asks THAT application whether a connection is admissible before accepting one. An application whose messages only make sense for a known connection — the SPA, whose every message is a request of a user — names its cookie here, and a handshake without it is accepted and closed with 1008, so the browser reads why.
- property app_snapshot: dict[str, Any]
This app as the monitor sees it, at the instant it is read.
The monitor aggregates one entry per mounted application by reading this on each. Subclasses extend it with their own panel data (registers, pool, gauges) on top of these identity facts.
- property app_panel: dict[str, Any]
a class constant.
The presentation complement of
app_snapshot: the snapshot carries the data (polled), this says who draws it (fetched once).panelnames a renderer in the shell’s registry and an unknown name falls back to the generic one;src— when a subclass declares it — is the module URL the shell imports to learn that renderer.- Type:
Which panel renders this app on the monitor shell
RoutedApplication: the application base wiring genro-routes into the core.
RoutedApplication composes the app-side contract (BaseApplication)
with the genro-routes RoutingClass: handlers are @route-decorated
methods on subclasses, and external RoutingClass instances mount as
sub-trees via add_branches({"name": ..., "instance": child}). The constructor peels
db_name (the request.db seam resolves it against the server’s
database registry) and plugs the auth plugin on the app router, so
entries declaring auth_rule are filtered by the request’s authorization
tags — attached children inherit the plug.
The config-driven plugins (the openapi dialect today) are armed LAZILY:
on the first route access made after the app is attached to a server, the
app calls server.arm_router(self.route) (once, guarded), so a server built
with a plugins section plugs them onto every routed app it hosts. A
composition whose server lacks the PluginMixin exposes no arm_router
and arms nothing — the app degrades to the auth plug alone.
The ASGI dispatch (__call__) is the per-app routing engine: build a
Request bound to this app, eager-parse it (init), resolve the node
from the request path — already mount-relative, the server demux strips the
prefix (D3) — with the identity tags of scope["auth"] as auth filters,
bind kwargs, execute (async handlers stay on the loop, sync handlers go
through server.run_sync — the Macro 1 pool protocol), then answer via
request.response.set_result(value, metadata). Resolution failures raise
core exceptions (ROUTER_ERRORS: unknown or unavailable path →
HTTPNotFound; a ruled entry denied with no identity → HTTPUnauthorized,
with an identity whose tags do not match → HTTPForbidden) that propagate to
the server’s ErrorMiddleware.
A call the handler cannot take becomes an HTTP answer — never a 500 — on the
two exception codes genro-routes distinguishes: signature_error (the bind
against the handler signature fails: unknown keyword, missing required
argument, too many positionals) → HTTPBadRequest (400);
validation_error (the signature is satisfied and pydantic rejects the
values) → HTTPUnprocessableContent (422). The handler BODY is mapped to
neither: whatever it raises — a TypeError included, sync body or async —
propagates and reaches ErrorMiddleware as a 500. There is no local
cleanup drain: the server finally owns end-of-request cleanups.
Kwargs binding: bind_kwargs starts from request.handler_kwargs()
(query + body by content-type) and reconciles a hydrated JSON body with a
scalar-parameter handler — when the node’s neutral params block declares
fields (pydantic plugin) and the handler does not itself absorb body_data
or **kwargs, the body dict is spread over the declared names through
spread_over_params (extras dropped). Auth without the middleware: when
scope carries no auth key the resolution runs unfiltered — the public
router exposes exactly what the auth plugin leaves untagged.
- class genro_asgi.routed_application.RoutedApplication(**kwargs)[source]
Bases:
BaseApplication,RoutingClassApplication base serving
@routehandlers through the app router.Constructor kwargs peeled here:
db_name— the server database code therequest.dbseam resolves for this app (Nonefalls back to"default"). The rest flows down the D16 chain (code/mounttoBaseApplication).- Parameters:
kwargs (Any)
- property route: Router
The app router; arms the server’s configured plugins on first access.
The first access made once a server owns this app triggers
server.arm_router(once, guarded by_armed); accesses before attachment — theauthplug in__init__— arm nothing.
- auth_filters(scope)[source]
Auth filters for node resolution, from the scope identity.
An
Avataronscope["auth"]becomes the comma-separatedauth_tagsthe auth plugin evaluates entry rules against. No identity — key absent (middleware off) orNone(anonymous) — passes no filter: the plugin still denies every ruled entry.
- make_callable(node, request)[source]
Package the node invocation as the zero-arg call the dispatcher runs.
The node declares its handler’s nature (genro-routes marks async entries so
asyncio.iscoroutinefunction(node)is honest); the dispatcher reads that same nature to pick the vehicle — the returned async callable is awaited on the loop, the sync one goes through the server pool.bind_kwargsdecides the arguments.
- route_cleanup()[source]
Per-dispatch cleanup on the executor thread — a consumer seam.
The sync dispatch runs this on the SAME pool thread the handler just ran on, after it returned or raised: the place to release whatever thread-local resources the handler’s code opened (a legacy db connection lives and must die on its own thread). No-op by default, same consumer-seam discipline as
wsgi_appandbuild_registry. The async path never calls it — an async handler owns its awaits.- Return type:
- bind_kwargs(node, request)[source]
Reconcile the request kwargs with the handler’s declared parameters.
Base:
request.handler_kwargs(). A hydrated JSON body arrives as a singlebody_datadict; a REST handler declares scalar parameters, so the dict is spread over the fields the handler accepts (from the node’s neutralparamsblock — neverinspect). The wholebody_datais kept when the handler itself declares it, accepts**kwargs, or exposes no signature (no pydantic plugin).A handler that declares
_requestis given the liveRequest: the login surface of the_serverapp asked for it first, and the websocket channel command asks for it now — both need what only the request knows, the cookie it came with. It was the server app’s own override until #68 moved it here, because the seam is nobody’s private business (owner, 2026-09-07).
- spread_over_params(node, data)[source]
Fit a dict of values to the handler’s declared parameters.
Shared by every wire dialect (REST body today, MCP arguments later): keeps
datawhole when the handler declares no signature (fieldsisNone— no pydantic plugin) or accepts**kwargs; otherwise keeps only the declared names, dropping extras. An emptyfieldslist is a known no-parameter handler: everything drops.
Request and response
HTTP request: one flat class over the ASGI scope, eager body parsing.
Request is HTTP-only — no transport abstraction (the WSX/message transport
is orchestration, out of the core). It wraps the ASGI scope and, in the
async init(), reads the request once and by itself: headers and cookies
off the scope, the query string, and the whole body pumped from receive
until more_body is false — always, whatever the content-type, so a
request never leaves unread ASGI messages behind. genro-tytx is used ONLY as
a serializer: header values, query values and multipart fields are hydrated
with from_tytx, an urlencoded body with from_qs, a json/xml/msgpack
body with from_tytx(transport=...). The transport → media type map lives
in media_types, the inbound content-type is resolved here in
get_transport; the protocol reading lives here and nowhere else.
The body is decoded by content-type:
json / xml / msgpack (standard or
application/vnd.tytx+*media type) → the hydrated value;application/x-www-form-urlencoded→ a dict viafrom_qs;multipart/form-data→ a dict: text parts hydrated withfrom_tytx, file parts (those carrying afilename) asUploadedFile; a field name repeated across parts collects its values in a list;anything else → the raw bytes;
an empty body →
None.
UploadedFile is the file a client uploaded in a multipart form: name
(the form field), filename (as sent by the client), content_type
(declared for that part) and data (the bytes, whole — no spooling, no
streaming: the body is already resident).
TYTX mode is detected from the X-TYTX-Transport header; the paired
Response reads tytx_mode / tytx_transport to serialize the reply
in the same transport. The owning application creates the request
(Request(scope, receive, application=app), or server= directly) and
holds the response seam: self.response is a Response bound back to it.
handler_kwargs() builds the kwargs a route handler receives: the query is
the base; a form body — urlencoded OR multipart — is merged field by field
(body wins on a clash, files included: def upload(self, title, doc) gets
doc as an UploadedFile); a hydrated body is passed whole as
body_data; opaque bytes as body_raw; an empty body adds nothing.
db is the deferred preparation layer (no ORM yet): on first access it
resolves the server’s registered handler for the owning app’s db_name (else
"default") and registers its closeConnection as a request cleanup (drained
by the server at end of request). get_db(name) is a plain lookup with no
cleanup registration. Auth and session ride the scope (scope["auth"] — an
Avatar or None — and scope["session"]), set by the middleware chain.
- class genro_asgi.request.Request(scope, receive, *, server=None, application=None)[source]
Bases:
objectAn ASGI HTTP request: scope wrapper with eager, TYTX-aware body parsing.
- Parameters:
scope (Scope)
receive (Receive)
server (BaseServer | None)
application (BaseApplication | None)
- async init()[source]
Read headers, cookies, query and body from the scope (once).
Then derives TYTX mode, the request id (
x-request-idheader or a fresh uuid4) and the optional client correlation id (x-external-id).- Return type:
- read_headers()[source]
Fill the header map off the scope and hand back the raw cookie header.
Keys are lowercased and values TYTX-hydrated;
cookiestays out of the map — it is the one headerdecode_cookiesowns.- Return type:
- decode_cookies(cookie_header)[source]
Split a
Cookieheader into its morsels, values TYTX-hydrated.
- decode_query(query_string)[source]
Split a query string, values TYTX-hydrated (a repeated key gives a list).
- async read_body()[source]
Pump the ASGI body messages until
more_bodyis false, joined once.- Return type:
- get_transport(content_type)[source]
The TYTX transport a content-type names, or
Nonefor the others.Substring matching, so the standard media type (
application/json) and the TYTX one (application/vnd.tytx+json) resolve to the same transport.
- decode_body(body, content_type)[source]
Decode the body bytes by content-type.
A json/xml/msgpack body comes back hydrated, a form body (urlencoded or multipart) as a dict of fields, anything else as the opaque bytes it is; an empty body is
None. The media type decides case-insensitively, while the multipart parser gets the header as sent — its boundary is case-sensitive.
- decode_multipart(body, content_type)[source]
Split a multipart form body into its fields, keyed by form name.
A part carrying a
filenamebecomes anUploadedFile, a text part is TYTX-hydrated, and a name repeated across parts collects a list.
- property scope: MutableMapping[str, Any]
The raw ASGI scope.
- property server: BaseServer | None
The owning server (passed directly, or via the owning application).
- property application: BaseApplication | None
The application that created this request (
Noneif unbound).
- avatar(key='root')[source]
The identity acting on this request under
key(anAvatar) orNone.With no argument — the root slot — it is the effective identity the auth chain resolved for this request: header credentials or the session’s root avatar, read from the scope. Any other key is a sub-login, looked up in the session’s keyed avatars (
Nonewithout a session).
- property db: Any
The default db handler for the owning app, or
None(lazy).Resolves
server.databases[name]wherenameis the owning application’sdb_nameattribute if set, else"default". On the first successful resolution it registershandler.closeConnectionas a request cleanup (drained by the server at end of request). ReturnsNonewhen there is no server or no handler under that name.Preparation layer only: no pooling, no transactions, no per-app registry.
- handler_kwargs()[source]
Build the kwargs a route handler is called with (query + body).
The query params are the base. The body adds to them by content-type, not by Python shape: a form body —
x-www-form-urlencodedormultipart/form-data— arrives as a dict of hydrated fields (files included, asUploadedFile) and is merged field by field, the body winning on a name clash; a hydrated body (JSON/XML/msgpack) is passed whole asbody_data; opaque bytes are passed asbody_raw; an empty body adds nothing.
- class genro_asgi.request.UploadedFile(name, filename, content_type, data)[source]
Bases:
objectA file uploaded in a multipart form: its form field, name, type and bytes.
HTTP response: one flat, buffered, TYTX-aware class.
Response is a single slotted class — no subclass hierarchy (no
JSON/HTML/Streaming/File variants). It buffers the body in memory and, as an
ASGI application, emits exactly two messages (http.response.start +
http.response.body). It can be built with content or created empty and
configured through set_header/set_cookie/set_result/set_error
before being sent.
set_result dispatches by result type: dict/list → JSON bytes via
genro_tytx.json_dumps (or TYTX serialization — media type from
media_types.TRANSPORT_MIME — when the bound request is in TYTX mode),
Path → file bytes, bytes → as-is, str → UTF-8 text, None →
empty. set_error maps an exception to a status:
HTTPException subtypes carry their own status; ValueError/TypeError
→ 400, FileNotFoundError → 404, PermissionError → 403, anything else
→ 500 (logged). set_cookie appends a set-cookie header.
The request binding is optional (request=None); every request-dependent
branch (the TYTX path) guards for its absence.
- class genro_asgi.response.Response(content=None, status_code=200, headers=None, media_type=None, request=None)[source]
Bases:
objectBuffered HTTP response, usable directly as an ASGI application.
Example
>>> response = Response(content="Hello", media_type="text/plain") >>> await response(scope, receive, send)
# Or create empty and configure: >>> response = Response() >>> response.set_header(“X-Custom”, “value”) >>> response.set_result({“data”: 123}) # auto-detects JSON >>> await response(scope, receive, send)
- Parameters:
- __init__(content=None, status_code=200, headers=None, media_type=None, request=None)[source]
Build a response.
Note
Status 204 (No Content) and 304 (Not Modified) must not carry a body per RFC 7230; providing content with those codes may be rejected or truncated by the ASGI server.
- set_cookie(key, value='', *, max_age=None, path='/', domain=None, secure=False, httponly=False, samesite='lax')[source]
Append a
set-cookieheader (the value is URL-encoded).
- set_result(result, metadata=None)[source]
Set the body from a handler result, dispatching by type.
dict/list→ JSON bytes (genro_tytx.json_dumps), or TYTX bytes/text when the bound request is in TYTX mode;Path→ file bytes;bytes→ as-is;str→ UTF-8 text;None→ empty; anything else → itsstr. Amedia_typeinmetadataoverrides the type-based default.
Streaming HTTP response: a chunked ASGI sibling of Response.
Response (response.py) is flat and BUFFERED — two ASGI messages, the whole
body in memory. A stream is a different shape, not a variant, so it is a
separate slotted class rather than a subclass: StreamingResponse sends the
http.response.start once, then one http.response.body per chunk with
more_body=True, and a terminal empty body with more_body=False. It has
NO set_result (the buffered type-dispatch is deliberately not carried over):
the body is an async iterator of bytes the caller supplies.
The iterator is the whole contract — a plain async for over user chunks, an
SseStream (sse.py), or any bounded event source. This class only frames the
ASGI message sequence around it; backpressure and heartbeats live in the source.
- class genro_asgi.streaming.StreamingResponse(body_iterator, status_code=200, headers=None, media_type=None)[source]
Bases:
objectChunked HTTP response, usable directly as an ASGI application.
Example
>>> async def chunks(): ... yield b"one" ... yield b"two" >>> response = StreamingResponse(chunks(), media_type="text/plain") >>> await response(scope, receive, send)
- Parameters:
Server-Sent Events framing over StreamingResponse.
An event is a small dict — {"data": ..., "event": ..., "id": ...} — framed
into the text/event-stream wire format: id:/event:/data: lines
(data split across lines on newlines, per the spec), retry: once at the
start when configured, and a : keepalive comment when the source falls
silent longer than the heartbeat interval (the comment keeps proxies from
closing an idle connection; the client ignores it). Each event ends with a
blank line. data that is not a string is JSON-encoded.
The framing is shaped like channel/frame.py (a slotted codec, its own wire
format) but has no bytes in common — SSE is a text protocol over HTTP, not the
length-prefixed wsx envelope. SseStream is SELF-CONTAINED: it wraps ANY
async source of event dicts (a user generator, a task hub subscription) and
yields wire bytes; the source is the caller’s concern. Resumability
(Last-Event-ID → a snapshot baseline then the live source) is built by the
consumer that owns the event source, not here.
- class genro_asgi.sse.SseStream(source, *, retry_ms=None, keepalive_seconds=15.0)[source]
Bases:
objectFrames an async source of event dicts into
text/event-streambytes.Note
The stream is bound to one source (dual relationship:
self.source). Iterating it yields wire bytes;response()wraps it in aStreamingResponsewith the SSE headers already set.- __init__(source, *, retry_ms=None, keepalive_seconds=15.0)[source]
Bind to an async source of event dicts.
- Parameters:
source (
AsyncIterable[dict[str,Any]]) – Any async iterable of events; each event is a dict with an optionalid/eventand adatapayload.retry_ms (
int|None) – When set, aretry:line is emitted once at the start (the client’s reconnection delay).keepalive_seconds (
float) – Idle interval after which a: keepalivecomment is sent to hold the connection open.
- Return type:
None
Registry, lifespan and pool
Request registry: the current request and the in-flight picture.
RequestRegistry is held by the server as a dual parent-child
(self.server, SPECIFICATION.md §4) and is the SINGLE writer of the
in-flight set (D12 spirit): the server registers a request on entry and
unregisters it on exit, around the http dispatch. Each registration is a
lightweight RegisteredRequest record (D18: slotted, high cardinality)
carrying a monotonic id, the scope type, the path, the start time, and the
request’s cleanup callbacks. The server drains those cleanups in the http
finally (run_cleanups) so any app — routed or bare — gets end-of-request
teardown (e.g. request.db closing its connection) for free.
The “current request” is exposed through a ContextVar that lives on the
registry INSTANCE (never at module level): register sets it and keeps the
reset token, unregister resets it. Because the ContextVar is an instance
attribute, deleting the server garbage-collects everything (instance-isolation
rule) and concurrent requests — each on its own task context — see their own
current.
- class genro_asgi.request_registry.RegisteredRequest(request_id, scope_type, path)[source]
Bases:
objectOne in-flight request tracked by the registry (D18: slotted record).
A snapshot taken at registration: the monotonic
request_id, the ASGIscope_type, thepath, andstarted_at(time.monotonic()). Slotted because requests are high cardinality.It also owns the request’s end-of-life cleanups:
add_cleanup(fn)queues a zero-arg callback andrun_cleanups()drains them LIFO at the end of the dispatch (the server calls it in the httpfinally). The_cleanupslist is lazy — allocated only when the first callback is queued — so a request that registers none pays nothing.- run_cleanups(error=None)[source]
Run queued cleanups LIFO, isolating and logging each one’s exception.
Called by the server in the http
finally— so cleanups run whether the request succeeded or failed.errorcarries the terminating exception (Noneon success) for error-aware cleanups; the base drain runs every callback regardless.- Return type:
- Parameters:
error (BaseException | None)
- class genro_asgi.request_registry.RequestRegistry(server)[source]
Bases:
objectTracks in-flight requests and the current one, owned by the server.
The server is the single writer: it calls
register(scope)on request entry andunregister(item)on exit.currentreads the instance-owned ContextVar;in_flightcounts the live requests;snapshot()lists them.- Parameters:
server (BaseServer)
- property current: RegisteredRequest | None
The request being handled in this task’s context, or
None.
- register(scope)[source]
Register a request from
scope, setcurrent, return the item.- Return type:
- Parameters:
scope (MutableMapping[str, Any])
- unregister(item)[source]
Drop
itemfrom the in-flight set and resetcurrent.- Return type:
- Parameters:
item (RegisteredRequest)
ASGI lifespan protocol: ordered startup, reverse shutdown, error isolation.
Lifespan is constructed with the server it manages (dual parent-child:
self.server, SPECIFICATION.md §4). On lifespan.startup it runs
on_startup on the server’s applications in registration order; on
lifespan.shutdown it runs on_shutdown in REVERSE order. Hooks may
be sync or async, detected with inspect.iscoroutinefunction at call time.
A hook that raises is logged and the sequence CONTINUES: one app’s error
never blocks the others, and uvicorn always receives the matching
.complete message — app errors are isolated, never abort the protocol.
FatalBootError is the ONE exception to that isolation: an on_startup
hook raises it to declare its failure fatal, the startup stops there and
uvicorn receives lifespan.startup.failed, so the server exits instead
of running without what the hook was there to build.
The server’s lifecycle states live here — the lifespan is the lifecycle.
RUNNING takes new requests in charge; anything else refuses them with 503.
The shutdown is where the state turns: BEFORE any application’s hook runs, the
server stops accepting — QUITTING when whoever triggered the shutdown chose
to save (shutdown_mode, set by the --reload launcher and one day by
the deliberate command), STOPPING otherwise — and the in-flight requests are
drained, bounded by SHUTDOWN_DRAIN_TIMEOUT_SECONDS. Only then do the hooks
run, in reverse order: each application saves AFTER nothing new can arrive and
nothing old is still being served. A state somebody already set is respected:
the deliberate command decides before the shutdown reaches here.
- genro_asgi.lifespan.QUITTING = 'quitting'
The server is leaving and saving what it holds.
- genro_asgi.lifespan.RUNNING = 'running'
The server takes new requests in charge. Any other state refuses them.
- genro_asgi.lifespan.SHUTDOWN_DRAIN_TIMEOUT_SECONDS = 10.0
How long the shutdown waits for the in-flight requests before proceeding without them, in seconds. What is still in flight past it is counted in the log and served by nobody: the worker-level cut answers those calls.
- genro_asgi.lifespan.STOPPING = 'stopping'
The server is leaving without saving.
- exception genro_asgi.lifespan.FatalBootError[source]
Bases:
ExceptionRaised by an
on_startuphook to declare its failure fatal to the server.The one exception
_run_hookdoes not swallow on startup: the startup stops at the app that raised it andLifespan.__call__answerslifespan.startup.failed(message = the exception text) instead of.complete, so uvicorn exits. On shutdown it gets the ordinary logged-and-continue isolation: nothing may abort the shutdown sequence.
- class genro_asgi.lifespan.Lifespan(server)[source]
Bases:
objectASGI lifespan handler, held by the server as a dual parent-child.
- Parameters:
server (BaseServer)
- async startup()[source]
Run
on_startupin registration order;FatalBootErrorstops it.- Return type:
- async shutdown()[source]
Stop accepting, drain what is in flight, THEN run the hooks in reverse.
The state turns first — to
shutdown_modewhen it is stillRUNNING, and it stays untouched when somebody already chose — so no application saves while new work can still arrive. The drain is bounded: pastSHUTDOWN_DRAIN_TIMEOUT_SECONDSthe count still in flight goes in the log and the sequence proceeds — those calls are answered by the worker-level cut, never waited for twice.- Return type:
The server’s single thread pool for blocking work (SPECIFICATION.md §4, D2).
WorkPool wraps one concurrent.futures.ThreadPoolExecutor and is held by
the server as a dual parent-child (self.server). Async handlers stay on the
event loop; only sync handlers reach the pool, dispatched through
BaseServer.run_sync via loop.run_in_executor.
Lazily provisioned (invariant #1 — build lazily on the running loop): the executor is created on the first dispatch, never at boot, and torn down at lifespan shutdown only if it was ever provisioned.
- class genro_asgi.pool.WorkPool(server, max_threads=None)[source]
Bases:
objectOne thread pool for blocking (sync) handlers, owned by the server.
Constructor kwarg:
max_threads— the executor’smax_workers(Noneuses the stdlib default:min(32, cpus + 4), wherecpusare the CPUs granted to the process on interpreters that haveos.process_cpu_count, the machine’s on older ones). Threads are namedgenro-pool*so a handler can assert it ran off the loop.- Parameters:
server (BaseServer)
max_threads (int | None)
- property metrics: dict[str, int]
the slots that exist, the calls in flight.
busycounts everyrun()entered and not yet exited — DEMAND, not slots held: past saturation the excess is queued inside the executor and still counts, sobusycan exceedtotal(the consumers clamp).Zeros until the executor is provisioned — before the first sync dispatch there is nothing to measure, and reporting the configured size of a pool that does not exist would read as pressure that isn’t there.
totalmirrors our own argument resolution, frozen at provision — never a private executor attribute.- Type:
Pressure gauges of the pool
- property executor: ThreadPoolExecutor
The pool’s executor, created on first access (lazy provisioning).
The moment of truth for
total: the slot count is resolved and frozen HERE, where the stdlib takes the same decision — from the CPUs granted to the process (os.process_cpu_count) on interpreters that have it, the machine’s otherwise, exactly mirroring the executor’s own default on each. A later affinity change cannot move the threads the pool already built, so the frozen number stays the true one.
Exceptions and types
HTTP control-flow exceptions: raised anywhere, answered by the errors middleware.
Plain classes, no framework machinery: HTTPException(status, detail=None,
headers=None) carries the response status (an optional plain-text detail and
optional response headers — ASGI (name, value) byte pairs forwarded to
the response, e.g. a WWW-Authenticate challenge on a 401); the common
errors are pre-filled subclasses — HTTPBadRequest (400), HTTPNotFound
(404), HTTPUnauthorized (401), HTTPForbidden (403),
HTTPUnprocessableContent (422). Redirect(location, status=302) is the
redirecting sibling: its location becomes the Location header. The
mapping to actual ASGI responses lives in middleware/errors.py.
One exception here is not an HTTP error at all: WebSocketDisconnect is how
the websocket facade reports that the client is gone. A disconnect is not a
value a read can return — every read would have to be checked — so it arrives
as an exception, and the read loop that catches it simply ends.
- exception genro_asgi.exceptions.HTTPBadRequest(detail=None, headers=None)[source]
Bases:
HTTPException400 Bad Request.
- exception genro_asgi.exceptions.HTTPException(status, detail=None, headers=None)[source]
Bases:
ExceptionHTTP error carried as an exception:
status, optionaldetailandheaders.headersare ASGI(name, value)byte pairs the errors middleware forwards onto the response (e.g. aWWW-Authenticatechallenge).
- exception genro_asgi.exceptions.HTTPForbidden(detail=None, headers=None)[source]
Bases:
HTTPException403 Forbidden.
- exception genro_asgi.exceptions.HTTPNotFound(detail=None, headers=None)[source]
Bases:
HTTPException404 Not Found.
- exception genro_asgi.exceptions.HTTPUnauthorized(detail=None, headers=None)[source]
Bases:
HTTPException401 Unauthorized.
- exception genro_asgi.exceptions.HTTPUnprocessableContent(detail=None, headers=None)[source]
Bases:
HTTPException422 Unprocessable Content.
- exception genro_asgi.exceptions.Redirect(location, status=302, headers=None)[source]
Bases:
HTTPExceptionHTTP redirect:
locationbecomes theLocationheader.
- exception genro_asgi.exceptions.WebSocketDisconnect(code=1000, reason='')[source]
Bases:
ExceptionThe client is gone:
codeandreasonas the ASGI message carried them.Raised by every read of the facade, and by its iterator, which ends on it. The default is 1000 with no reason, which is what an ASGI server sends when the disconnect message carries neither.
ASGI type aliases for genro-asgi.
Aliases follow the ASGI spec (MutableMapping, not TypedDict, for
extensibility): Scope — connection metadata (type, method, path,
headers, …); Message — messages between app and server (the type
key identifies the message); Receive/Send — the two async channel
callables; ASGIApp — the standard application signature.