Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ SM_VITE_DEV_URL=http://localhost:5050
# SM_AUTH_PUBLIC_PATHS=["/api/integrations/webhook", "/status"]

# First-boot admin seed (optional). Only applied when the users table is empty.
# Leave unset and use `uv run sm-users create-admin` instead if you prefer.
# Leave unset and use `uv run smpy users create-admin` instead if you prefer.
# SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
# SM_USERS_BOOTSTRAP_PASSWORD=changeme
# Optional second non-admin seed user (handy in dev):
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,8 +31,8 @@ All day-to-day tasks go through `make`:
| Command | Purpose |
|---|---|
| `make install` | Install Python + JS deps |
| `make dev` | Docker up + regen module pages + API (8000) and Vite (5173) in parallel |
| `make kill` | Free ports 8000/5173 |
| `make dev` | Docker up + regen module pages + API (8000) and Vite (5050) in parallel |
| `make kill` | Free ports 8000/5050/5173 |
| `make test` | Run `test-py` then `test-js` (e2e excluded by default) |
| `make test-py` / `make test-js` | Run a single suite |
| `make test-e2e` | Playwright smoke tests (requires `make dev` running + `uv run playwright install chromium`) |
Expand All@@ -42,7 +42,7 @@ All day-to-day tasks go through `make`:
| `make new-module name=<name>` | Scaffold a new module package end-to-end |
| `make gen-pages` | Regenerate `host/client_app/modules.{manifest.json,generated.ts,generated.css}` from installed modules |

Single test: `uv run pytest path/to/test_file.py::test_name` (root `pyproject.toml` sets `asyncio_mode=auto` and `-m 'not e2e'`). A single JS test: `npx vitest run <path>`.
Single test: `uv run pytest path/to/test_file.py::test_name` (root `pyproject.toml` sets `asyncio_mode=auto` and `-m 'not e2e and not perf'`). A single JS test: `npx vitest run <path>`.

Entry point: `host/main.py` (`uv run --project host uvicorn host.main:app --reload`). Alembic runs from the repo root (`host/alembic.ini`) so it shares the `.env` / `SM_DATABASE_URL` with the API.

Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
# simple_module_python

A modular-monolith framework for Python. Each feature lives in its own self-contained module — its own SQLModel tables, schema, 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.
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.

## Stack

- **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:** Local user management (email+password, cookie-based sessions) via fastapi-users
- **Auth:** Pluggable providers — local users (email+password + OAuth/OIDC: Google, GitHub, Microsoft/Entra) via fastapi-users, or Keycloak OIDC SSO; cookie sessions or bearer tokens resolved through a principal-resolver chain
- **Tooling:** uv workspaces, Ruff, ty, Biome, pytest

## Use in a new project
Expand DownExpand Up@@ -35,7 +35,7 @@ make install
# 2. Copy env template (defaults work for local SQLite dev)
cp .env.example .env

# 3. Start Postgres (skip if using SQLite — the default .env uses SQLite)
# 3. Start the shared dev-services stack — Postgres/Redis/MinIO (skip if using the default SQLite)
make docker-up

# 4. Run migrations
Expand DownExpand Up@@ -71,9 +71,11 @@ The new module is automatically discovered (via Python entry points), its routes

```
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
Expand DownExpand Up@@ -103,7 +105,7 @@ docs/
| `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-up` / `docker-down` | Manage the Postgres container (SQLite needs no Docker) |
| `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) |

## Configuration

Expand DownExpand Up@@ -202,17 +204,19 @@ SM_USERS_SMTP_TLS=true
## Architecture

