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
11 changes: 11 additions & 0 deletions docs/framework/discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ meta = ModuleMeta(
view_prefix="/orders", # where the view router mounts
depends_on=["Products"], # hard ordering requirements
version="1.0.0", # semver for the module
i18n_audience="public", # who gets this module's locale catalog
)
```

Expand Down Expand Up @@ -72,6 +73,16 @@ A list of other modules' `meta.name` values that **must be loaded first**. The f

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](/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](/framework/i18n#audience).

## The `simple_module` group

Entry points are the same mechanism that ships with `importlib.metadata`:
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,4 @@ This is how the `auth.user` shared prop is built: framework middleware calls wha
- [Lifecycle hooks](/framework/lifecycle) — the lifecycle hooks in call order with examples.
- [Middleware pipeline](/framework/middleware) — execution order and how to slot your own in.
- [Settings & app.state](/framework/settings) — framework vs. module state.
- [Bundled modules](/modules/) — the ten first-party modules and what each one ships.
- [Bundled modules](/modules/) — the twelve first-party modules and what each one ships.
2 changes: 1 addition & 1 deletion docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ smpy new myapp --preset standard --with background_tasks,file_storage

Dependencies between modules are resolved automatically — e.g. `users` always pulls in `auth`, and `file_storage` pulls in `settings`. The `settings` module ships only in the `full` preset (or when added explicitly with `--with settings`).

After scaffolding, `smpy new` runs `uv sync`, `npm install`, and `alembic upgrade head` for you (skip with `--no-install` if you'd rather drive that yourself).
After scaffolding, `smpy new` runs `uv sync`, `npm install`, and `alembic upgrade heads` for you (skip with `--no-install` if you'd rather drive that yourself).

## Boot it

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ smpy new myapp --yes
cd myapp
```

`--yes` accepts the defaults (SQLite, no multi-tenancy, the `standard` preset: `auth`, `users`, `dashboard`, `permissions`). The CLI runs `uv sync`, `npm install`, and `alembic upgrade head` for you.
`--yes` accepts the defaults (SQLite, no multi-tenancy, the `standard` preset: `auth`, `users`, `dashboard`, `permissions`). The CLI runs `uv sync`, `npm install`, and `alembic upgrade heads` for you.

