Core¶
The application bootstrap, DI container, config, routing, service providers, the application-level event dispatcher, feature flags, view rendering, the Blade template compiler, the dependency-free Python component builder, the framework's exception/validation primitives, and its own testing utilities.
application ¶
The Zeython application: an ASGI app assembled from service providers.
Application ¶
The central object: owns the container, config, router, and providers.
Application is itself a valid ASGI callable, so uvicorn main:app
works directly once you construct one and register at least a router.
Source code in src/zeython/application.py
providers
property
¶
Service providers registered so far, in registration order.
add_middleware ¶
Add a middleware layer, wrapping the app outside every layer added so far.
Prepends, matching Starlette's own Starlette.add_middleware()
convention: the most recently added middleware ends up
outermost -- the one that sees a request first and a response
last -- since Starlette's build_middleware_stack() wraps its
middleware=[...] list from the end backward. Getting this
backward silently defeats anything that depends on running before
everything else (e.g. :class:~zeython.maintenance.MaintenanceModeMiddleware,
which is documented as needing to intercept a request before even a
database session is opened).
Source code in src/zeython/application.py
boot ¶
Boot every registered provider, in registration order.
Resumable, not just idempotent: if a provider's boot() raises
(e.g. a transient "database not reachable yet" at the very first
request), providers before it in registration order are not
re-booted on the next call -- only the one that failed and any
after it. Without this, a naive "retry every provider" replay
would call an already-succeeded provider's boot() a second
time, and for a provider like DatabaseServiceProvider that
calls add_middleware() in boot(), that means a second,
duplicate middleware layer added for the remaining life of the
process.
Source code in src/zeython/application.py
run ¶
Run with uvicorn. For auto-reload during development, use the zeython serve CLI instead.
Source code in src/zeython/application.py
container ¶
A minimal, type-hint-driven dependency injection container.
Zeython's :class:Container resolves dependencies by inspecting constructor
and function type annotations, in the spirit of Laravel's service container.
Bindings can be a plain instance, a factory callable, or a class to
autowire directly.
BindingResolutionError ¶
Bases: Exception
Raised when the container cannot resolve a requested binding.
Container ¶
A small service container supporting binding, singletons, and autowiring.
Fully synchronous by design (bindings resolve at boot, well before an
event loop is necessarily running) -- an async factory is rejected
with a clear error rather than silently returning an unawaited,
never-usable coroutine that a shared/singleton binding would then
cache and hand out forever. A circular dependency (A needing
B needing A) is likewise rejected with a clear error instead
of recursing until Python's own RecursionError.
Source code in src/zeython/container.py
bind ¶
Register a binding. If factory is omitted, abstract must be a concrete class.
Source code in src/zeython/container.py
singleton ¶
instance ¶
make ¶
Resolve abstract to a concrete instance, autowiring its dependencies.
Source code in src/zeython/container.py
call ¶
Call fn, resolving any missing arguments from the container.
Source code in src/zeython/container.py
config ¶
Environment-driven configuration for Zeython applications.
Config ¶
Layered configuration backed by the environment and .env files.
Values resolve in this order (highest priority first): real process
environment variables, then the loaded .env file, then explicit
defaults passed to :meth:get.
Source code in src/zeython/config.py
debug
property
¶
APP_DEBUG -- False unless explicitly set to true.
Deliberately not inferred from :attr:environment (e.g.
environment != "production"): that would make debug mode --
full tracebacks, executed SQL, and (for a browser request) an
HTML page with source snippets, all in the response body --
the default the moment APP_ENV is merely left unset, which is
exactly the kind of thing that's easy to forget on a real
deployment (DATABASE_URL/APP_SECRET_KEY tend to get
remembered; APP_ENV doesn't always). Debug output now requires
an affirmative APP_DEBUG=true -- which is exactly what
zeython new's generated .env.example already sets for
local development, so this doesn't change the default local
dev experience at all.
get ¶
Dot-path lookup, e.g. config.get("database.url") reads DATABASE_URL.
providers ¶
Service providers: the seam where cross-cutting concerns hook into boot.
ServiceProvider ¶
Base class for registering and booting application services.
register() runs for every provider before any provider's boot()
runs, so bindings you depend on in boot() are guaranteed to exist
regardless of registration order.
Source code in src/zeython/providers.py
register ¶
DatabaseServiceProvider ¶
Bases: ServiceProvider
Wires up the async :class:~zeython.db.Database and its request-scoped session.
DATABASE_POOL_SIZE/DATABASE_MAX_OVERFLOW are passed straight
through to SQLAlchemy's connection pool when set -- unset by default,
so nothing changes for an in-memory SQLite URL (:memory:), whose
default pool doesn't accept them at all. See
docs/database.md#connection-pooling.
DATABASE_READ_URL, if set, binds a read replica --
database.read_replica() opens a session against it instead of the
primary. See docs/database.md#read-replicas.
Source code in src/zeython/providers.py
RouteServiceProvider ¶
Bases: ServiceProvider
Imports route modules for their side effect of registering routes on the app.
Each module is purged from sys.modules (if already cached) before
importing, so it always re-executes against this register()
call's app rather than silently returning whatever module object a
plain importlib.import_module() already had cached. That matters
for a long-lived process that reloads main more than once for the
same project (zeython mcp's load_app() does this on every call,
to pick up edits) -- a route module's own from main import app
binds to whichever Application existed at its import time, so
without this, a fresh Application created by the second reload
would end up with none of its routes registered: every route file
would still be registering onto the now-discarded previous instance.
Source code in src/zeython/providers.py
ViewServiceProvider ¶
Bases: ServiceProvider
Binds a :class:~zeython.views.Views instance for server-rendered HTML.
Looks for templates in resources/views under the app's base path by
default; override with VIEWS_PATH in .env or the views.path
config key.
Source code in src/zeython/providers.py
CorsServiceProvider ¶
Bases: ServiceProvider
Opt-in CORS support, configured via .env.
CORS_ORIGINS— comma-separated list of allowed origins (default: none)CORS_ALLOW_CREDENTIALS— defaultfalseCORS_ALLOW_METHODS— comma-separated, default*CORS_ALLOW_HEADERS— comma-separated, default*
Source code in src/zeython/providers.py
events ¶
Application-level events: decoupled listeners reacting to a domain event
(OrderPlaced, UserRegistered, ...) without the code that raises it
needing to know who's listening.
Deliberately separate from :class:zeython.db.Observer -- an Observer
reacts to one model's own lifecycle (creating, updated, ...); an
event here can be anything your application defines, dispatched from
anywhere (a controller, a job, a scheduled task), with any number of
independent listeners reacting to it without editing the code that
dispatches it. A common pattern is dispatching an event from a model
hook (created()) once the write itself is the model's own concern but
what happens next (send a receipt, notify a webhook, update a search
index) isn't.
EventDispatcher ¶
Maps an event type to the listeners registered for it.
Source code in src/zeython/events.py
listen ¶
Register listener to run whenever an instance of event_type is dispatched.
on ¶
Decorator form of :meth:listen::
@dispatcher.on(OrderPlaced) async def send_receipt(event: OrderPlaced) -> None: ...
Source code in src/zeython/events.py
listeners_for ¶
The listeners currently registered for event_type, in registration order.
dispatch
async
¶
Call every listener registered for type(event), in registration order.
A listener's own exception is logged and reported (see
:mod:zeython.error_monitoring), not raised -- one broken listener
(a bad webhook call, a typo in an audit-log write) shouldn't stop
the others from running, the same way a failed Slack notification
shouldn't also silently swallow the receipt email.
Source code in src/zeython/events.py
EventServiceProvider ¶
Bases: ServiceProvider
Binds an :class:EventDispatcher into the container.
Register your own listeners by subclassing and overriding boot()
(calling super().boot() first, so the dispatcher exists) --
registration happens once, at startup, the same way route modules and
other providers wire themselves up::
class AppEventServiceProvider(EventServiceProvider):
def boot(self) -> None:
super().boot()
dispatcher = self.container.make(EventDispatcher)
dispatcher.listen(OrderPlaced, send_receipt_email)
dispatcher.listen(OrderPlaced, notify_fulfillment_webhook)
Source code in src/zeython/providers.py
emit
async
¶
Dispatch event to every listener registered for its type.
Uses whichever :class:EventDispatcher is bound in the container (see
:class:EventServiceProvider). Outside of a request -- a job, a
scheduled task, a model hook -- dispatch directly against a resolved
dispatcher instead::
await app.container.make(EventDispatcher).dispatch(event)
Source code in src/zeython/events.py
feature_flags ¶
Feature flags: name a capability, decide who gets it, check it from
anywhere. Static (.env-driven) toggles and deterministic percentage
rollouts -- no database or Redis required. Mirrors the boolean/rollout
building blocks of Laravel Pennant, without persisted per-user storage;
:meth:FeatureManager.define takes a custom resolver if you need a flag
backed by a real store (a table, a third-party flag service) instead.
FeatureManager ¶
Holds every defined feature flag and resolves them per-context.
Bound in the container by :class:FeatureServiceProvider -- define
flags in your own subclass of it (the same pattern
:class:~zeython.events.EventServiceProvider uses)::
class AppFeatureServiceProvider(FeatureServiceProvider):
def boot(self) -> None:
super().boot()
manager = self.container.make(FeatureManager)
manager.boolean("new_checkout")
manager.percentage("beta_dashboard", rollout=10)
Source code in src/zeython/feature_flags.py
define ¶
Register a flag with a custom resolver. resolver(context)
returns (or awaits to) a bool -- context is whatever the
caller of :meth:active/:func:feature passed, typically the
current user, None for a flag that doesn't vary per-request.
Source code in src/zeython/feature_flags.py
boolean ¶
A static on/off flag, controlled via .env
(FEATURE_<NAME>) without touching code -- flip it in a
deployment's environment and restart, no redeploy of code needed.
Resolves default for every context alike -- there's no
per-user variation here; use :meth:percentage or :meth:define
for that.
Source code in src/zeython/feature_flags.py
percentage ¶
A deterministic rollout: the same context always lands on
the same side of the flag, so a percentage-rolled-out feature
doesn't flicker on and off for the same user across requests --
no database write needed to get that stability, just a stable
hash of (name, context).
Buckets by context.id if present, else str(context) --
pass whatever stable identifier makes sense for a flag with no
natural object to check against (a request ID, a tenant slug).
Source code in src/zeython/feature_flags.py
names ¶
active
async
¶
Whether name is active for context.
An undefined flag resolves False and logs a warning -- most
likely a typo, or a flag checked before its own
FeatureServiceProvider subclass registered it. Never raises,
so a flag check is always safe to sprinkle into a request path.
Source code in src/zeython/feature_flags.py
FeatureServiceProvider ¶
Bases: ServiceProvider
Binds a :class:FeatureManager into the container.
Register no flags on its own -- subclass it and override boot()
(calling super().boot() first, so the manager exists) to define
your own, the same pattern :class:~zeython.events.EventServiceProvider
uses. See docs/feature-flags.md.
Source code in src/zeython/providers.py
feature
async
¶
Whether name is active, using whichever :class:FeatureManager
is bound in the container (see :class:FeatureServiceProvider).
Outside of a request -- a job, a scheduled task -- resolve directly
instead::
manager = app.container.make(FeatureManager)
await manager.active("new_checkout", context=user)
Source code in src/zeython/feature_flags.py
routing ¶
Ergonomic routing built on top of Starlette's proven route matching.
Controller ¶
Marker base class for class-based controllers used with :meth:Router.resource.
Router ¶
Collects routes and exposes Laravel/FastAPI-style decorator sugar.
A Router compiles down to a plain list of Starlette BaseRoute
objects, so nesting via :meth:include is just a Mount and gets the
same battle-tested path matching as everything else built on Starlette.
Source code in src/zeython/routing.py
websocket ¶
Register a WebSocket handler: async def handler(websocket: WebSocket) -> None.
See :mod:zeython.websockets and docs/websockets.md.
Source code in src/zeython/routing.py
include ¶
Mount another router's routes under an optional additional prefix.
version ¶
Group routes under a version prefix, with :func:current_api_version set during each call.
Yields a sub-:class:Router to register routes on; it's mounted
onto this router only once the with block finishes, so build it
up fully inside the block::
with app.router.version("v1") as v1:
v1.resource("/posts", PostControllerV1)
Defaults prefix to /{version} (so "v1" mounts at
/v1); pass prefix="" to version routes without changing
their path.
Source code in src/zeython/routing.py
mount ¶
Mount an arbitrary ASGI app (e.g. starlette.staticfiles.StaticFiles) at a path prefix.
resource ¶
resource(
path: str,
controller_cls: type[Controller],
*,
only: Iterable[str] | None = None,
) -> None
Register RESTful CRUD routes bound to a controller's methods.
Maps: index->GET path, store->POST path, show->GET path/{id:int}, update->PUT/PATCH path/{id:int}, destroy->DELETE path/{id:int}.
{id:int}, not a plain {id} -- every :class:~zeython.db.Model's
primary key is an integer, so a request for e.g. /posts/abc
fails route matching itself (a clean 404, same as an unknown
route) instead of reaching the handler and blowing up on
int(request.path_params["id"]), the conversion every generated
show/update/destroy action does next.
Source code in src/zeython/routing.py
MethodOverrideMiddleware ¶
Lets a plain HTML <form> -- which, unlike fetch, only ever
submits GET/POST -- drive a PUT/PATCH/DELETE route,
by reading a _method field out of a POST request's form body and
rewriting the request's method before it reaches routing.
Opt-in: app.add_middleware(MethodOverrideMiddleware). Only a
POST with a form-encoded (or multipart) body is inspected; every
other request -- including every JSON API call -- passes through
untouched. Pairs with Zeython Blade's @method('PUT') directive,
which renders the matching hidden field.
Source code in src/zeython/routing.py
current_api_version ¶
The version label (e.g. "v1") the current request was routed under.
None outside a request, or inside one routed through a plain
(non-versioned) :class:Router. Set for the duration of an endpoint
call registered via :meth:Router.version.
Source code in src/zeython/routing.py
deprecated ¶
Mark an endpoint deprecated, signaling it with standard HTTP headers.
Sets Deprecation: true (per the IETF draft) on every response, and
Sunset: <sunset> (an RFC 8594 HTTP-date, per RFC 7231 section 7.1.1.1)
when a removal date is known::
@app.router.get("/v1/reports")
@deprecated(sunset="Wed, 01 Jan 2027 00:00:00 GMT")
async def old_reports(request: Request) -> Response: ...
Source code in src/zeython/routing.py
views ¶
Server-rendered HTML views, by convention read from resources/views/.
Three view styles are all first-class and can be mixed freely in the same
project: a plain .html file is rendered as-is by Jinja2 (as before); a
.blade.html file is first run through :class:~zeython.blade.BladeCompiler
-- a Laravel Blade-styled directive syntax (@if, @foreach,
@extends/@section, components, ...) compiled to the exact same
Jinja2 source the plain-.html path would produce by hand (see
docs/blade.md); a .py file is a real, importable, type-checkable
Python module built with :mod:zeython.components -- HTML via plain
Python function calls, no template language at all (see docs/components.md).
BladeLoader ¶
Bases: FileSystemLoader
A FileSystemLoader that compiles *.blade.html source through a
shared :class:~zeython.blade.BladeCompiler before Jinja2 parses it.
Every other extension (plain .html) is returned unchanged. Jinja2's
own mtime-based uptodate check (returned untouched from the parent
loader) still governs template caching, so a .blade.html file is
only recompiled when it actually changes on disk.
Source code in src/zeython/views.py
Views ¶
Thin wrapper around Starlette's Jinja2 integration, extended with a
Blade-styled compiler for .blade.html templates.
Bound into the container by :class:~zeython.providers.ViewServiceProvider
under the resources/views directory by default. Use the module-level
:func:render helper from inside a controller/handler for Flask-style
ergonomics.
Source code in src/zeython/views.py
render ¶
render(
request: Request,
name: str,
context: dict[str, Any] | None = None,
*,
status_code: int = 200,
) -> HTMLResponse
Render name using the application's registered :class:Views instance.
Usage inside a controller::
from zeython.views import render
async def show(self, request):
return render(request, "posts/show.html", {"post": post})
name may be a plain .html Jinja2 template, a .blade.html
template using Blade-styled directives (see docs/blade.md), or a
.py file defining view(request, **context) with
:mod:zeython.components (see docs/components.md).
Source code in src/zeython/views.py
blade ¶
A Laravel Blade-styled template engine, compiled to Jinja2.
Directives (@if, @foreach, @extends, ...) give templates the
same readable control-flow Laravel developers know, but the expressions
inside them are plain Python/Jinja2 expressions, not PHP -- post.title
and len(items), not $post->title and count($items). Zeython
Blade is a source-to-source compiler that turns that directive syntax into
real Jinja2 template source, then hands it to Jinja2 for parsing,
compiling, caching, and rendering -- every directive is sugar over a
native Jinja2 construct (@if over {% if %}, @extends/@section
over {% extends %}/{% block %}, and so on), so Jinja2's own
(battle-tested) autoescaping, template caching, and nesting rules apply
unchanged. This module never executes template source itself; it only
rewrites text that Jinja2 then compiles normally.
See docs/blade.md for the full directive reference and the (short) list of real Blade features deliberately not implemented, with the reasoning and workaround for each.
BladeCompileError ¶
Bases: Exception
Raised for malformed Blade source (unbalanced parens, unmatched
block directives, an @php line that isn't a simple assignment).
BladeCompiler ¶
Compiles Blade-styled template source into Jinja2 template source.
A single instance is shared by every .blade.html template in a
project (held by :class:~zeython.views.Views), so custom directives
registered via :meth:directive apply everywhere. Register them at
boot (a service provider's register()/boot()), before any
request renders a template -- Jinja2 caches compiled templates keyed by
file mtime, so a directive added after a template has already been
compiled won't retroactively change that cached copy.
Source code in src/zeython/blade.py
directive ¶
Register (or override) a directive: handler(expr) receives the
raw text inside the directive's parentheses (None for a bare
directive with no parens) and returns Jinja2 source to splice in::
blade.directive(
"money",
lambda expr: f"{{{{ '%.2f'|format({expr}) }}}}",
)
Used in a template as @money(order.total).
Source code in src/zeython/blade.py
blade_view_path ¶
'layouts.app' -> 'layouts/app.blade.html' -- Blade's
dot-notation view names, resolved against the same resources/views
directory as everything else. A name that already ends in .html
(including .blade.html) is used as-is, so a plain (non-Blade)
Jinja2 partial can still be included/extended.
Source code in src/zeython/blade.py
blade_class ¶
@class({'active': is_active, 'text-red-600': has_error}) -> a
space-joined class-attribute string of only the truthy entries --
Blade's @class helper, handy for conditional Tailwind classes.
Source code in src/zeython/blade.py
components ¶
Build HTML by calling plain Python functions -- a third view style,
alongside Jinja2 (.html) and Blade (.blade.html): a .py file
under resources/views/ is a real, importable, type-checkable Python
module, not a text template. No external dependency; this module is
entirely self-contained.
::
# resources/views/posts/show.py
from zeython.components import Node, a, div, h1, p
def view(request, post) -> Node:
return div(".post")[
h1[post.title],
p[post.body],
a(href="/posts")["Back"],
]
render(request, "posts/show.py", {"post": post}) calls view(request,
post=post) and turns the returned :class:Node into an HTMLResponse
-- the exact same call a Jinja2 or Blade template would go through, so a
project can freely mix all three view styles.
Reusable pieces are just Python functions returning :class:Element/
:class:Node -- there's no separate "component" concept to learn:
::
# app/Components/card.py
from zeython.components import Node, div, h2
def card(title: str, body: Node) -> Node:
return div(".card")[h2[title], body]
Escaping is on by default for every string/number child and every
attribute value (via :func:html.escape); wrap trusted markup in
:func:safe to emit it unescaped.
SafeString ¶
Bases: str
A string that's already valid HTML -- passed through unescaped.
Returned by :func:safe and by :class:Element/:func:render_to_string
themselves, so nesting components never double-escapes already-rendered
markup.
Element ¶
An HTML element. Immutable: :meth:__call__ (attributes) and
:meth:__getitem__ (children) each return a new Element, so a
shared base element (card = div(".card")) is always safe to reuse
across renders without one call's attributes/children leaking into
another's.
Source code in src/zeython/components.py
__call__ ¶
Return a copy of this element with attributes set.
div(".card#main", data_open=True) -- the optional leading
string is CSS-selector-style shorthand for id/class
(#id once, .class any number of times); everything else is
a keyword argument. A trailing underscore is stripped (class_,
for_, type_ -- for names that shadow a Python keyword or
builtin); remaining underscores become hyphens (data_open ->
data-open). True emits a bare boolean attribute,
False/None omits it entirely, and a dict value (most
useful for class_) joins its truthy keys space-separated.
Source code in src/zeython/components.py
VoidElement ¶
Bases: Element
A self-closing element (<br>, <img>, ...) that never
accepts children -- calling :meth:__getitem__ is a programmer error,
not a silently-dropped no-op.
Source code in src/zeython/components.py
safe ¶
Mark value as already-safe HTML, skipping escaping.
Only use this for markup you trust (a hand-written constant, output
that's already been through :func:render_to_string) -- never for
unsanitized user input, which is exactly what escaping-by-default
exists to protect against.
Source code in src/zeython/components.py
tag ¶
Build an element for a tag not already exported by this module --
a web component (tag('my-widget')) or anything missing from the
standard set below.
Source code in src/zeython/components.py
render_to_string ¶
Render any :data:Node to a safe HTML string. str(element)
already does this for a single :class:Element; use this directly
for a bare list/generator of nodes (a page with no single root tag).
Source code in src/zeython/components.py
exceptions ¶
HTTP-aware exception hierarchy with a default JSON error handler.
HTTPException ¶
Bases: Exception
Base class for exceptions that should be rendered as HTTP responses.
Only works as intended when raise\ d from inside routing -- a
route handler, or code it calls. Starlette's own ExceptionMiddleware
(which owns the per-class handlers :func:default_exception_handlers
registers for this hierarchy) sits inside every add_middleware()
layer, wrapping only the router. Raising an HTTPException directly
from a custom pure-ASGI middleware's __call__ -- rather than a
route handler -- skips that layer entirely: the exception propagates
to ServerErrorMiddleware instead (outside everything), which has
no per-class handler for it, so it's treated as a genuine unhandled
error -- the intended status code and headers= are lost, and the
caller gets a generic 500 instead. Call the handler directly and
return/send its response instead of raising -- :class:~zeython.csrf.CsrfMiddleware
does exactly this (see its __call__) specifically to avoid this trap.
Source code in src/zeython/exceptions.py
validation ¶
Declarative validation rules for :class:zeython.db.Model fields.
Rule ¶
validate ¶
Run declarative rules against a plain dict -- the same rule sets you'd
write for :attr:zeython.db.Model.__rules__, applied to a request
payload, query params, or any other dict that isn't (or isn't yet) a
model instance. Does not raise; raise ValidationException(errors)
yourself if that's what you want when errors is non-empty.
Model.validate() is this function applied to a model instance's own
field values -- kept in sync with it deliberately, so a rule set means
the same thing whether it's checked against a model or a plain dict.
Source code in src/zeython/validation.py
testing ¶
Test helpers for Zeython applications.
client
async
¶
client(
app: Application, *, base_url: str = "http://testserver"
) -> AsyncIterator[httpx.AsyncClient]
An httpx.AsyncClient wired directly to the app's ASGI callable, no sockets involved.
Named client rather than test_client so pytest's test_* collection
doesn't mistake this helper for a test function when imported into a test module.
Automatically attaches the CSRF header (see :mod:zeython.csrf) from
whatever csrf_token cookie this client is already holding -- the
same thing a real browser-based app does by reading the cookie in JS,
so a test doesn't need to plumb the token through by hand. This still
means the first unsafe request in a test needs a prior safe one (a
GET) to actually receive that cookie -- there's nothing to attach
before that.
Usage::
async with client(app) as http:
await http.get("/") # picks up the csrf_token cookie
response = await http.post("/posts", json={"title": "t"})
assert response.status_code == 201
Source code in src/zeython/testing.py
login_as ¶
Log http_client in as user directly, without a real
POST /login -- for a test that needs an authenticated request but
isn't specifically testing the login flow itself::
async with client(app) as http:
login_as(http, app, user)
response = await http.get("/me")
assert response.status_code == 200
Builds the exact signed session cookie Starlette's own
SessionMiddleware would set after a real login(request, user)
call (see :mod:zeython.auth) -- same secret key, same cookie name
(SESSION_COOKIE_NAME, default zeython_session) -- and sets it
directly on the client, so :func:~zeython.auth.require_auth and
:func:~zeython.auth.current_user see a real logged-in session on the
very next request. Requires AuthServiceProvider to be registered
(same requirement login() itself has).
Source code in src/zeython/testing.py
transactional_session
async
¶
Opens one session for an entire test and rolls it back unconditionally on exit, regardless of whether the block raised -- for a test that writes real data through the Active Record API and wants it visible to queries made anywhere in the same test, without that data persisting past it.
Most valuable against a real Postgres/MySQL test database, where
recreating the schema per test (the usual alternative) is slow.
Against SQLite's own :memory: URL -- what the framework's own test
suite and zeython new's default scaffold both use -- a fresh
:class:~zeython.db.Database per test already gets the same
isolation for free, so this mostly matters once a project's tests run
against its real production database engine::
@pytest_asyncio.fixture
async def db_session(database: Database):
async with transactional_session(database):
yield
Requires the caller to hold a reference to the app's
:class:~zeython.db.Database directly (e.g. via
app.container.make(Database)) -- unlike :meth:Database.session,
this doesn't commit, so nesting it inside a request handled by
DatabaseSessionMiddleware would conflict with that middleware's
own session for the same context; use it to wrap a whole test instead.
Source code in src/zeython/testing.py
websocket_client ¶
A starlette.testclient.TestClient wired to the app's ASGI callable, for testing WebSocket routes.
Unlike :func:client, this is synchronous -- httpx has no WebSocket
support, and Starlette's own TestClient is what actually drives a
WebSocket handshake against an ASGI app in tests, no real socket
involved either way.
Usage::
with websocket_client(app).websocket_connect("/ws/chat") as ws:
ws.send_text("hi")
assert ws.receive_text() == "hi"