- **Modules**: discovered via Python entry points at boot. Each module subclasses `ModuleBase` and opts into the lifecycle hooks it needs (`register_routes`, `register_menu_items`, `register_permissions`, `register_middleware`, `on_startup`, ...).
- **Database isolation**: PostgreSQL → one schema per module. SQLite → single schema, `__tablename__` prefixed with the module name.
- **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 → `<module middleware>` → Tenant (opt-in) → Locale → InertiaLayoutData → app.
- **Diagnostics**: `make doctor` runs 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>.json` files merged at boot into `I18nRegistry`. Frontend uses `i18next` with type-safe keys; backend uses `Babel` for CLDR plurals. Locale resolved per request via cookie → `Accept-Language` → `SM_I18N_DEFAULT_LOCALE`. See `docs/framework-conventions.md` → Internationalization.

Deeper dives in `docs/plans/`:
Full documentation lives in [`docs/`](docs/index.md) — 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:

- [Module lifecycle hooks](docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md)
- [Alembic migrations design](docs/plans/2026-04-13-alembic-migrations-design.md)
- [DB state refactor](docs/plans/2026-04-13-eliminate-global-mutable-db-state-design.md)
- [DX hardening (latest)](docs/plans/2026-04-14-dx-hardening-design.md)
- [Framework conventions](docs/framework-conventions.md)
- [Module authoring](docs/module-authoring.md)
- [E2E testing](docs/e2e-testing.md)
- [Release playbook](docs/release.md)

Historical, point-in-time design docs live under [`docs/plans/`](docs/plans/) and [`docs/superpowers/`](docs/superpowers/).

## Contributing

Expand Down
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,8 @@ export default defineConfig({
{ text: "Middleware pipeline", link: "/framework/middleware" },
{ text: "Settings & app.state", link: "/framework/settings" },
{ text: "Permissions", link: "/framework/permissions" },
{ text: "Principal resolvers", link: "/framework/principal-resolvers" },
{ text: "Public routes", link: "/framework/public-routes" },
{ text: "Events", link: "/framework/events" },
{ text: "Internationalization", link: "/framework/i18n" },
],
Expand DownExpand Up@@ -173,11 +175,13 @@ export default defineConfig({
{ text: "Overview", link: "/modules/" },
{ text: "auth", link: "/modules/auth" },
{ text: "users", link: "/modules/users" },
{ text: "keycloak", link: "/modules/keycloak" },
{ text: "permissions", link: "/modules/permissions" },
{ text: "settings", link: "/modules/settings" },
{ text: "feature_flags", link: "/modules/feature_flags" },
{ text: "file_storage", link: "/modules/file_storage" },
{ text: "background_tasks", link: "/modules/background_tasks" },
{ text: "audit_log", link: "/modules/audit_log" },
{ text: "dashboard", link: "/modules/dashboard" },
],
},
Expand Down
55 changes: 30 additions & 25 deletions docs/database/migrations.md
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
# Migrations

All migrations live in `migrations/versions/` in your app — **not** in module packages. Alembic runs from the app root (`alembic.ini`) and shares the app's `.env` / `SM_DATABASE_URL`.
All migrations live in `host/migrations/versions/` — **not** in module packages. Alembic runs from the repo root via `host/alembic.ini` and shares the app's `.env` / `SM_DATABASE_URL`.

## Why centralized?

- **Dependency ordering is global.** If `invoices` depends on `orders.order.id`, their migrations must order correctly. One linear Alembic history enforces this.
- **Autogenerate sees everything.** `migrations/env.py` calls `build_module_metadata()` to union every installed module's `MetaData`. Autogenerate diffs the DB against that union and writes one migration covering all changes.
- **Dependency ordering is global.** If `invoices` depends on `orders_order.id`, their migrations must order correctly. One linear Alembic history enforces this.
- **Autogenerate sees everything.** `host/migrations/env.py` calls `build_module_metadata()` to union every installed module's `MetaData`. Autogenerate diffs the DB against that union and writes one migration covering all changes.
- **Operators run one command.** `make migrate` is the only target. No "did you also run `orders/migrate`?" footgun.

Each module's *first* migration sets `branch_labels = ("<module_name>",)` so you can still downgrade one module at a time with `alembic downgrade <module>@base`.
Expand All@@ -15,10 +15,10 @@ Each module's *first* migration sets `branch_labels = ("<module_name>",)` so you
### Create a migration

```bash
uv run alembic revision --autogenerate -m "add orders tables"
make migration msg="add orders tables"
```

The resulting file lands in `migrations/versions/XXXX_add_orders_tables.py`.
This runs `alembic -c host/alembic.ini revision --autogenerate` from the repo root. The resulting file lands in `host/migrations/versions/XXXX_add_orders_tables.py`.

**Always open and read the generated file** before committing. Autogenerate is good but not perfect:

Expand All@@ -32,14 +32,14 @@ The resulting file lands in `migrations/versions/XXXX_add_orders_tables.py`.
make migrate
```

Runs `alembic upgrade head`. Idempotent.
Runs `alembic -c host/alembic.ini upgrade heads`. Idempotent.

### Downgrade

```bash
uv run alembic downgrade -1 # back one revision
uv run alembic downgrade <revision_id> # to a specific revision
uv run alembic downgrade orders@base # back to the state before the orders module existed
make downgrade # back one revision
uv run --project host alembic -c host/alembic.ini downgrade <revision_id> # to a specific revision
uv run --project host alembic -c host/alembic.ini downgrade orders@base # back to the state before the orders module existed
```

