Flaxon 0.3.0 and Teloce-Py 0.3.0 are available on PyPI. Release instructions are maintained on this site; source links identify the framework snapshot. Check version and upgrade guidance.
Modules Guide
For generated projects, use the shared settings.py, Flaxon.from_settings(),
explicit mounts in app.py, and management.py workflow. See ORM and project management
for Python migrations, model registration, permission hooks, relationship editing,
and Admin/CMS metadata storage. Backend-only and legacy adapter examples remain supported.
flaxon/modules.py adds a Flask-blueprint-style composition unit to
Flaxon: FlaxonModule. It's one file, drops into an existing Flaxon
install with no other changes, and is built entirely on Flaxon's own
real primitives (Router, Container, on_startup/on_shutdown,
mount_static, Jinax) rather than copying Flask's design wholesale.
Every example on this page has been run and verified, not just written.
Importing it is what activates it -- it attaches mount_module() onto
the real Flaxon app class the moment it's imported:
from flaxon.modules import FlaxonModule # this import alone is enough
If you're also using flaxon_cli.py-at-project-root for custom CLI
commands (see the CLI section below), make sure your flaxon/cli/discovery.py
has the sys.path fix -- without it, that specific discovery path silently
finds nothing when run through the installed flaxon command (a real,
pre-existing bug, unrelated to modules.py). The ./cli/*.py discovery path
doesn't need the fix and works either way.
Quick Start
from flaxon import Flaxon
from flaxon.modules import FlaxonModule
users = FlaxonModule("users")
@users.get("/")
async def list_users():
return {"users": ["alice", "bob"]}
@users.get("/<int:user_id>")
async def get_user(user_id: int):
return {"id": user_id}
app = Flaxon("my-app")
app.mount_module(users, prefix="/api/v1/users")
Run with flaxon run app:app --reload. GET /api/v1/users/ and
GET /api/v1/users/42 both work. The module itself has no idea it's
mounted under /api/v1/users -- that's decided entirely at
mount_module() time, not baked into the module when it's authored.
That's the core design difference from Flask blueprints, whose
url_prefix handling has historically been a source of confusion.
Large application pattern
Use one FlaxonModule for each business capability. Keep the application
factory responsible for composition, and keep feature code responsible for
its own routes, schemas, services, templates, and tests. This lets a large
application grow without turning app.py into a registry of unrelated
handlers.
myapp/
|-- app/
| |-- main.py
| |-- config.py
| `-- modules/
| |-- catalog/
| | |-- __init__.py
| | `-- module.py
| |-- accounts/
| | |-- __init__.py
| | `-- module.py
| `-- ai_tools/
| |-- __init__.py
| `-- module.py
|-- tests/
`-- pyproject.toml
The entry point should only create the app and mount features:
# app/main.py
from flaxon import Flaxon
from app.modules.accounts.module import install_accounts
from app.modules.catalog.module import install_catalog
def create_app() -> Flaxon:
app = Flaxon("store", debug=False)
install_accounts(app)
install_catalog(app)
return app
app = create_app()
Define routes and request models beside the feature that owns them:
# app/modules/catalog/module.py
from typing import Any
from pydantic import BaseModel
from flaxon.modules import FlaxonModule
catalog = FlaxonModule("catalog")
class CreateProduct(BaseModel):
name: str
price: float
@catalog.post("/")
async def create_product(product: CreateProduct) -> CreateProduct:
# Replace this with a service/repository call in a real application.
return product
def install_catalog(app: Any) -> None:
app.mount_module(catalog, prefix="/api/products")
The same feature can expose MCP tools without mixing protocol startup into the HTTP application:
# app/modules/ai_tools/module.py
from typing import Any
from fastmcp import FastMCP
from flaxon.integrations.fastmcp import mount_fastmcp
mcp = FastMCP("Store Tools")
@mcp.tool
async def find_product(name: str) -> dict[str, str | bool]:
return {"name": name, "available": True}
def install_ai_tools(app: Any, auth_provider: Any) -> None:
mount_fastmcp(
app,
mcp,
path="/mcp",
auth=auth_provider,
require_auth=True,
stateless_http=True,
)
auth_provider is your application's configured authentication provider. Do
not replace it with require_auth=False for a public deployment. Do not call
mcp.run() from a module; mount_fastmcp() owns the mounted ASGI lifecycle.
Decide where code goes
| Code | Location | Why |
|---|---|---|
| URL and HTTP method | FlaxonModule route |
Defines the public HTTP contract |
| JSON input shape | Pydantic model or Flaxon Schema |
Gives clients predictable validation errors |
| Business rules | Service called by the route/tool | Reusable from HTTP, MCP, tasks, and tests |
| Database queries | Repository or adapter | Keeps storage replaceable and testable |
| HTML | Feature-owned Jinax templates | Keeps presentation out of services |
| MCP tools/resources | Feature-owned FastMCP instance |
Keeps MCP names and permissions together |
| App-wide wiring | create_app() |
Makes startup, testing, and deployment consistent |
Install the optional integrations only when you use them:
python -m pip install "flaxon[pydantic,mcp]"
flaxon run app.main:app --reload
The complete runnable versions are in
examples/pydantic_api/ and examples/fastmcp_app/.
Why the prefix is decided at mount time (not authoring time)
This matters in practice, not just in theory -- it's what makes a module reusable for API versioning without copy-pasting it:
api = FlaxonModule("api")
@api.get("/ping")
async def ping():
return {"pong": True}
app = Flaxon("versioned-app")
app.mount_module(api, prefix="/v1", name="api-v1")
app.mount_module(api, prefix="/v2", name="api-v2")
Both /v1/ping and /v2/ping work, from the exact same module
instance. The name= is required here because two mounts of the same
module default to the same name and would otherwise collide -- see
"Duplicate mounts" below.
Declaring dependencies with requires()
A module can declare what it needs from the app's dependency-injection
container. If the dependency isn't registered, mounting fails
immediately with a clear error -- not an AttributeError three requests
later in production.
from flaxon import Flaxon
from flaxon.modules import FlaxonModule, ModuleDependencyError
posts = FlaxonModule("posts")
posts.requires("db")
@posts.get("/")
async def list_posts(db):
return await db.fetch_all("SELECT * FROM posts")
app = Flaxon("blog-app")
try:
app.mount_module(posts, prefix="/posts")
except ModuleDependencyError as exc:
print(exc) # "Module 'posts' requires ['db'] in app.container, but ..."
# Register the dependency, then it mounts fine:
app.container.register_instance("db", my_database_connection)
app.mount_module(posts, prefix="/posts")
Handlers ask for dependencies by parameter name (db above) -- same
injection convention as any other Flaxon route, since modules just wrap
the real router underneath.
Module-scoped request hooks
before_request and after_request only run for routes registered on
that module -- they don't affect routes elsewhere in your app.
from flaxon import Flaxon
from flaxon.modules import FlaxonModule
from flaxon.exceptions import Unauthorized
admin_area = FlaxonModule("admin_area")
@admin_area.before_request
async def require_admin_header(request):
if request.headers.get("x-admin-key") != "secret":
raise Unauthorized("Missing or invalid admin key")
@admin_area.after_request
async def log_access(request, result):
print(f"Admin route accessed: {request.path}")
@admin_area.get("/stats")
async def stats():
return {"visits": 1000}
app = Flaxon("app-with-admin-area")
app.mount_module(admin_area, prefix="/admin-area")
A before_request that raises genuinely blocks the handler -- it never
runs at all, not even partially. This is implemented by wrapping each
endpoint at mount time (not by patching Flaxon's internal request
dispatcher, which would be far riskier) -- the wrapper preserves the
original handler's exact signature, so path params, request injection,
and DI-resolved dependencies still work correctly whether or not the
handler itself declares a request parameter.
Module-scoped error handlers
from flaxon import Flaxon
from flaxon.http import JSONResponse
from flaxon.modules import FlaxonModule
class PostNotFound(Exception):
pass
posts = FlaxonModule("posts")
@posts.errorhandler(PostNotFound)
async def handle_missing_post(request, exc):
return JSONResponse({"error": str(exc)}, status_code=404)
@posts.get("/<int:post_id>")
async def get_post(post_id: int):
if post_id not in real_posts_store:
raise PostNotFound(f"No post with id {post_id}")
return real_posts_store[post_id]
app = Flaxon("blog")
app.mount_module(posts, prefix="/posts")
Only exceptions of the registered type (or a subclass) raised within this module's own routes are caught here. Exceptions from unrelated routes elsewhere in the app, or exception types this module hasn't registered a handler for, propagate as normal and hit your app's regular error handling.
Static files and templates
from flaxon import Flaxon
from flaxon.modules import FlaxonModule
from flaxon.http import Request
blog = FlaxonModule("blog", template_dir="templates", static_dir="static")
@blog.get("/")
async def home(request: Request):
return await request.render("index.html")
app = Flaxon("blog-app", debug=True)
app.mount_module(blog, prefix="/blog")
static_diris served at/static/<module name>/...(uses the realapp.mount_static(), which is idempotent and path-traversal safe).template_diris wired into the app's template loader. If the app doesn't have a template engine set up yet, mounting the module sets one up for you. If it already does, the module's templates become a fallback -- the app's own templates always take precedence for a same-named file. This mirrors Flask's blueprint template-namespacing intent without requiring a<module_name>/prefix convention on every filename.
Lifecycle hooks
from flaxon import Flaxon
from flaxon.modules import FlaxonModule
blog = FlaxonModule("blog")
@blog.on_startup
async def warm_cache():
print("Warming blog cache...")
@blog.on_shutdown
async def flush_cache():
print("Flushing blog cache...")
app = Flaxon("blog-app")
app.mount_module(blog, prefix="/blog")
These forward directly into the app's real ASGI lifespan hooks
(app.on_startup/app.on_shutdown) -- they fire on real startup/shutdown
events (e.g. under flaxon run), the same as any hook registered
directly on the app.
Nested modules
from flaxon import Flaxon
from flaxon.modules import FlaxonModule
v1_users = FlaxonModule("users")
@v1_users.get("/")
async def list_users():
return {"users": []}
api = FlaxonModule("api")
api.register_module(v1_users, prefix="/v1/users")
app = Flaxon("nested-app")
app.mount_module(api, prefix="/api")
# final resolved path: GET /api/v1/users/
Nest as deep as you want. A module can't be nested inside itself, and
cycles (a nests b, b nests a) are detected and rejected with
ModuleCycleError at register_module() time, before you ever try to
mount anything.
CLI commands owned by a module
Flaxon's CLI already supports plugin-style command discovery from two
places: a flaxon_cli.py at your project root, or any ./cli/*.py
file. FlaxonModule hooks into this directly -- no core CLI changes
needed.
# cli/blog.py
from flaxon.modules import FlaxonModule
blog = FlaxonModule("blog")
@blog.cli_command("seed", help_text="Seed demo blog posts")
async def seed_posts(console):
console.info("Seeding demo posts...")
# ... insert demo data ...
blog.install_cli_commands(globals())
Then:
flaxon --help # "seed" shows up in the command list
flaxon seed # actually runs it
Command functions can be sync or async, and can optionally take args
as a second parameter if they need to read CLI flags:
@blog.cli_command("import")
async def import_posts(console, args):
console.info(f"Importing from: {args.command}")
Use ./cli/*.py, not flaxon_cli.py, unless you've applied the
discovery.py fix. The flaxon_cli.py-at-project-root path relies on
plain import flaxon_cli, which only works if the current directory
happens to already be on sys.path -- verified this is not reliably
true when running the installed flaxon console script. ./cli/*.py
uses explicit file loading instead and works regardless.
Testing a module in isolation
No full app needed:
import asyncio
from flaxon.modules import FlaxonModule, ModuleTestClient
async def test_ping():
mod = FlaxonModule("api")
@mod.get("/ping")
async def ping():
return {"pong": True}
client = ModuleTestClient(mod)
response = await client.request("GET", "/ping")
assert response.status_code == 200
asyncio.run(test_ping())
ModuleTestClient builds a throwaway Flaxon app, mounts your module
at the root, and wraps it in the real AsyncTestClient -- genuinely
low-risk since AsyncTestClient only needs a bare ASGI callable, not
anything Flaxon-specific.
Production considerations
Duplicate mounts fail loudly, on purpose
app.mount_module(users, prefix="/a")
app.mount_module(users, prefix="/b") # raises ModuleAlreadyMountedError
This is intentional -- a second full-module mount under the same
default name is more likely a real bug (accidentally mounting the same
module twice) than something you meant to do. If you do mean it
(e.g. API versioning), pass distinct name= values, as shown earlier.
requires() as a deploy-time safety net
Put every hard dependency your module needs in requires(). Combined
with mounting all your modules at app-startup (not lazily, not inside a
request handler), a misconfigured deployment -- a missing database
connection, a missing cache client -- fails immediately when the app
starts, not on the first real request from a user.
Error handlers and information leakage
A module-scoped errorhandler fully controls what gets sent back to
the client for its exception types. Don't accidentally leak internals:
@posts.errorhandler(DatabaseError)
async def handle_db_error(request, exc):
# Log the real exception server-side...
logger.error(f"DB error: {exc}", exc_info=exc)
# ...but never put exc's raw message straight into the response.
return JSONResponse({"error": "Internal error"}, status_code=500)
No unmounting
There's no app.unmount_module(). Once mounted, a module's routes stay
registered for the app's lifetime. This is deliberate -- runtime
unmounting of routes is a much larger design problem (in-flight
requests, cache invalidation, route-table consistency) that this cut
doesn't attempt to solve. If you need conditional features, decide
whether to mount a module before calling mount_module(), not after:
if feature_flags.get("new_dashboard"):
app.mount_module(dashboard_v2, prefix="/dashboard")
else:
app.mount_module(dashboard_v1, prefix="/dashboard")
WebSocket routes are handled, but check your version
Flaxon's own Router.include_router() re-prefixes HTTP routes
correctly but does not re-prefix WebSocket routes at all (verified
against the router source). modules.py works around this itself, so
WebSocket routes on a module mount correctly regardless -- but if you
ever bypass mount_module() and call include_router() directly on a
module's .router, you'll hit this gap yourself.
Route collision warnings are expected and fine
Mounting a module whose routes overlap with an existing catch-all
pattern (e.g. a literal route landing inside a broader <param>
pattern from something else, like AdminDashboard) will log a
collision warning at mount time. This is informational, not an error --
Flaxon's router resolves these correctly via specificity (literal
segments always win over <param> segments, regardless of which was
registered first). Don't be alarmed by a noisy startup log full of
these on an app with several plugins mounted; check that the specific
route you care about actually resolves to what you expect, using
flaxon routes app:app.
Full example: a real module, everything together
# cli/blog.py
from flaxon import Flaxon
from flaxon.http import JSONResponse, Request
from flaxon.exceptions import Unauthorized
from flaxon.modules import FlaxonModule
class PostNotFound(Exception):
pass
blog = FlaxonModule("blog", template_dir="templates", static_dir="static")
blog.requires("db")
@blog.before_request
async def require_api_key(request):
if request.headers.get("x-api-key") != "demo-key":
raise Unauthorized("Missing API key")
@blog.errorhandler(PostNotFound)
async def handle_missing(request, exc):
return JSONResponse({"error": str(exc)}, status_code=404)
@blog.get("/posts")
async def list_posts(db):
return await db.fetch_all("SELECT * FROM posts")
@blog.get("/posts/<int:post_id>")
async def get_post(post_id: int, db):
post = await db.fetch_one("SELECT * FROM posts WHERE id = ?", post_id)
if not post:
raise PostNotFound(f"No post with id {post_id}")
return post
@blog.get("/")
async def home(request: Request):
return await request.render("index.html")
@blog.on_startup
async def warm_cache():
print("Blog module ready.")
@blog.cli_command("seed", help_text="Seed demo blog posts")
async def seed_posts(console):
console.info("Seeding demo posts...")
blog.install_cli_commands(globals())
# app.py
from flaxon import Flaxon
from cli.blog import blog # reuse the same module instance defined above
app = Flaxon("blog-app", debug=True)
app.container.register_instance("db", my_database_connection)
app.mount_module(blog, prefix="/blog")
flaxon run app:app --reload # serves the app
flaxon seed # runs the module's own CLI command
flaxon routes app:app # confirms everything mounted where you expect
Use this capability in a full-stack app
Flaxon serves Python APIs and Teloce interfaces in one project. Keep validation, authorization and privileged operations on the server; components call those endpoints and present their results. Follow the full-stack lessons and Teloce integration reference to connect this capability to pages, components and application modules.