Extensibility¶
Custom console commands, the plugin registry, localization, the auto-generated admin panel, and AI integration.
console ¶
Custom CLI commands: the app/Console/Commands/ extension point.
Laravel's Artisan and Django's manage.py both let an application define
its own CLI commands, wired into the same container/config the rest of the
app uses -- without that, a one-off script (a data import, a cleanup job)
ends up disconnected from the app entirely: its own hand-rolled
Application() bootstrap, no access to container.make(...), easy to
drift from how the real app is actually configured. zeython command
<name> closes that gap.
One file per command in app/Console/Commands/, one Command
subclass per file -- zeython make command SendReport scaffolds one.
The command's CLI name defaults to the snake_case filename; override with
a name class attribute for something else (reports:send, ...).
Command ¶
Bases: ABC
Base class for a custom zeython command <name> CLI command.
Source code in src/zeython/console.py
handle
abstractmethod
async
¶
Do the work. args are the raw extra CLI arguments after the command name.
Runs outside any request, so there's no request-scoped database session automatically available -- open one explicitly if you need one, the same way any other out-of-request code does::
async def handle(self, *args: str) -> None:
database: Database = self.container.make(Database)
async with database.session():
...
Source code in src/zeython/console.py
discover_commands ¶
Every :class:Command subclass in app/Console/Commands/*.py, keyed by CLI name.
Source code in src/zeython/console.py
plugins ¶
Plugin discovery: letting a pip-installed third-party package register
its own service provider(s) with a Zeython app, without the app author
having to add an app.register(...) line for every package.
Every other extension point in the framework is an explicit
:class:~zeython.providers.ServiceProvider, registered by one line in
main.py -- nothing auto-wires itself in behind the app author's back
(see SecurityHeadersServiceProvider, ErrorMonitoringServiceProvider,
etc., all opt-in). Plugins follow the same rule at the app level: a plugin
package changes nothing until the app author registers
:class:PluginServiceProvider once. From then on, which packages
contribute providers is driven by what's pip installed, the same way
Laravel's package auto-discovery or Django's INSTALLED_APPS works --
one line turns on discovery, not one line per package.
A plugin package declares itself via a standard Python entry point in its
own pyproject.toml::
[project.entry-points."zeython.plugins"]
my_plugin = "my_package.providers:MyPluginServiceProvider"
The value is an import path to a :class:~zeython.providers.ServiceProvider
subclass (or an instance), exactly what app.register(...) already
accepts directly.
PluginServiceProvider ¶
Bases: ServiceProvider
Registers every plugin found by :func:discover_plugins.
Register this once, anywhere in main.py. A plugin adding routes
should do it in its own register() (like the built-in
RouteServiceProvider) -- route/model introspection (zeython
routes/about, the MCP server's list_routes) reflects the app
right after every provider's register() phase, not after boot(),
which only runs lazily on the first real request. A plugin that needs
another provider's binding already in place (Gate, Database)
should defer that specific part to its own boot(), for the same
reason every built-in provider does -- see docs/architecture.md. See
docs/plugins.md.
Source code in src/zeython/providers.py
discover_plugins ¶
Load every provider registered under the zeython.plugins entry-point
group by an installed package.
A plugin that fails to import is left to raise -- a broken or misconfigured plugin should fail loudly at boot, not vanish silently from an app that's relying on it.
Source code in src/zeython/plugins.py
localization ¶
Translation strings and per-request locale resolution.
Translations live in flat JSON files, one per locale, under
resources/lang/ by convention (resources/lang/en.json,
resources/lang/es.json, ...) -- a dotted key mapped to its translated
string, e.g. {"welcome.title": "Welcome!", "greeting": "Hello, {name}!"}.
:class:Translator loads and caches them, resolving a key against the
current request's locale (with a fallback locale for a missing key) and
substituting {name}-style parameters.
LocaleMiddleware resolves the locale for each request -- an explicit
?lang=xx query parameter, then the Accept-Language header, then the
configured default -- against whichever locales actually have a
translation file on disk, and makes it available to :func:current_locale
for the request's duration via a :class:~contextvars.ContextVar, the same
technique :func:~zeython.request_id.request_id uses. Translating a string
never needs a request threaded through every call as a result --
Translator.t() (and the t global :class:LocalizationServiceProvider
registers for Jinja templates) reads the current locale from that
contextvar directly.
Translator ¶
Loads {locale}.json translation files from path and looks up
keys by exact match, falling back to fallback_locale for a key
missing from the requested locale, and to the key itself (so a missing
translation degrades to something readable, not a crash) if it's
missing from the fallback too.
Source code in src/zeython/localization.py
available_locales
property
¶
Locales with a translation file on disk -- what :class:LocaleMiddleware
negotiates against. Empty for a project that hasn't added any yet,
in which case every request just resolves to default_locale.
t ¶
Translate key, in locale if given, otherwise
:func:current_locale (or default_locale outside a request).
Any keyword arguments fill {name}-style placeholders in the
translated string via str.format -- a placeholder with no
matching argument is left as-is rather than raising.
Source code in src/zeython/localization.py
LocaleMiddleware ¶
Pure ASGI middleware: resolves the request's locale and sets it as a
contextvar for :func:current_locale (readable via
:class:Translator without a request in hand) for the request's
duration.
Source code in src/zeython/localization.py
LocalizationServiceProvider ¶
Bases: ServiceProvider
Binds a :class:Translator and registers :class:LocaleMiddleware.
LOCALE_PATH-- defaultresources/langunder the project root.LOCALE_DEFAULT-- defaulten. Used when nothing else resolves a locale (no?lang=/Accept-Languagematch, or outside a request entirely).LOCALE_FALLBACK-- default: same asLOCALE_DEFAULT. Used when a key exists in some locale's file but not the requested one.LOCALE_QUERY_PARAM-- defaultlang. The query parameter a link can use to force a locale, e.g.?lang=es.
Also registers t as a Jinja global if :class:~zeython.views.ViewServiceProvider
is registered, so a template can call {{ t("welcome.title") }}
directly -- see docs/localization.md.
Source code in src/zeython/providers.py
current_locale ¶
The current request's resolved locale, or None outside a request
handled by :class:LocaleMiddleware.
t ¶
Translate key using the app's registered :class:Translator, in
the current request's resolved locale. Usage inside a controller,
exactly like :func:~zeython.views.render/:func:~zeython.queue.dispatch::
from zeython.localization import t
async def show(self, request):
return JSONResponse({"message": t(request, "welcome.title")})
Inside a Jinja template, call t(...) directly instead --
:class:LocalizationServiceProvider registers it as a template global,
no request needed there. Outside a request entirely (a job, a
script), resolve the translator directly instead:
app.container.make(Translator).t(key).
Source code in src/zeython/localization.py
admin ¶
An auto-generated CRUD admin UI for registered models.
Register a model and get list/create/edit/delete pages for it, generated from the model's own columns -- no hand-written admin templates, the same trade-off Django's admin makes. This is a lightweight v1, not a Django-admin clone: no relationship pickers (a foreign key column is a plain number input, you type the related row's ID), no search/filtering, no bulk actions. It's for internal, trusted-staff CRUD over your own models, not a public-facing UI -- see "What this isn't" below.
Every admin route requires a logged-in user (:func:~zeython.auth.require_auth)
and an explicit guard callable you provide -- there is deliberately
no default that lets any authenticated user in. Forcing that choice is the
same reasoning as :class:~zeython.security_headers.SecurityHeadersServiceProvider
having no default CSP: guessing a policy for you would be worse than
requiring you to state one.
Pages are plain server-rendered HTML with a small inline script that turns
a form submission into a fetch carrying the CSRF header
(:mod:zeython.csrf reads the token from a header, not a form field --
see docs/csrf.md), not a second Jinja template system -- the admin UI
works whether or not :class:~zeython.views.ViewServiceProvider is even
registered.
AdminServiceProvider ¶
AdminServiceProvider(
app: Application,
*,
models: Sequence[type[Model]],
guard: Guard,
prefix: str = "/admin",
)
Bases: ServiceProvider
Registers a CRUD admin UI for models under prefix (default /admin).
guard is required, not optional -- lambda user: getattr(user, "is_admin", False)
for a boolean flag on your user model, or anything else that returns
(or awaits to) a bool. Every admin route also requires a logged-in
user regardless of guard -- see :func:~zeython.auth.require_auth.
::
from zeython import AdminServiceProvider
from app.Models.post import Post
from app.Models.user import User
app.register(AdminServiceProvider(
app,
models=[Post, User],
guard=lambda user: user.is_admin,
))
See docs/admin.md.
Source code in src/zeython/admin.py
ai ¶
AI-assisted app features: a small, provider-agnostic LLM client bound in the container, for calling a model from your own request handlers and jobs.
This is a different thing from zeython.mcp: that module lets an AI
agent introspect and operate on a Zeython project (Laravel Boost's role).
This module lets a Zeython app call an LLM as part of its own logic --
summarizing text, drafting a reply, classifying input -- the same role
Vercel's AI SDK or LangChain's chat models play, kept to a fraction of the
surface area.
Requires the ai extra (pip install zeython[ai]) only if you use
:class:AnthropicAI; the interface and :class:EchoAI have no extra
dependency.
AI ¶
Bases: ABC
A chat-style completion client.
complete
abstractmethod
async
¶
Send prompt (plus optional system instructions) and return the model's reply.
EchoAI ¶
Bases: AI
Returns the prompt back, unmodified, with no network call.
The default (AI_PROVIDER=echo) -- the same role :class:~zeython.mail.LogMailer
and :class:~zeython.queue.InMemoryQueue play for their subsystems: a
fresh zeython new project (and its tests) work immediately without
external credentials. Switch to :class:AnthropicAI (AI_PROVIDER=anthropic)
once you have an API key. See docs/ai.md.
AnthropicAI ¶
Bases: AI
Calls the Anthropic API via the official SDK. Requires the ai extra
(pip install zeython[ai]).
Source code in src/zeython/ai.py
AIServiceProvider ¶
Bases: ServiceProvider
Binds an :class:AI client into the container from .env.
AI_PROVIDER--echo(default, no network/credentials) oranthropicANTHROPIC_API_KEY-- required whenAI_PROVIDER=anthropicAI_MODEL-- defaultclaude-sonnet-5, only used by theanthropicprovider
Not registered by default -- opt in with app.register(AIServiceProvider)
once your app actually calls an LLM.