`orders@base` uses the `branch_labels` marker from the module's first migration. Module-level downgrade is the mechanism for uninstalling a module cleanly.
Expand All@@ -49,7 +49,7 @@ uv run alembic downgrade orders@base # back to the state before the orders m
When you scaffold a module with `smpy create-module`, the *first* autogenerate revision produces a file that needs this marker added by hand:

```python
# migrations/versions/XXXX_add_orders_tables.py
# host/migrations/versions/XXXX_add_orders_tables.py

revision = "..."
down_revision = "..."
Expand All@@ -61,27 +61,32 @@ Once the marker is in place, all future `orders` migrations inherit the branch.

## Alembic environment setup

`migrations/env.py` (in your app) looks roughly like:
`host/migrations/env.py` looks roughly like:

```python
from simple_module_db.base import build_module_metadata
from simple_module_db.migration_support import make_include_object
from simple_module_db import (
build_module_metadata,
make_include_object,
make_process_revision_directives,
render_item,
)

target_metadata = build_module_metadata()
include_object = make_include_object()
include_object = make_include_object(target_metadata)
process_revision_directives = make_process_revision_directives(target_metadata)

context.configure(
target_metadata=target_metadata,
include_object=include_object,
compare_type=True,
compare_server_default=True,
render_item=render_item,
process_revision_directives=process_revision_directives,
)
```

- `target_metadata` — union of every module's `MetaData`.
- `include_object` — filters out system tables (`alembic_version`) and any host-owned tables you don't want tracked.
- `compare_type=True` — detects type changes (e.g. `VARCHAR(50)` → `VARCHAR(100)`).
- `compare_server_default=True` — detects default-value changes.
- `include_object` — accepts only tables present in `target_metadata`, so autogenerate never diffs system tables (`alembic_version`) or any host-owned tables outside the module system.
- `render_item` — collapses SQLModel's `AutoString` to `sa.String` and renders `StrEnum` columns with `values_callable` so generated migrations are importable.
- `process_revision_directives` — re-emits expression-based (functional) indexes that autogenerate silently drops under SQLite.

## Boot-time migration check

Expand All@@ -100,17 +105,17 @@ Fires as a warning when a module's model declares a table that doesn't appear in
- You renamed a table but the old migration still references the old name.
- You used `__abstract__ = True` somewhere it shouldn't be.

The dev-mode boot log prints the offending table names. Resolution: run `uv run alembic revision --autogenerate -m "..."`, review, `make migrate`.
The dev-mode boot log prints the offending table names. Resolution: run `make migration msg="..."`, review, `make migrate`.

## Cross-module foreign keys

If `invoices` has an FK to `orders.order.id`:
If `invoices` has an FK to `orders_order.id`:

- Alembic will emit `ADD CONSTRAINT` in the invoices table's migration.
- The migration that creates `invoices_invoice` must come **after** the one that creates `orders_order` in linear history.
- `smpy create-module` + `uv run alembic revision --autogenerate` handle this naturally as long as `depends_on` is correct in `ModuleMeta`.
- `smpy create-module` + `make migration` handle this naturally as long as `depends_on` is correct in `ModuleMeta`.

On Postgres, cross-schema FKs work natively (`orders.order.id ← invoices.invoice.order_id`).
All tables share the host's single schema on both Postgres and SQLite, so a cross-module FK is just an ordinary same-schema reference (`orders_order.id ← invoices_invoice.order_id`).

On SQLite, FKs are off by default but the test suite enables them; in production SQLite use (rare), set `PRAGMA foreign_keys = ON`.

Expand DownExpand Up@@ -153,7 +158,7 @@ Keep merges small; a merge revision with its own `op.*` logic is a code smell.

When you autogenerate a migration for a freshly-added module, autogenerate writes `op.create_table(...)` for every table in the module's `MetaData`. Inspect:

- Are the schema / table names right for your provider (`orders.order` on Postgres vs `orders_order` on SQLite)?
- Are the table names right (the module-prefixed `orders_order`, identical on Postgres and SQLite)?
- Do indexes and constraints have stable names? Rename via `name=...` on the model if not.
- Did autogenerate also pick up any **other** module's tables? That means you forgot `make migrate` after the last scaffold. Squash the file down to just this module's changes.

Expand All@@ -168,7 +173,7 @@ async def test_migration_up_then_down(tmp_path):
from alembic import command

db_url = f"sqlite:///{tmp_path}/migrate_test.db"
cfg = Config("alembic.ini")
cfg = Config("host/alembic.ini")
cfg.set_main_option("sqlalchemy.url", db_url)

command.upgrade(cfg, "head")
Expand Down
Loading
Loading