Discovery & entry points
Modules are Python packages registered via the simple_module entry-point group. There is no module registry file, no INSTALLED_MODULES list in settings — if the package is installed in the environment and declares the entry point, it's a module.
Declaring a module
Every module's pyproject.toml ends with:
[project.entry-points.simple_module]
orders = "orders.module:OrdersModule"- The key (
orders) is the entry-point name. It's used for reporting only — the framework authoritative name isModuleMeta.name. - The value is
<import_path>:<class_name>. The class must inherit fromsimple_module_core.module.ModuleBaseand declare a non-nullmeta = ModuleMeta(...)class attribute.
Entry points are registered at install time, not at import time. After editing a module's pyproject.toml, re-run uv sync --all-packages (or make install) so the installer writes the new metadata to the venv's *.dist-info/entry_points.txt.
Discovery algorithm
simple_module_core.discovery.discover_modules():
- Iterates
importlib.metadata.entry_points(group="simple_module"). - For each entry point, calls
.load()to import the module'smodule.pyand pull out the class, then instantiates it (module_cls()). - Validates: the loaded object must be a
ModuleBasesubclass with ametaattribute of typeModuleMeta. - If
SM_MODULES_ENABLEDis set, filters to only the modules whoseModuleMeta.name(case-insensitive) appears in the list. - Returns a
listofModuleBaseinstances (one per module, constructed with no args). A separatetopological_sort()call (made bycreate_app) then orders them byModuleMeta.depends_onusing a stable tiebreaker (name).
Failures:
- Import error (e.g. syntax error in
module.py) — lenient mode: logged + skipped. Strict mode: raisesInvalidModuleError. - Missing
meta— emitsSM001. Strict mode: raises. - Duplicate
meta.name— emitsSM008(error). Always fails boot, even in dev, because names are used as table-name prefixes. - Cycle in
depends_on— raisesCircularDependencyError. Always fails boot. - Missing
depends_ontarget (a module that is not installed, not enabled, or dropped because another auth provider was selected) — outside development,topological_sort(strict=True)raises oneMissingDependencyError(anInvalidModuleError) naming every(module, dependency)pair, and the boot fails. In development it is logged per pair and reported asSM030(a warning at boot, an error inmake doctor). The outbox worker sorts leniently. GH #501.
ModuleMeta
from simple_module_core.module import ModuleMeta
meta = ModuleMeta(
name="Orders", # PascalCase, globally unique
route_prefix="/api/orders", # where the API router mounts
view_prefix="/orders", # where the view router mounts
depends_on=["Products"], # required, and sorted after
optional_depends_on=["Settings"], # sorted after when loaded; may be absent
version="1.0.0", # semver for the module
i18n_audience="public", # who gets this module's locale catalog
)name
The authoritative identifier. It's used for:
- Table-name prefix (
orders_*) — every module's tables share the host's single schema on both Postgres and SQLite, so the prefix is what keeps them collision-free. - Inertia page namespace (
"Orders/Browse"). - Diagnostic reporter name.
- Feature-flag and permission grouping.
Rule of thumb: use PascalCase and keep it stable. Renaming a module after release requires a data migration and a coordinated client-side rename.
depends_on
A list of other modules' meta.name values that must be loaded first. The framework topo-sorts on this before invoking lifecycle hooks. Use this when:
- Your module's middleware must sit inside/outside another module's middleware. See Middleware pipeline.
- Your module's
register_routesreads another module's service to compose endpoints. - Your module subscribes to events published by another module.
depends_on is not a dependency-injection mechanism. It controls order and presence — you still import the services you need. Outside development a missing entry refuses the boot (see Failures above), so list only modules yours cannot work without.
optional_depends_on
Modules to sort after when they are loaded, for an ordering-only relationship: your middleware wraps outside theirs, or you subscribe to an event they publish, but your module works without them. Present modules order exactly as depends_on would, so moving an entry here never changes the boot order; an absent one is fine in every environment. The modules that sorted after Settings only for ordering (users, tenants, file_storage, background_tasks, site_lock, api_tokens, keycloak) declare it here, because the settings mechanism lives in the framework (#500) and the settings plugin may be disabled.
version
Semver string. Used in diagnostic/boot logging to help operators correlate deployed module versions with bug reports. Unless a module declares requires_framework, it is not parsed for automated version-range checks.
requires_framework
Optional PEP 440 specifier (e.g. ">=1.0,<2.0") for the framework API version the module supports. When set, the module is rejected at boot if the installed simple_module_core.FRAMEWORK_API_VERSION doesn't satisfy it. When None (the default), no compatibility check runs. See module authoring.
i18n_audience
Who the module's locale catalog is shipped to — "public" (the default) or "admin".
Catalogs ride the Inertia shared props on full page loads, so a module whose UI sits entirely behind login can declare i18n_audience="admin" and stop anonymous visitors downloading admin form labels on every public page. The catalog is shipped as soon as the user authenticates, and server-side Translator lookups always see every namespace either way. See Internationalization → Audience.
The simple_module group
Entry points are the same mechanism that ships with importlib.metadata:
.venv/lib/python3.12/site-packages/orders-1.0.0.dist-info/entry_points.txt:
[simple_module]
orders = orders.module:OrdersModuleYou can inspect what the framework sees with:
uv run python -c "
from importlib.metadata import entry_points
for ep in entry_points(group='simple_module'):
print(ep.name, '->', ep.value)
"Disabling modules at runtime
Set SM_MODULES_ENABLED to a comma-separated allow-list of module names (matched case-insensitively against ModuleMeta.name). Useful for:
- Running tests against a minimal world (
SM_MODULES_ENABLED=Users,Orders). - Temporarily disabling a misbehaving module in production without a redeploy.
- Sharding a large app into multiple deployments (e.g. background-worker process loads only the
background_tasksmodule).
Disabling via env doesn't remove the module's database tables. Re-enabling picks up where it left off.
Strict-mode checklist
Before flipping to SM_ENVIRONMENT=production, verify:
- [ ] Every module's
pyproject.tomldeclares thesimple_moduleentry point. - [ ] Every
ModuleBasesubclass hasmeta = ModuleMeta(...). - [ ] App boot in development reports no diagnostic errors (
SM001,SM008,SM009,SM010,SM016are all ERROR-level — production boot will fail on any of them). - [ ]
alembic upgrade headhas been run against the production DB. - [ ]
SM_SECRET_KEYis notchange-me-in-production.
Failure on any of the above produces a clean boot-time exception rather than a silent misbehavior.