For an interactive run with prompts, drop the `--yes`. For a preset + extras: `smpy new myapp --preset standard --with background_tasks,file_storage --yes`.

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ features:
link: /guide/first-module
linkText: Build a module
- title: Use a bundled module
details: Eleven first-party modules ship with the framework — auth, users, keycloak, permissions, settings, file_storage, background_tasks, feature_flags, audit_log, dashboard, site_lock.
details: Twelve first-party modules ship with the framework — auth, users, keycloak, permissions, settings, file_storage, branding, background_tasks, feature_flags, audit_log, dashboard, site_lock.
link: /modules/
linkText: Browse modules
- title: Operate it in production
Expand Down
2 changes: 1 addition & 1 deletion docs/module-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ installed or a module's models change:
pip install simple_module_my_module
alembic revision --autogenerate -m "add my-module"
# review the generated file
alembic upgrade head
alembic upgrade heads
```

The host's `env.py` (scaffolded from the framework's template) calls:
Expand Down
183 changes: 156 additions & 27 deletions docs/modules/branding.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/modules/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Bundled modules

simple_module_python ships with eleven first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need.
simple_module_python ships with twelve first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need.

| Module | Depends on | What it provides |
|---|---|---|
Expand All @@ -11,7 +11,7 @@ simple_module_python ships with eleven first-party modules. Each is a regular Py
| [`settings`](/modules/settings) | — | DB-backed key/value store with system / tenant / user precedence; per-module pydantic settings registration; hot reload; `smpy settings` CLI. |
| [`feature_flags`](/modules/feature_flags) | — | Runtime feature toggles with system + tenant overrides. |
| [`file_storage`](/modules/file_storage) | `settings` | Pluggable file storage (filesystem, S3-compatible) with upload validation, presigned URLs, browse/download/delete UI. |
| [`branding`](/modules/branding) | `settings`, `file_storage` | Admin-configurable app identity — app name, logo, favicon and primary colourpushed to every page via Inertia shared props. |
| [`branding`](/modules/branding) | `settings`, `file_storage` | White-labelling — app name, logo (plus a dark-background variant), favicon, primary colour, design pack, announcement banner and configurable footer, pushed to every page via Inertia shared props. Serves the logo and favicon from anonymous routes so guests see the brand too. |
| [`background_tasks`](/modules/background_tasks) | `users` | Celery + Redis workers, persistent task history, retry, stuck-task sweep, live worker dashboard. |
| [`audit_log`](/modules/audit_log) | `users` | Automatic field-level audit trail for SQLModel entities, with an admin UI to browse change history. |
| [`dashboard`](/modules/dashboard) | `users` | Authenticated landing page with system overview (user counts, module list, health checks). |
Expand Down
76 changes: 47 additions & 29 deletions docs/reference/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Before serving traffic:
- [ ] `SM_ENVIRONMENT` set to something other than `development`/`testing` (those two are the only values treated as non-prod).
- [ ] `SM_SECRET_KEY` is a strong random value (not the default).
- [ ] `SM_DATABASE_URL` points at Postgres (`postgresql+asyncpg://`), not SQLite.
- [ ] `alembic upgrade head` run against the production DB.
- [ ] `alembic upgrade heads` run against the production DB.
- [ ] Frontend built (`npm run build` → `static/dist/`) and bundled into the image — outside `development`/`testing`, the app renders assets from the Vite manifest, not a dev server (see [Static assets](#static-assets)).
- [ ] App boot in a non-development `SM_ENVIRONMENT` starts clean. Module discovery runs strict (missing/invalid `meta` and entry-point failures raise), and the migration check (SM010) aborts boot — so a successful start covers those. The full page/locale/auth-provider diagnostic suite only runs in development, so do a clean dev boot before shipping (see [diagnostic codes](/reference/diagnostic-codes)).
- [ ] Admin bootstrap complete — an admin user exists and can log in.
Expand All @@ -19,43 +19,61 @@ Before serving traffic:

## Build

Typical Docker build for an app scaffolded by `smpy new`:

```dockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev
COPY . .
RUN uv run python -m compileall .

# Build the frontend
FROM node:20-slim AS frontend
WORKDIR /app/client_app
COPY client_app/package.json client_app/package-lock.json ./
RUN npm ci
COPY client_app .
RUN npm run build

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /app /app
# Vite builds to ../static/dist relative to client_app, i.e. /app/static/dist
COPY --from=frontend /app/static/dist /app/static/dist
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
**Every `smpy new` scaffold ships its own Docker assets** — you don't write these by hand:

| Path | What it is |
|---|---|
| `docker/host.Dockerfile` | Multi-stage app image (also used for the Celery worker/beat services) |
| `docker-compose.yml` | Stack matched to your `--db` choice, plus `redis`/`worker`/`beat` when `background_tasks` is installed |
| `.dockerignore` | Keeps `node_modules`, caches and local state out of the build context |
| `make docker-build` / `docker-up` / `docker-down` | Build, run and stop the stack |

```bash
make docker-up # builds the image, applies migrations, serves on :8000
```

Tune worker count with `--workers N` for multi-CPU boxes, or run behind a process manager like Gunicorn with Uvicorn workers. A single worker is CPU-bound (one process, the GIL) — multiple workers scale read throughput roughly linearly on a multi-core box.
The compose file pins `SM_ENVIRONMENT=production` for the app service — a container has no Vite dev server, so development mode would emit asset tags pointing at nothing.

::: warning `SM_ENVIRONMENT=production` doesn't make the default stack production-ready
A default (SQLite) scaffold's compose file also pins `SM_DATABASE_URL=sqlite+aiosqlite:////app/data/app.db`, which fails the second item in the checklist above. That stack is for local and demo runs — a real deployment wants a `--db postgres` scaffold.

The reason it isn't just a URL swap: a migration history is **dialect-frozen at autogenerate time** (`sa.false()` renders as `DEFAULT 0` under SQLite, which Postgres rejects), so containers must run the same dialect the migrations were generated against. Moving an existing SQLite scaffold to Postgres means regenerating migrations against Postgres, not editing one env var.
:::

### Why the image has one builder stage

The obvious split — a Python stage and a Node stage — **cannot work here**. The Vite build imports `modules.generated.{ts,css}`, which `gen-pages` emits by inspecting the *installed Python modules*. A Node-only stage has no venv and no entry points, so it can't generate those files and the frontend build fails for any app with module pages.

So `docker/host.Dockerfile` keeps uv and Node in one builder stage and orders the work:

1. `uv sync --all-packages --no-dev` (deps first, then the real source, for layer caching)
2. `gen-pages` — writes the page manifest and generated module imports
3. `npm run build` — the production bundle
4. `uv sync` once more, so hatch `force-include` picks up freshly built module `static/dist` output

The runtime stage is `python:3.12-slim-bookworm`, runs as a non-root `app` user (uid 10001), and carries a `HEALTHCHECK` against `/health`.

If you build your own image instead, keep that ordering — skipping `gen-pages` before the Vite build is the single most common way to get a container that builds cleanly and then serves a blank page.

### Sizing the web process

The shipped `CMD` runs a single Uvicorn worker. Tune worker count with `--workers N` for multi-CPU boxes, or run behind a process manager like Gunicorn with Uvicorn workers. A single worker is CPU-bound (one process, the GIL) — multiple workers scale read throughput roughly linearly on a multi-core box.

**Size the DB pool to the worker count.** Each worker keeps its own connection pool of up to `SM_DB_POOL_SIZE + SM_DB_MAX_OVERFLOW` (default `10 + 20 = 30`) connections, so the deployment's ceiling is `workers × (pool_size + max_overflow)`. Keep that under the database's `max_connections` (Postgres default `100`) or workers will throw `asyncpg.TooManyConnectionsError: sorry, too many clients already` under load. For example, 4 workers want roughly `SM_DB_POOL_SIZE=5`, `SM_DB_MAX_OVERFLOW=10` (≤ 60 connections). For larger fleets, put PgBouncer in front instead of growing every pool.

### Migrations on container start

The shipped `CMD` runs `alembic upgrade heads && uvicorn …`. Note **`heads` (plural)**: each module's first migration sets its own `branch_labels`, so once a second module ships one, `upgrade head` (singular) errors out.

That default is right for a single container. Once you run more than one replica, move migrations to a one-shot job instead — see [Running migrations on deploy](#running-migrations-on-deploy).

## Running migrations on deploy

Don't migrate from inside the web container's startup hookthat way lies races when scaling up. Run it as a **one-shot job** before rolling the web tier:
Once you run more than one replica, drop the `alembic upgrade heads` from the container's start commandconcurrent replicas racing the same migration is exactly the failure it causes. Run it as a **one-shot job** before rolling the web tier instead:

```bash
# one-shot container — run from the host project (default workspace scaffold)
cd host && uv run alembic upgrade head
cd host && uv run alembic upgrade heads

# then roll web tier
kubectl rollout restart deployment/web
Expand Down Expand Up @@ -152,7 +170,7 @@ Outside `development`/`testing` (i.e. any other `SM_ENVIRONMENT`), the app serve
## Zero-downtime deploys

1. Build new image.
2. Run `alembic upgrade head` as a one-shot job.
2. Run `alembic upgrade heads` as a one-shot job.
3. `kubectl rollout restart deployment/web` (rolling).
4. Watch logs for `SM010` (would mean step 2 failed silently).
5. Rollback path: `kubectl rollout undo`; old pods come back up against the new DB. **Make sure** your migrations are backward-compatible for at least one release cycle (expand/contract pattern — see below).
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/diagnostic-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ The framework runs a set of static checks over installed modules at app boot. Th
| `SM019` | WARNING | Module declares a non-empty `view_prefix` and overrides `register_routes` but registers neither menu items nor permissions — admins can't reach the pages from the sidebar or grant access from the role editor. | Add `register_menu_items` for a sidebar entry, or `register_permissions` to surface the module in the role editor (sub-pages of another module typically just register permissions). |
| `SM020` | ERROR | More than one auth-provider module is installed (e.g. both `users` and `keycloak`). | Install exactly one auth provider. |
| `SM021` | WARNING | No auth-provider module is installed. | Install an auth provider (e.g. `simple-module-users` or `simple-module-keycloak`). |
| `SM022` | WARNING | A module's `styles.css` contains a top-level `@theme`, `@custom-variant` or `@utility` block. That file is imported into `layer(components)`, where those at-rules are inert. | Move the block to the module's `theme.css`, which is imported unlayered so its tokens actually register. |
| `SM023` | WARNING | A module's `theme.css` contains an unlayered plain rule (anything but an at-rule or a `:root`-style selector). Unlayered CSS outranks every Tailwind utility. | Move the rule to the module's `styles.css`, which is imported into `layer(components)` so utilities still win. |

`SM022`/`SM023` are the two halves of the same invariant: a module's optional [`theme.css` is imported unlayered and `styles.css` into `layer(components)`](/module-authoring#styling), and CSS put in the wrong one silently does nothing (or silently wins everything).

## When diagnostics fire

Expand Down
4 changes: 2 additions & 2 deletions framework/cli/simple_module_cli/templates/host/README.md.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ cp .env.example .env
# First time only — initialize the migration history for the modules you
# picked. Inspect the generated file, then apply it.
alembic revision --autogenerate -m "initial schema"
alembic upgrade head
alembic upgrade heads

# Run the API
python main.py
Expand All @@ -39,7 +39,7 @@ uv add simple_module_my_module

# 2) generate & apply the migration (new tables + any schema changes)
alembic revision --autogenerate -m "add my-module"
alembic upgrade head
alembic upgrade heads

# 3) restart the host — the module's routes, menu items, permissions,
# feature flags, events, and health checks register automatically
Expand Down
Loading