Sub-applications and mounts¶
app.mount attaches a second application
under a path prefix on the parent app. The mounted thing can be another
Veloce instance, any ASGI application, or a
StaticFiles handler. This lets you
compose a large service from independently-built pieces that each own a subtree
of the URL space.
Mounting a Veloce sub-application¶
Build the child like any other app, then mount it at a prefix on the parent. The prefix is stripped before the child matches, so the child's routes are written relative to its own root.
from veloce import Request, Veloce
admin = Veloce()
@admin.get("/users")
async def list_users(request: Request):
return {"users": ["ada", "alan"]}
app = Veloce()
app.mount("/admin", admin)
@app.get("/")
async def index(request: Request):
return {"app": "main"}
if __name__ == "__main__":
app.run()
A request to /admin/users is dispatched into the admin sub-app, which sees
the path as /users. A request to / is handled by the parent.
from veloce import Request, TestClient, Veloce
admin = Veloce()
@admin.get("/users")
async def list_users(request: Request):
return {"users": ["ada", "alan"]}
app = Veloce()
app.mount("/admin", admin)
client = TestClient(app)
resp = client.get("/admin/users")
assert resp.status_code == 200
assert resp.json() == {"users": ["ada", "alan"]}
A mounted Veloce sub-app is dispatched through the parent's request pipeline — the parent matches the prefix, builds a sub-request scoped to the remaining path, and hands it to the child's request handler. The child's own routing, dependency injection, and error handling all run.
Note
A Veloce sub-app is matched before the parent's own routes and static handlers during dispatch. The parent does not need a route registered for the prefix; the mount owns the whole subtree under it.
Lifecycle fan-out to mounted Veloce apps¶
A mounted Veloce sub-app does not receive its own ASGI lifespan cycle, because
it is driven through the parent. Instead the parent fans its lifecycle out
to every mounted Veloce child:
| Phase | Behaviour |
|---|---|
| Startup | The parent runs its own startup, then each child's startup in registration order. |
| Shutdown | Children are torn down newest-first, before the parent's own on_shutdown handlers. |
| Mid-fan-out failure | If a child's startup fails mid-fan-out, the already-started children are unwound in reverse so nothing leaks. |
| Duplicate mount | The same child instance mounted under more than one prefix is started and shut down exactly once (deduplicated by identity). |
from veloce import Request, Veloce
reports = Veloce()
@reports.on_startup
async def open_pool():
reports.state.pool = "connected"
@reports.on_shutdown
async def close_pool():
reports.state.pool = None
@reports.get("/daily")
async def daily(request: Request):
return {"pool": request.app.state.pool}
app = Veloce()
app.mount("/reports", reports)
if __name__ == "__main__":
app.run()
The child's on_startup and on_shutdown hooks (and any lifespan context
manager it declares) run as part of the parent's startup and shutdown, so the
child's resources are ready before the first request and released on exit.
Note
Mount the sub-app before the parent starts. Mounting is a setup-time
operation; a child mounted after the parent has already started will not have
its startup driven. The in-memory TestClient
runs the parent's startup on construction, so build the full mount tree
before creating the client.
Mounting an arbitrary ASGI app¶
Any ASGI application — a micro-framework, an instrumentation shim, a GraphQL
endpoint — can be mounted. A non-Veloce ASGI mount is dispatched at the ASGI
layer, not through the parent pipeline: the matched prefix is moved from the
scope's path onto its root_path, so the mounted app sees a normal
root-relative request.
from veloce import Request, Veloce
async def asgi_health(scope, receive, send):
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"text/plain")],
}
)
await send({"type": "http.response.body", "body": b"ok"})
app = Veloce()
app.mount("/health", asgi_health)
@app.get("/")
async def index(request: Request):
return {"app": "main"}
if __name__ == "__main__":
app.run()
A mounted ASGI app owns its entire prefix subtree — a native route registered
under the same prefix is unreachable. The mount receives http and websocket
scopes only.
ASGI mounts run only under an ASGI server
ASGI mounts are dispatched in Veloce's ASGI entry point, which is exercised
by an ASGI server (uvicorn, hypercorn) and by the in-memory TestClient.
The native app.run() transport
dispatches Veloce sub-apps and static handlers, but does not route into
arbitrary ASGI mounts. Serve a tree that uses ASGI mounts through an ASGI
server.
ASGI mounts get no lifespan
The parent fans its lifecycle out to mounted Veloce sub-apps only. A
non-Veloce ASGI mount is never sent the ASGI lifespan events, so it must
not depend on lifespan.startup / lifespan.shutdown for its setup or
teardown. Acquire its resources another way (lazily on first request, or
from the parent's own startup hook).
Mounting StaticFiles¶
A StaticFiles handler looks ASGI-shaped
but speaks Veloce's internal handle protocol, so mount special-cases it: the
mount prefix becomes the handler's serving prefix and it is registered as a
static handler rather than an ASGI mount.
from veloce import StaticFiles, Veloce
app = Veloce()
app.mount("/assets", StaticFiles(directory="static"))
if __name__ == "__main__":
app.run()
For the common case of serving one directory, prefer
app.mount_static, which builds
the StaticFiles handler for you. See Static files for the
full handler options.
Mounting a WSGI app¶
Veloce has no built-in WSGI bridge. To mount a WSGI application (Flask, Django,
a bare def app(environ, start_response)), wrap it in an ASGI-to-WSGI adapter
and mount the resulting ASGI app. The
a2wsgi package provides one:
from a2wsgi import WSGIMiddleware
from veloce import Request, Veloce
def legacy_wsgi_app(environ, start_response):
start_response("200 OK", [("content-type", "text/plain")])
return [b"served by WSGI"]
app = Veloce()
app.mount("/legacy", WSGIMiddleware(legacy_wsgi_app))
@app.get("/")
async def index(request: Request):
return {"app": "main"}
if __name__ == "__main__":
app.run()
WSGIMiddleware is an ASGI app, so it follows every ASGI-mount rule above: it
receives http scopes with the prefix moved to root_path, gets no lifespan
events, and must be served through an ASGI server.
WSGI calls block a worker thread
WSGI is a synchronous protocol. The adapter runs the WSGI app in a thread so it does not block the event loop, but a slow WSGI request still occupies a worker thread for its full duration. Keep WSGI mounts for legacy endpoints, not high-throughput paths.
Prefix-overlap rules¶
Mount prefixes must not overlap. Registering a prefix that is equal to, nested
under, or a parent of an existing mount raises ValueError at mount time,
because overlapping mounts would shadow each other in an order-dependent way.
from veloce import Veloce
admin = Veloce()
reports = Veloce()
app = Veloce()
app.mount("/admin", admin)
app.mount("/admin/reports", reports) # raises ValueError: overlaps "/admin"
The overlap check spans both Veloce sub-app mounts and ASGI mounts: an ASGI
mount at /admin and a Veloce mount at /admin/users conflict the same way.
Bare prefixes are normalised first — a trailing slash is stripped and a leading
slash is added if missing, so mount("admin/", ...) and mount("/admin", ...)
register the same prefix and therefore collide.
from veloce import TestClient, Veloce
app = Veloce()
app.mount("/admin", Veloce())
raised = False
try:
app.mount("/admin/reports", Veloce())
except ValueError:
raised = True
assert raised
client = TestClient(app)
Note
Passing something that is neither a Veloce app, a StaticFiles instance,
nor an ASGI callable to mount raises TypeError at registration time
rather than failing on the first request.
Next steps¶
- Compose routes within a single app instead of mounting — see Routing and blueprints.
- Run startup and shutdown work for the parent and its sub-apps — see Lifespan events.
- Serve a static directory at a prefix — see Static files.
- Sit the whole tree behind a reverse proxy — see Behind a proxy.
- Full signatures are in the API reference.