A modular-monolith framework for Python. Each feature lives in its own self-contained module — its own SQLModel tables, FastAPI endpoints, React pages — but everything ships as one FastAPI + Inertia.js + React app. No microservice tax, no API-client glue; just plugin modules that compose at boot.
- Backend: Python 3.12, FastAPI, SQLModel (SQLAlchemy async + Pydantic), Alembic
- Frontend: Inertia.js + React + Tailwind CSS 4, Vite HMR
- UI: shadcn/ui primitives + emerald/teal design tokens, Sora display font, DM Sans body, JetBrains Mono code
- Auth: Pluggable providers — local users (email+password + OAuth/OIDC: Google, GitHub, Microsoft/Entra) via fastapi-users, Keycloak OIDC SSO, or any OIDC provider (Microsoft Entra, Auth0, Okta, Keycloak) via the generic
oidcmodule; cookie sessions or bearer tokens resolved through a principal-resolver chain - Tooling: uv workspaces, Ruff, ty, Biome, pytest
If you want to build an app on simple_module, not hack on the framework itself:
uvx --from simple_module_cli@latest smpy new my-app
cd my-app
make dev@latest matters: without it uvx reuses an smpy you installed earlier with uv tool install, and an older CLI scaffolds an app pinned to its own, older framework version. smpy new warns when PyPI has a newer CLI.
That scaffolds a working FastAPI + Inertia + React app with users, dashboard, and permissions pre-wired. You land on /users/login, sign in with the admin account you bootstrap, and go from there.
See CHANGELOG.md for the list of published PyPI / npm packages at each release.
# 1. Install Python and JS deps
make install
# 2. Copy env template (defaults work for local SQLite dev)
cp .env.example .env
# 3. Start the shared dev-services stack — Postgres/Redis/MinIO (skip if using the default SQLite)
make docker-up
# 4. Run migrations
make migrate
# 5. Start API + Vite dev server in parallel
make devHit http://localhost:8000 — you land on the public page. /users/login is the email+password login, /dashboard/ is the authenticated home, and /admin/doctor/ is the admin-only "smpy doctor" panel (static checks, migrations, dev server, modules).
The repo ships a default image (./Dockerfile) that builds the host plus every
bundled module — Python workspace, gen-pages, and the production Vite bundle —
and serves it with uvicorn. It is standalone: SQLite lives inside the container
under /app/data, so no Postgres and no Redis are needed to boot.
make docker-app # build ./Dockerfile, then run it on http://localhost:8000Both run targets take SM_APP_PORT=8010 when a make dev already holds
8000.
or without make:
docker build -t simple-module-python .
docker run --rm -p 8000:8000 -v simple-module-python-data:/app/data simple-module-pythonLogging in. With no SM_USERS_BOOTSTRAP_PASSWORD set, the container seeds
admin@example.com with a randomly generated password. The users module logs it
once when the first admin is created:
first-boot admin created; store this password securely: admin@example.com / <generated password>
Read it with docker logs. Pass -e SM_USERS_BOOTSTRAP_EMAIL=… -e SM_USERS_BOOTSTRAP_PASSWORD=…
to seed your own instead; an explicit changeme is refused because the image runs
with SM_ENVIRONMENT=production. The seed only applies while the users table is
empty, so on a reused volume the existing password still stands and no credentials
are logged — reset it with
smpy users create-admin --email … --password … --force.
make docker-compose-app (or docker compose up --build app) runs the same
image through compose with a named volume — independent of the shared
dev-services stack that worker/beat use.
What the entrypoint does before uvicorn binds: applies alembic upgrade heads
(a fresh volume has no tables, and production fails boot on SM010 when the DB
is behind head), and generates an ephemeral SM_SECRET_KEY when none is set —
enough to boot and log in, but sessions die on restart, so set one for anything
real.
The image runs with SM_ENVIRONMENT=production because it serves the built
bundle rather than a Vite dev server. Useful overrides:
| Variable | Image default | Why you'd change it |
|---|---|---|
SM_SECRET_KEY |
generated per start | Persist sessions across restarts |
SM_DATABASE_URL |
sqlite+aiosqlite:////app/data/app.db |
Point at Postgres |
SM_USERS_BOOTSTRAP_EMAIL / _PASSWORD |
admin@example.com / generated and logged |
Seed a first admin with a password you chose |
SM_TRUSTED_PROXY |
unset | Set to * behind a reverse proxy, so logs record the visitor's IP and not the proxy's |
No background tasks. The image skips installing the Celery module
(uv sync … --no-install-package simple-module-background-tasks), so nothing in
it wants a broker — a queue means a second process and a Redis, which is the
opposite of a standalone image. docker-compose.yml's worker / beat
services cover that path: they build docker/worker.Dockerfile against the
shared ../dev-services Postgres + Redis on the external devnet network. To
run tasks from the web process instead, drop the --no-install-package flag
from the Dockerfile and set SM_BG_TASKS_BROKER_URL / _RESULT_BACKEND.
make new-module name=ordersThat scaffolds modules/orders/ with a working CRUD module end-to-end — ModuleMeta, SQLModel table with AuditMixin, SQLModel contracts, service layer, REST + Inertia view endpoints, Browse/Create/Edit.tsx pages, and tests. Next:
# 1. Edit modules/orders/orders/models.py to your actual schema
# 2. Generate a migration
make migration msg="add orders tables"
# 3. Apply it
make migrate
# 4. Run the scaffolded tests
make testThe new module is automatically discovered (via Python entry points), its routes register at /api/orders and /orders, and its sidebar entry appears in the menu.
framework/
cli/ # smpy CLI — scaffolding, skills, package updates
core/ # module system, discovery, events, diagnostics
db/ # per-module Base, session, mixins, listeners
hosting/ # app_builder, middleware, settings, Inertia glue
testing/ # shared pytest fixtures + helpers
modules/ # plugin modules (auth, dashboard, users, settings, ...)
host/
main.py # FastAPI entry point
routes.py # host-level routes (landing page)
client_app/ # Vite + React client app
migrations/ # Alembic migrations
packages/
ui/ # shared shadcn/ui components, layouts, and design-system primitives
i18n/ # generated i18n keys + translation runtime
scripts/
new_module.py # thin wrapper over the CLI scaffolder (`make new-module`)
docs/
plans/ # design + implementation plans
framework-conventions.md # invariants for module authors
| Command | What it does |
|---|---|
make install |
Install Python (uv sync) and JS (npm install) deps |
make dev |
Docker up + API + Vite dev servers in parallel |
make test |
Run the pytest suite |
make lint |
Ruff + ty + Biome + tsc |
make doctor |
Run module diagnostics (orphan pages, missing meta, coupling violations) |
make migrate |
Apply pending Alembic migrations |
make migration msg="..." |
Autogenerate a new migration |
make new-module name=<name> |
Scaffold a new module |
make kill |
Stop any running dev servers (ports 8000, 5050, 5173) |
make docker-build / docker-app |
Build the default app image / run it standalone on port 8000 |
make docker-up / docker-down |
docker-up brings up the shared dev-services stack (Postgres/Redis/MinIO); docker-down stops only this repo's worker/beat (SQLite needs no Docker) |
Local deployments only need one env var — everything else has sensible defaults and is managed in the admin UI at /admin/settings/.
| Variable | Default | Required |
|---|---|---|
SM_DATABASE_URL |
sqlite+aiosqlite:///./app.db |
Yes — async URL. Postgres: postgresql+asyncpg://... |
SM_ENVIRONMENT |
development |
No — any value other than development, test, testing triggers strict discovery and placeholder-secret checks |
SM_SECRET_KEY |
change-me-in-production |
No in dev; must be overridden in production |
SM_VITE_DEV_URL |
http://localhost:5050 |
Dev only — Vite HMR origin |
SM_AUTH_PROVIDER |
users |
No — users, keycloak or oidc. Only read when more than one is installed; see Auth providers |
Power users can still override the following bootstrap knobs via env if needed: SM_DB_POOL_SIZE, SM_DB_MAX_OVERFLOW, SM_DB_POOL_PRE_PING, SM_DB_POOL_RECYCLE, SM_DEBUG, SM_LOG_LEVEL, SM_LOG_FORMAT, SM_MODULES_ENABLED. These are needed before the DB connection is open.
All module-level settings — users, SMTP, Celery broker, file storage backend, etc. — live in the admin UI. After upgrading an existing deployment, run once:
uv run smpy settings import-from-envto seed DB overrides from the current SM_* environment.
docker-compose note:
docker-compose.ymlsets a fewSM_BG_TASKS_*vars so Celery can reach theredisservice by container hostname before the DB-backed settings are loaded. That's deployment plumbing — not a module config knob.
See framework-conventions.md for the settings-per-module convention.
The frontend uses an emerald + teal design system mirrored as Tailwind 4 tokens. Module pages should compose from a small set of shared primitives so they stay visually consistent without duplication.
Shared primitives (in packages/ui/src/):
| Component | When to use |
|---|---|
PageShell |
Every authenticated page. Wraps title + description + actions header and a max-width content area. |
StatCard |
Top-of-page KPI tiles — icon, value, label, optional delta badge. Used on Dashboard, Users, Doctor. |
SectionTitle |
Card section headings with the gradient accent bar. |
FilterPills |
Segmented filter chips for status/tab-style toggles. |
AuthCardShell |
Login / register / forgot / accept-invite / verify — light glass card on emerald mesh blobs. |
ErrorScreen |
403 / 404 / 500 — gradient HTTP numerals + accent badge per status. |
Design tokens live in packages/ui/src/styles/globals.css under the @theme block — primary emerald scale (--color-primary-50…900), display/sans/mono families, semantic shadcn tokens. Override the CSS variables to rebrand without touching component code.
Module pages should:
- Wrap in
PageShellwithtitle, optionaldescription, andactions. - Use
Card+CardContentfrom@simple-module-py/ui/components/ui/cardfor content blocks. - Reach for
StatCard/SectionTitle/FilterPillsbefore rolling new layouts. - Use lucide-react icons (already a dependency) and the existing
Badge/Buttonvariants — emerald primary for the main CTA, outline / ghost for secondary actions.
The 300-line file cap (enforced by CI) usually pushes you to factor row-level components into pages/components/ — see modules/users/users/admin/components/UserRow.tsx and modules/dashboard/dashboard/ui/components/doctor/report.ts for the pattern.
This repo ships three authentication providers, and exactly one can be active at a time:
| Module | What it does |
|---|---|
users (default) |
Local accounts — password login, invites, signup, roles, an admin UI |
keycloak |
Delegates authentication to a Keycloak realm over OIDC |
oidc |
Delegates authentication to any OIDC provider (Entra preset; Keycloak via the generic preset) — see docs/modules/oidc.md |
They all claim the same slot (app.state.auth.auth_provider), so running two together
is a misconfiguration, not a supported combination. Installing several and activating
none is reported as SM020 (error — fails boot); installing neither is SM021.
uv sync --all-packages installs every workspace member, so a dev clone has every
provider on disk. The host therefore activates one and skips the others rather than
failing on SM020, and users is the default — keycloak and oidc are installed but inert
until you ask for it. To switch:
# .env
SM_AUTH_PROVIDER=keycloakThen re-run make gen-pages so the frontend manifest picks up the active provider's
pages (make dev does this for you), and configure the realm under
/admin/settings/. Switching back is the same knob set to users.
Two caveats when running Keycloak:
dashboard,permissions,audit_log, andbackground_tasksdeclare a dependency on theUsersmodule and import from theuserspackage, sosimple_module_usersstill has to be installed even though it is inactive. A host that wants Keycloak and none of the local-account machinery should leave those modules out of its own dependency list.- The local-account flows (
/users/login, invites, signup, the sections below) belong to theusersmodule and are gone while Keycloak is active — Keycloak's realm owns login, logout, and user administration instead.
Only one provider is ever discovered, so nothing here changes if you install just one
of them: a host that ships only keycloak (or only oidc) keeps it regardless of SM_AUTH_PROVIDER.
Either use the CLI:
uv run smpy users create-admin --email admin@example.com --password changemeOr let the app bootstrap it automatically on first boot by setting env vars before running make migrate && make dev:
SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
SM_USERS_BOOTSTRAP_PASSWORD=changeme
The auto-bootstrap is idempotent — it only creates the user if the users_user table is empty.
- Log in as admin and navigate to
/admin/users/add. - Fill in the invitee's email and optionally a full name and role(s). Click Send invite.
- With the default
consolemailer, the invite link is logged to stdout (tail -fthe server log). Copy the link and send it to the user. Withsmtp, the email is delivered automatically. - The invitee opens the link (
/users/invite/accept?token=…), sets a password, and is immediately logged in.
Set SM_USERS_ALLOW_SIGNUP=true and restart the server. The /users/register page becomes accessible.
Outgoing mail is configured in the mail module (admin UI at /admin/settings/, under Mail), not through SM_USERS_* variables. Users only needs its public link base:
SM_USERS_BASE_URL=https://your-domain.com
- Modules: discovered via Python entry points at boot. Each module subclasses
ModuleBaseand opts into the lifecycle hooks it needs (register_routes,register_menu_items,register_permissions,register_middleware,on_startup, ...). - Database: a single shared schema on both Postgres and SQLite. Each module owns its own
MetaData(so Alembic can attribute tables to it), and__tablename__is prefixed with the module name (orders_order) to avoid collisions. - Middleware pipeline (LIFO order of execution): CorrelationId → RequestLogging → SecurityHeaders → Session → InertiaMiddleware →
<module middleware>→ Tenant (opt-in) → Locale → InertiaLayoutData → app. - Diagnostics:
make doctorruns a static analyzer over installed modules looking for orphan pages, phantom renders, empty modules, framework/plugin coupling, migration drift, and locale-file consistency. Errors fail the boot in production. - Internationalization: per-module
locales/<lang>.jsonfiles merged at boot intoI18nRegistry. Frontend usesi18nextwith type-safe keys; backend usesBabelfor CLDR plurals. Locale resolved per request via cookie →Accept-Language→SM_I18N_DEFAULT_LOCALE. Seedocs/framework-conventions.md→ Internationalization.
Full documentation lives in docs/ — a VitePress site covering the guide, framework internals, database, frontend, testing, every bundled module, and reference. When conventions are ambiguous, the authoritative single-pagers are the source of truth:
Historical, point-in-time design docs live under docs/plans/ and docs/superpowers/.
- Write tests with the fixtures from the
simple_module_testplugin (db_session,authenticated_client). - Lint with
make lintbefore pushing; CI runs all four checks in parallel. - Stick to the conventions in
docs/framework-conventions.md— they're what diagnostics enforce.