Writing a module¶
Xyberos has no module type, and that is the design rather than a gap. What it has is an application object whose registrations are explicit, so a module is a package that exposes one function taking the application. Nothing registers itself, nothing is discovered, and composition is a decision a reader can point at.
Two properties make a module composable, and both are about what it does not say:
- It declares nothing about the application it lands in — no name, no settings file, no assumption about what else is registered.
- Its route handlers obey the three parameter rules the HTTP adapter enforces: a path segment, the
Request, orAnnotated[T, Inject()]. A query string is read from the request, never invented.
The shape¶
"""A composable module: it registers capabilities and routes, and composes with anything.
This is the whole convention. Xyberos has no module type — what it has is an application object
whose registrations are explicit, so a module is a package that exposes one function taking the
application. Two properties make it composable:
* it declares nothing about the application it lands in — no name, no settings file, no assumption
about what else is registered;
* its route handlers obey the three parameter rules the HTTP adapter enforces: a path segment, the
`Request`, or `Annotated[T, Inject()]`. A query string is read from the request, never invented.
"""
from __future__ import annotations
from typing import Annotated
from xyberos import App, Inject
class Rates:
"""The capability this module contributes, and the contract other modules depend on."""
def __init__(self, table: dict[str, float]) -> None:
self._table = table
def discount(self, code: str) -> float:
"""Return the discount for a code, or 0.0 for a code nobody has heard of."""
return self._table.get(code.upper(), 0.0)
def register(app: App) -> None:
"""Attach this module's provider and route to an application, and return nothing."""
app.provide(Rates, instance=Rates({"SAVE10": 0.10, "PARTNER": 0.25}))
app.get("/rates/{code}")(read_rate)
async def read_rate(code: str, rates: Annotated[Rates, Inject()]) -> dict[str, object]:
"""Answer one rate: the path segment, and the capability resolved from the registry."""
return {"code": code.upper(), "discount": rates.discount(code)}
register is the whole convention: it takes the application, attaches what this module contributes,
and returns nothing. It is deliberately not a class with a bootstrap method and not a decorator —
because a plain function can be read at a glance, called in a test, and called twice against two
different applications.
Depending on a contract, not on a registration¶
"""A second composable module: it depends on the first one's contract, not on its registration.
Importing the *type* is what depending on a contract means here, and it is the only thing this
module takes from its neighbour: it never calls `module_rates.register`, so which modules are
composed, and in which order, is the application's decision rather than this module's.
"""
from __future__ import annotations
from typing import Annotated
from starlette.requests import Request
from learn_extend.module_rates import Rates
from xyberos import App, Inject
class Pricing:
"""Quotes a price, using whatever rates the application happens to have registered."""
def __init__(self, rates: Annotated[Rates, Inject()]) -> None:
self._rates = rates
def quote(self, code: str, price: float) -> float:
"""Return a price after the discount for ``code``."""
return round(price * (1 - self._rates.discount(code)), 2)
def register(app: App) -> None:
"""Attach this module's provider and route to an application."""
app.provide(Pricing)
app.get("/quote")(read_quote)
async def read_quote(request: Request, pricing: Annotated[Pricing, Inject()]) -> dict[str, object]:
"""Answer one quote, reading the query string the way every handler does."""
code = request.query_params.get("code", "")
price = float(request.query_params.get("price", "0"))
return {"code": code.upper(), "price": price, "quote": pricing.quote(code, price)}
The pricing module imports Rates — the type — and never calls module_rates.register. That is what
depending on a contract means here: this module states which capability it needs, and the application
decides who provides it. A module that wired its own neighbours would take the composition decision
away from the application, and a test that wants to replace one of them would have nowhere to stand.
Composing¶
"""Walkthrough — composing modules into one application, and replacing one of them in a test.
Run it with:
python -m learn_extend.modules01_composition
"""
from __future__ import annotations
from learn_extend import module_pricing, module_rates
from learn_extend.module_rates import Rates
from xyberos import App, Settings
from xyberos.http import HttpAdapter
from xyberos.http.testing import create_test_client
def create_app() -> App:
"""The application's own module: it decides which pieces exist, and in which order.
This function is also the target a deployment points at —
``xyberos check --app pricing.app:create_app --app-dir src`` — which is why it takes no
required arguments. A factory with required arguments is a factory the CLI and an ASGI server
cannot call.
"""
app = App(settings=Settings.load(environ={}, overrides={"app": {"name": "pricing"}}))
module_rates.register(app)
module_pricing.register(app)
return app
def main() -> None:
"""Compose two modules, serve them, and then swap one module's answer without editing either."""
app = create_app()
print("routes:", [f"{route.method} {route.path}" for route in app.routes])
print(app.diagnostics().render().strip())
with create_test_client(HttpAdapter(app)) as client:
print("GET /rates/SAVE10 ->", client.get("/rates/SAVE10").json())
print("GET /quote ->", client.get("/quote?code=SAVE10&price=100").json())
under_test = app.with_overrides({Rates: Rates({"SAVE10": 0.5})})
with create_test_client(HttpAdapter(under_test)) as tested:
print(
"the same routes, a test's rates ->",
tested.get("/quote?code=SAVE10&price=100").json(),
)
print("the clone is a different application:", under_test is not app)
if __name__ == "__main__":
main()
create_app is the application's own module, and it is the only place that knows which pieces exist.
It takes no required arguments, which is not a style preference: xyberos check --app pricing.app:create_app
--app-dir src calls it, and so does an ASGI server through an import string. A factory with required
arguments is a factory neither of those can call.
routes: ['GET /rates/{code}', 'GET /quote']
providers:
Rates [app] from application
Pricing [app] from application
plugins:
(none)
GET /rates/SAVE10 -> {'code': 'SAVE10', 'discount': 0.1}
GET /quote -> {'code': 'SAVE10', 'price': 100.0, 'quote': 90.0}
the same routes, a test's rates -> {'code': 'SAVE10', 'price': 100.0, 'quote': 50.0}
the clone is a different application: True
The last two lines are the payoff. with_overrides({Rates: ...}) changes what the pricing module
sees without editing either module, because the pricing module never asked where Rates came from —
only that it exists. A second application built from the same modules and a different Rates is a
different application, not a mocked one.
What belongs in a module¶
| Belongs | Why |
|---|---|
app.provide(...) for the contracts it owns |
the module that constructs a capability owns its lifetime |
| Routes that read those capabilities | the handler signature is the dependency declaration |
| Constants and types other modules may import | a contract is what makes composition possible |
| Nothing about the application it is registered with | the application owns composition |
| Does not belong | Why |
|---|---|
Calling another module's register |
that is the application's decision, not the module's |
| Reading the environment or a settings file | configuration reaches a module as a constructor argument |
Reaching into an internal xyberos module |
only the public surface is a stable contract |
| Registering after startup | routes and providers are frozen once the application is ready |
When a module should become a plugin. The moment somebody else has to install it. A module is code
inside your application; a plugin is a package with a name, an api_version, and typed configuration,
which the runtime can validate, order, and attribute. The mechanics are the same app.provide and
app.get calls — which is why the next page is about the wrapper and not about a second API.
Where to read next¶
- Writing a plugin — the same registrations, packaged and versioned.
- The provider registry — scopes, factories, and overrides in detail.
- HTTP handlers — the parameter rules this page refuses to work around.