simple-module-cli 0.0.20__tar.gz → 0.0.22__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/PKG-INFO +1 -1
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/pyproject.toml +1 -1
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/simple-module-cli/SKILL.md +51 -15
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/simple-module-conventions/SKILL.md +1 -1
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/simple-module-creating/SKILL.md +32 -18
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/simple-module-database/SKILL.md +3 -3
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/simple-module-doctor/SKILL.md +15 -10
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +15 -11
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/simple-module-locales/SKILL.md +11 -2
- simple_module_cli-0.0.22/simple_module_cli/skills/simple-module-migrations/SKILL.md +124 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/simple-module-registries/SKILL.md +20 -12
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/simple-module-testing/SKILL.md +8 -7
- simple_module_cli-0.0.20/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -103
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/.gitignore +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/LICENSE +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/README.md +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/__init__.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/_env.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/app_project.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/case.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/catalog.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/cli.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/new.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/package_update.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/pins.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/plugins.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/recipes.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/scaffolding.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills/README.md +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/skills_cmd.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/.env.example +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/.gitignore +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/Makefile +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/README.md.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/alembic.ini +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/client_app/styles.css +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/client_app/vite.config.ts +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/main.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/migrations/env.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/routes.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/templates/index.html +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/.gitignore +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/README.md.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/package.json.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/workspace/.env.example +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/workspace/.gitignore +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/workspace/Makefile +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/workspace/README.md.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/workspace/pyproject.toml.tpl +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/wizard.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_build_packaging.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_case.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_cli_catalog.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_cli_new.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_cli_new_dest_tolerance.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_cli_new_regressions.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_cli_new_scaffold_layout.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_cli_package_update.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_cli_recipes.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_cli_wizard.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_create_module_ci_skip.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_env_helper.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_module_pages_manifest.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_no_framework_deps.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_plugin_discovery.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_scaffold_rollback.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_scaffolding_host.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_scaffolding_module.py +0 -0
- {simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/tests/test_skills_cmd.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.22
|
|
4
4
|
Summary: Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host.
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: simple-module-cli
|
|
3
|
-
description: Use when invoking the `smpy` CLI for a simple_module_python project — starting a new app, scaffolding a host or a publishable module, regenerating the Inertia page manifest, importing settings overrides from env, creating an admin user, or installing the bundled agent skills. Triggers on "smpy new", "smpy create-host", "smpy create-module", "smpy host gen-pages", "smpy users create-admin", "smpy skills add", or any unfamiliar `smpy` subcommand.
|
|
3
|
+
description: Use when invoking the `smpy` CLI for a simple_module_python project — starting a new app, scaffolding a host or a publishable module, bumping simple_module_* dependencies to their latest PyPI versions, regenerating the Inertia page manifest, importing settings overrides from env, creating an admin user, or installing the bundled agent skills. Triggers on "smpy new", "smpy create-host", "smpy create-module", "smpy package-update", "smpy host gen-pages", "smpy users create-admin", "smpy skills add", or any unfamiliar `smpy` subcommand.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# simple_module_python: the `smpy` CLI
|
|
7
7
|
|
|
8
|
-
The `smpy` command is provided by `simple_module_cli` (installed as a dep of `simple_module_hosting`). It groups several kinds of operations: scaffolding new things, project-time helpers for the host, admin shortcuts for the bundled modules, installing the bundled agent skills, and bumping `simple_module_*` deps.
|
|
8
|
+
The `smpy` command is provided by `simple_module_cli` (installed as a dep of `simple_module_hosting`); `simple-module` is an identical alias for the same entry point. It groups several kinds of operations: scaffolding new things, project-time helpers for the host, admin shortcuts for the bundled modules, installing the bundled agent skills, and bumping `simple_module_*` deps.
|
|
9
|
+
|
|
10
|
+
`new`, `create-host`, `create-module`, `package-update`, and `skills` are built into `simple_module_cli` and always present. `host` / `settings` / `users` are **plugin subgroups** contributed by installed packages (`simple_module_hosting` / `simple_module_settings` / `simple_module_users`) through the `simple_module_cli.cli_plugins` entry-point group — they only show up when that package is installed, which is why a fresh bare host won't list `smpy users` until the users module is added.
|
|
9
11
|
|
|
10
12
|
## Top-level commands
|
|
11
13
|
|
|
@@ -24,19 +26,24 @@ The `smpy` command is provided by `simple_module_cli` (installed as a dep of `si
|
|
|
24
26
|
|
|
25
27
|
The fastest way from zero to a working app. It calls `create-host` under the hood, then installs and wires up the modules you pick.
|
|
26
28
|
|
|
29
|
+
App names must be **lowercase** with a single separator style (`my_app`, `my-app`, or `myapp`) — `smpy new` rejects mixed case / spaces / mixed separators up front (`MyApp`, `foo_bar-baz` error out). `create-host` and `create-module` are lenient and accept any case.
|
|
30
|
+
|
|
27
31
|
```bash
|
|
28
32
|
# Interactive (asks for DB, tenancy, preset, module list)
|
|
29
|
-
smpy new
|
|
33
|
+
smpy new my_app
|
|
30
34
|
|
|
31
35
|
# Non-interactive: take all defaults (sqlite, no tenancy, standard preset)
|
|
32
|
-
smpy new
|
|
36
|
+
smpy new my_app --yes
|
|
33
37
|
|
|
34
38
|
# Pick a preset and add extras
|
|
35
|
-
smpy new
|
|
36
|
-
smpy new
|
|
39
|
+
smpy new my_app --preset full --tenancy --db postgres
|
|
40
|
+
smpy new my_app --preset minimal --with background_tasks,file_storage --yes
|
|
37
41
|
|
|
38
|
-
#
|
|
39
|
-
smpy new
|
|
42
|
+
# Consume-only host: no modules/ dir, no sample module to author against
|
|
43
|
+
smpy new my_app --preset standard --flat --yes
|
|
44
|
+
|
|
45
|
+
# Scaffold only — skip uv sync / npm install / the initial migration
|
|
46
|
+
smpy new my_app --no-install
|
|
40
47
|
```
|
|
41
48
|
|
|
42
49
|
**Module presets:**
|
|
@@ -45,10 +52,14 @@ smpy new MyApp --no-install
|
|
|
45
52
|
|---|---|
|
|
46
53
|
| `minimal` | `users` (and `auth` as a dep) |
|
|
47
54
|
| `standard` (default) | `users`, `dashboard`, `permissions` (+ deps) |
|
|
48
|
-
| `full` | every module in the catalog |
|
|
49
|
-
| `custom` | interactive — pick each module yes/no |
|
|
55
|
+
| `full` | every module **in the scaffolder catalog** (the 8 keys below) — *not* every module in the monorepo |
|
|
56
|
+
| `custom` | interactive — pick each catalog module yes/no |
|
|
57
|
+
|
|
58
|
+
`--preset` only accepts `minimal`, `standard`, or `full`. **`custom` is wizard-only** — there's no `--preset custom`; to hand-pick modules non-interactively, start from any preset and add the rest with `--with`.
|
|
50
59
|
|
|
51
|
-
`--with` accepts a comma-separated list of catalog keys (`auth, users, permissions, dashboard, settings, feature_flags, file_storage, background_tasks`). Transitive `requires` are auto-added; the wizard prints `Added X (required by Y)` so you can see what got pulled in.
|
|
60
|
+
`--with` accepts a comma-separated list of catalog keys (`auth, users, permissions, dashboard, settings, feature_flags, file_storage, background_tasks`). Transitive `requires` are auto-added; the wizard prints `Added X (required by Y)` so you can see what got pulled in. An unknown key fails fast with the available list (so `--with=does_not_exist` is self-correcting). Note the catalog is a curated subset — repo modules like `products`, `keycloak`, and `audit_log` are **not** catalog keys; add those to an existing host by installing the package (see the pitfall below).
|
|
61
|
+
|
|
62
|
+
Selecting `background_tasks` also runs its post-scaffold **recipe**: it drops `docker-compose.yml`, `scripts/run_worker.py`, host/worker Dockerfiles, a `SM_BG_TASKS_BROKER_URL` entry in `.env.example`, and Celery `make` targets. The closing "Next steps" then reminds you to run `docker compose up -d redis worker beat` for the worker/beat processes.
|
|
52
63
|
|
|
53
64
|
**Options summary:**
|
|
54
65
|
|
|
@@ -57,10 +68,13 @@ smpy new MyApp --no-install
|
|
|
57
68
|
| `--dest <PATH>` | `./<name>` | Where to write the project |
|
|
58
69
|
| `--db sqlite\|postgres` | `sqlite` | Backend configured in `.env.example` |
|
|
59
70
|
| `--tenancy / --no-tenancy` | `--no-tenancy` | Enable the multi-tenant middleware |
|
|
60
|
-
| `--preset minimal\|standard\|full` | wizard asks | Module bundle |
|
|
71
|
+
| `--preset minimal\|standard\|full` | wizard asks | Module bundle (no `custom` — that's wizard-only) |
|
|
61
72
|
| `--with <names>` | none | Extra catalog keys beyond the preset |
|
|
73
|
+
| `--flat` | off | Skip the `modules/` directory and the sample module — for a host that only ever consumes published modules and never authors its own |
|
|
62
74
|
| `--yes / -y` | off | Skip prompts; accept defaults |
|
|
63
|
-
| `--no-install` | off | Skip `uv sync` / `npm install` / `
|
|
75
|
+
| `--no-install` | off | Skip `uv sync` / `npm install` / the autogenerated initial migration; the printed next steps use `make migration` / `make migrate` / `make dev` instead |
|
|
76
|
+
|
|
77
|
+
Passing `--preset` or any `--with` value switches `smpy new` into flag-driven mode (no wizard) even without `--yes`; DB and tenancy then come from `--db` / `--tenancy` defaults. The validated name is also normalized to a PyPI-safe kebab-case slug — `smpy new my_app` prints `Normalizing PyPI name to 'my-app'.` and uses that for the package metadata.
|
|
64
78
|
|
|
65
79
|
## `smpy create-host <name>` — bare host
|
|
66
80
|
|
|
@@ -70,7 +84,7 @@ smpy create-host MyApp --with Auth,Products # declare module deps in pyproject.
|
|
|
70
84
|
smpy create-host MyApp --dest ./apps/myapp # custom destination
|
|
71
85
|
```
|
|
72
86
|
|
|
73
|
-
`--with` takes **PascalCase module names** (matching `ModuleMeta.name`), not catalog keys. Use this when you want to
|
|
87
|
+
`--with` takes **PascalCase module names** (matching `ModuleMeta.name`), not catalog keys — each is lowercased into a `simple_module_<name>` dependency in `pyproject.toml`. Unlike `smpy new`, the names are **not** checked against the scaffolder catalog, so any published module works (e.g. `--with Products` → `simple_module_products`). No deps are installed and no transitive `requires` are resolved; you drive `uv sync` and migrations yourself. Use this when you want to wire the build by hand rather than via `smpy new`'s wizard.
|
|
74
88
|
|
|
75
89
|
## `smpy create-module <name>` — module package
|
|
76
90
|
|
|
@@ -87,6 +101,25 @@ The result is a complete package: `pyproject.toml` with the entry point declared
|
|
|
87
101
|
|
|
88
102
|
For the post-scaffold steps (entry point, Inertia namespace, etc.) see **simple-module-creating**.
|
|
89
103
|
|
|
104
|
+
## `smpy package-update` — bump `simple_module_*` deps
|
|
105
|
+
|
|
106
|
+
Walks the project's `pyproject.toml` (plus every `[tool.uv.workspace]` member), finds each dependency whose distribution name matches `simple_module_*` / `simple-module-*`, queries PyPI for the latest non-yanked release, and rewrites the constraint to `name>=<latest>`. PyPI lookups run in parallel, so it's quick even across a multi-package workspace.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
smpy package-update # rewrite constraints in ./pyproject.toml (+ workspace members)
|
|
110
|
+
smpy package-update --dry-run # show "old → new" per file, write nothing
|
|
111
|
+
smpy package-update --include-pre # also consider pre-releases (a/b/rc/dev/post)
|
|
112
|
+
smpy package-update --path apps/web # point at another project root or a pyproject.toml
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Flag | Default | Meaning |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `--path <DIR\|FILE>` | cwd | Project root or an explicit `pyproject.toml` |
|
|
118
|
+
| `--dry-run` | off | Print planned changes only; don't write |
|
|
119
|
+
| `--include-pre` | off | Allow pre-release versions as the "latest" |
|
|
120
|
+
|
|
121
|
+
**Deps it deliberately skips:** anything whose `[tool.uv.sources]` entry points at a workspace member, a local `path`, a `git` ref, or a `url` — those don't come from PyPI, so their version lives elsewhere. Skipped deps are listed with a reason (`workspace/local source`, `not found on PyPI`). The command only edits constraints; **run `uv sync` afterward** to actually install the new versions (it reminds you to).
|
|
122
|
+
|
|
90
123
|
## `smpy skills` — install the bundled agent skills
|
|
91
124
|
|
|
92
125
|
`simple_module_cli` ships a set of [SKILL.md](https://agentskills.io/specification) packs (the ones in this directory). Drop them into any project so Claude Code / Cursor / Codex / etc. find them automatically.
|
|
@@ -124,6 +157,7 @@ Wheel-installed modules ship `package.json` declarations that need to land in th
|
|
|
124
157
|
```bash
|
|
125
158
|
smpy host sync-js-deps # uses ./client_app
|
|
126
159
|
smpy host sync-js-deps --host-client-app=apps/web/client_app
|
|
160
|
+
smpy host sync-js-deps --dry-run # print the npm install command without running it
|
|
127
161
|
```
|
|
128
162
|
|
|
129
163
|
Run after `pip install <module>` if the new module ships frontend code.
|
|
@@ -163,9 +197,11 @@ Don't bake `--password` literals into a script you commit; use a secrets store a
|
|
|
163
197
|
- **Forgot `smpy host sync-js-deps` after `pip install`-ing a module with pages.** Vite resolves module imports against `client_app/node_modules`; the new module's JS deps won't land until you sync.
|
|
164
198
|
- **Used `smpy create-module` to add a module to an existing host.** That command is for **publishable** packages, intended to live in their own repo. To add a module to an existing host: install it (`pip install simple_module_<name>` or add to `pyproject.toml` and `uv sync`), then autogenerate a migration. See **simple-module-creating** + **simple-module-migrations**.
|
|
165
199
|
- **Calling `smpy create-admin` before migrations have run.** The users tables don't exist yet; the command will error. Run `alembic upgrade head` first (or use `smpy new` which does it for you when `--no-install` isn't set).
|
|
200
|
+
- **Expecting `--with products` (or `keycloak` / `audit_log`) to work on `smpy new`.** Those modules exist in the monorepo but aren't scaffolder-catalog keys, so `smpy new --with products` errors. Either scaffold without them and `pip install simple_module_products` into the host afterward, or use `smpy create-host --with Products` (which doesn't validate against the catalog).
|
|
201
|
+
- **`smpy package-update` "did nothing" for a workspace dep.** Deps sourced from a `[tool.uv.sources]` workspace/path/git/URL entry are intentionally skipped (their version isn't on PyPI) — they show up in the output as `skipped`, not updated. Also remember to run `uv sync` after; `package-update` only rewrites the constraint strings.
|
|
166
202
|
|
|
167
203
|
## Related skills
|
|
168
204
|
|
|
169
205
|
- **simple-module-creating** — what `smpy create-module` produces and the post-scaffold contract
|
|
170
206
|
- **simple-module-inertia-pages** — what `smpy host gen-pages` regenerates and why
|
|
171
|
-
- **simple-module-migrations** — the `
|
|
207
|
+
- **simple-module-migrations** — the migration step `smpy new` runs (`make migration` + `make migrate`, or `alembic upgrade head` directly)
|
|
@@ -89,7 +89,7 @@ Locale-related diagnostics: SM013 (missing file), SM014 (missing keys vs default
|
|
|
89
89
|
|
|
90
90
|
### 9. Don't re-enable the silenced ty rules
|
|
91
91
|
|
|
92
|
-
Projects using the `ty` type checker should globally suppress `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`,
|
|
92
|
+
Projects using the `ty` type checker should globally suppress `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, `invalid-argument-type`, and `invalid-assignment` in `pyproject.toml`. SQLModel runtime-instruments fields as SQLAlchemy attributes, so the type checker can't see what's there (and `model_config = ConfigDict(...)` clashes with ty's internal `SQLModelConfig` type, which `invalid-assignment` covers). Real bugs surface in tests.
|
|
93
93
|
|
|
94
94
|
## Related skills
|
|
95
95
|
|
|
@@ -10,35 +10,49 @@ description: Use when adding a new feature package to a simple_module_python app
|
|
|
10
10
|
## Quick path
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
# scaffold a
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# install the new package into the host environment
|
|
18
|
-
uv sync # or: pip install -e ./orders
|
|
13
|
+
# scaffold a module into this repo's modules/orders/ — also wires the package
|
|
14
|
+
# into host/ + root pyproject.toml and runs `uv sync --all-packages`
|
|
15
|
+
make new-module name=orders
|
|
19
16
|
|
|
20
17
|
# if you added a frontend page, regenerate the Inertia manifest
|
|
21
|
-
|
|
18
|
+
make gen-pages
|
|
22
19
|
|
|
23
20
|
# if you added SQLModel tables, autogenerate + apply a migration
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
make migration msg="add orders module"
|
|
22
|
+
make migrate
|
|
26
23
|
```
|
|
27
24
|
|
|
25
|
+
`make new-module` (→ `scripts/new_module.py`) is the in-repo path and produces
|
|
26
|
+
the full tree below. For a **standalone publishable package** (its own repo,
|
|
27
|
+
with CI + PyPI `publish.yml`), use `smpy create-module orders` instead — a
|
|
28
|
+
leaner scaffold; pass `--standalone` to force the `.github/` workflows, then
|
|
29
|
+
`uv sync` (or `pip install -e ./orders`) to install it into the host.
|
|
30
|
+
|
|
28
31
|
## What scaffolding produces
|
|
29
32
|
|
|
30
33
|
```
|
|
31
|
-
orders/
|
|
34
|
+
modules/orders/
|
|
32
35
|
├── pyproject.toml # entry point declared here
|
|
36
|
+
├── package.json # per-module JS deps
|
|
37
|
+
├── tsconfig.json
|
|
38
|
+
├── tests/test_orders.py
|
|
33
39
|
└── orders/
|
|
34
40
|
├── module.py # OrdersModule(ModuleBase) with ModuleMeta
|
|
35
41
|
├── models.py # Base = create_module_base("orders")
|
|
36
|
-
├── contracts/
|
|
42
|
+
├── contracts/schemas.py # SQLModel DTOs — public surface
|
|
43
|
+
├── service.py # business logic
|
|
44
|
+
├── services.py # app.state.orders state container (settings)
|
|
45
|
+
├── settings.py # SM_ORDERS_* env → dataclass
|
|
46
|
+
├── deps.py # FastAPI dependencies
|
|
37
47
|
├── endpoints/{api,views}.py
|
|
38
|
-
├── pages/
|
|
48
|
+
├── pages/{Browse,Create,Edit}.tsx # *.tsx, auto-discovered by Vite
|
|
39
49
|
└── locales/en.json
|
|
40
50
|
```
|
|
41
51
|
|
|
52
|
+
`make new-module` also edits `host/pyproject.toml` (adds the workspace dep) and
|
|
53
|
+
the root `pyproject.toml` (type-check + test paths), so the module is picked up
|
|
54
|
+
by the host and by `make lint`/`make test` without further wiring.
|
|
55
|
+
|
|
42
56
|
## The contract: pyproject.toml + module.py
|
|
43
57
|
|
|
44
58
|
The host discovers modules via a single entry point. If this is wrong, the module silently does nothing in dev and **fails boot in production** (strict discovery).
|
|
@@ -56,7 +70,7 @@ orders = "orders.module:OrdersModule"
|
|
|
56
70
|
|
|
57
71
|
```python
|
|
58
72
|
# modules/orders/orders/module.py
|
|
59
|
-
from simple_module_core import ModuleBase, ModuleMeta
|
|
73
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
60
74
|
|
|
61
75
|
class OrdersModule(ModuleBase):
|
|
62
76
|
meta = ModuleMeta(
|
|
@@ -64,13 +78,12 @@ class OrdersModule(ModuleBase):
|
|
|
64
78
|
route_prefix="/api/orders",
|
|
65
79
|
view_prefix="/orders",
|
|
66
80
|
depends_on=[], # other module names (PascalCase)
|
|
67
|
-
version="0.1.0",
|
|
68
81
|
)
|
|
69
82
|
```
|
|
70
83
|
|
|
71
84
|
`ModuleMeta.name` is load-bearing in two places: the `__tablename__` prefix you author and the PascalCase Inertia component namespace. So directory `blog_posts` → `name="BlogPosts"` → `inertia.render("BlogPosts/Index", ...)` → `pages/Index.tsx`. Mismatches fire diagnostic codes `SM003` (orphan page) / `SM004` (phantom render).
|
|
72
85
|
|
|
73
|
-
|
|
86
|
+
`ModuleMeta` also accepts `version=` (defaults to `"1.0.0"`) and `requires_framework=` (a PEP 440 spec for the framework API range, e.g. `">=1.0,<2.0"`) so the host can reject incompatible installs at boot — set these on modules you intend to publish.
|
|
74
87
|
|
|
75
88
|
## Lifecycle hooks (override only what you need)
|
|
76
89
|
|
|
@@ -84,6 +97,7 @@ In execution order — all no-op by default:
|
|
|
84
97
|
| `register_feature_flags(registry)` | `FeatureFlagDefinition` constants |
|
|
85
98
|
| `register_event_handlers(bus)` | `bus.subscribe(EventCls, handler)` |
|
|
86
99
|
| `register_health_checks(registry)` | Module-owned health probes |
|
|
100
|
+
| `register_public_routes(registry)` | Exempt routes from auth via `add_prefix` / `add_regex` (method-aware) |
|
|
87
101
|
| `register_exception_handlers(app)` | Module-specific error mapping |
|
|
88
102
|
| `register_middleware(app)` | LIFO — module middleware sorted last wraps outermost |
|
|
89
103
|
| `register_routes(api_router, view_router)` | `include_router(...)` your two routers |
|
|
@@ -91,14 +105,14 @@ In execution order — all no-op by default:
|
|
|
91
105
|
|
|
92
106
|
## Verify after scaffolding
|
|
93
107
|
|
|
94
|
-
Boot the host.
|
|
108
|
+
Boot the host. The diagnostics pass runs **only in development** — warnings/errors print to the boot logs and any ERROR raises `SystemExit`. In **production** the diagnostics pass is skipped; the one thing that fails a production boot is strict module *discovery* (a bad entry point — broken import, missing `meta`, non-`ModuleBase` class), surfaced as `SM001`. So `SM008`/`SM009` are dev-boot / `make doctor` errors, not production-boot blockers; `SM007` (module overrides no hooks) is info-only.
|
|
95
109
|
|
|
96
|
-
The new module should appear in the registered-modules log line. If it has a view endpoint, visit `<view_prefix>/`.
|
|
110
|
+
The new module should appear in the registered-modules log line. If it has a view endpoint, visit `<view_prefix>/`. Run `make doctor` for the same diagnostics out of band (orphan pages, coupling, migration drift, locale checks) — see the **simple-module-doctor** skill for the full code list.
|
|
97
111
|
|
|
98
112
|
## Pitfalls
|
|
99
113
|
|
|
100
114
|
- **Forgot the entry point.** Package installs, module silently doesn't load (production strict mode raises `InvalidModuleError`). Verify `[project.entry-points.simple_module]` exists in `pyproject.toml`.
|
|
101
|
-
- **`name=` collides.** Two modules with the same `ModuleMeta.name` raise `SM008` at boot.
|
|
115
|
+
- **`name=` collides.** Two modules with the same `ModuleMeta.name` raise `SM008` at dev boot / `make doctor` (their lowercased names would also collide as table prefixes in the shared schema).
|
|
102
116
|
- **Registered the module by hand in host code.** Don't — discovery is entry-point-only; host code never imports module code.
|
|
103
117
|
|
|
104
118
|
For framework-wide rules that apply once the module exists (SQLModel-everywhere, file-size cap, settings layout, no `session.commit()` in services), see the **simple-module-conventions** skill. For migration mechanics see **simple-module-migrations**.
|
|
@@ -31,9 +31,9 @@ All modules — on Postgres and SQLite alike — share the host's single schema.
|
|
|
31
31
|
|
|
32
32
|
| Mixin | Adds | Behavior |
|
|
33
33
|
|---|---|---|
|
|
34
|
-
| `AuditMixin` | `created_at`, `updated_at`, `created_by`, `updated_by` |
|
|
34
|
+
| `AuditMixin` | `created_at`, `updated_at`, `created_by`, `updated_by` | `created_at` defaults Python-side (and via `server_default`); `updated_at`/`created_by`/`updated_by` set by the `before_flush` listener from `current_user_id` + timestamp. |
|
|
35
35
|
| `SoftDeleteMixin` | `is_deleted`, `deleted_at`, `deleted_by` | `session.delete(obj)` → soft-delete; `SELECT` auto-filters deleted rows. |
|
|
36
|
-
| `MultiTenantMixin` | `tenant_id` | Auto-populated on insert; `SELECT` auto-scoped when `current_tenant_id` is set. |
|
|
36
|
+
| `MultiTenantMixin` | `tenant_id` | Auto-populated on insert from `current_tenant_id`. Column is **non-nullable**, so inserting outside any tenant context fails at the DB; creating/mutating a row for a different tenant raises `TenantIsolationError`. `SELECT` auto-scoped when `current_tenant_id` is set. |
|
|
37
37
|
| `VersionedMixin` | `version: int` | Auto-incremented on update. Use for optimistic concurrency. |
|
|
38
38
|
|
|
39
39
|
List `Base` first; Python MRO handles mixin ordering after that:
|
|
@@ -77,7 +77,7 @@ async def create_order(session: AsyncSession, payload: OrderCreate) -> Order:
|
|
|
77
77
|
|
|
78
78
|
## Pitfalls
|
|
79
79
|
|
|
80
|
-
- **Setting `tenant_id` manually.** `MultiTenantMixin` populates it on insert from request state. Setting it
|
|
80
|
+
- **Setting `tenant_id` manually.** `MultiTenantMixin` populates it on insert from request state (`current_tenant_id`). Setting it to a *different* tenant than the active context raises `TenantIsolationError`; changing it on an existing row raises too.
|
|
81
81
|
- **Querying `WHERE is_deleted = false` by hand.** The listener already does it. Use `execution_options(include_deleted=True)` only when you genuinely want deleted rows.
|
|
82
82
|
|
|
83
83
|
## Related skills
|
|
@@ -5,22 +5,24 @@ description: Use when interpreting a simple_module_python diagnostic code (SM001
|
|
|
5
5
|
|
|
6
6
|
# simple_module_python: diagnostics
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
`make doctor` runs `python -m simple_module_core`: it discovers modules **strictly**, runs the diagnostics, prints findings to stderr (`✓ No module diagnostics issues found` when clean), and exits `1` if any ERROR-level finding was reported.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
The same diagnostics also run at host boot, but **only in development** (`SM_ENVIRONMENT == development`) — they print to the boot logs and `raise SystemExit` if there's any ERROR. **In production the diagnostics pass is skipped**; what protects prod is that module *discovery* runs strictly (any bad entry point — broken import, missing `meta`, non-`ModuleBase` class — raises and fails the boot). So "errors fail prod boot" is true for discovery-level breakage (surfaced as **SM001**), not for the per-finding checks below, which you'll catch via `make doctor` / dev boot before you ever deploy.
|
|
11
|
+
|
|
12
|
+
You can also call `run_diagnostics(...)` from `simple_module_core` programmatically (e.g. in a CI job) to surface issues without booting the full app — pass `migration_state=` / `module_tables=` / `migrated_tables=` to additionally get **SM010/SM011** (the migration checks below), and `i18n_supported_locales=` / `i18n_default_locale=` to get the locale checks.
|
|
11
13
|
|
|
12
14
|
## Code reference
|
|
13
15
|
|
|
14
16
|
| Code | Level | What it means | Fix |
|
|
15
17
|
|---|---|---|---|
|
|
16
|
-
| **SM001** | ERROR | Module class is missing `meta = ModuleMeta(...)` or
|
|
18
|
+
| **SM001** | ERROR | Module class is missing its `meta = ModuleMeta(...)` class attribute, or (under strict discovery) the entry point failed to load — broken import, missing `meta`, or a class that isn't a `ModuleBase` subclass | Add a valid `meta` class attribute on the `ModuleBase` subclass / fix the entry point. This is the one code that fails *production* boot (via strict discovery) |
|
|
17
19
|
| **SM003** | WARNING | A `pages/<Name>.tsx` exists but no `inertia.render("<Module>/<Name>", ...)` references it | Either delete the orphan file or wire up the render call |
|
|
18
20
|
| **SM004** | WARNING | `inertia.render("<Module>/<Name>", ...)` is called but no matching `.tsx` exists | Fix the render-key typo or create the page file |
|
|
19
21
|
| **SM007** | INFO | Module overrides no `register_*` hooks at all — likely scaffolded but empty | Either implement at least one hook or delete the module if unused |
|
|
20
|
-
| **SM008** | ERROR | Two modules declare the same `ModuleMeta.name
|
|
22
|
+
| **SM008** | ERROR | Two modules declare the same `ModuleMeta.name`, or two names collide once lowercased as table prefixes in the shared schema (one finding per collision, from either the duplicate-name or the schema-conflict check) | Rename one module's `meta.name` |
|
|
21
23
|
| **SM009** | ERROR | A `framework/*` package directly imports from a plugin module (`modules/*`) | Move the symbol *up* into `simple_module_core` / `simple_module_hosting`, or invert the dependency via the event bus / a registry |
|
|
22
|
-
| **SM010** | ERROR | Live DB revision is behind the migration head | Run `
|
|
23
|
-
| **SM011** | WARNING | A module declares a SQLModel table that has no entry in migration history | Run `
|
|
24
|
+
| **SM010** | ERROR | Live DB revision is behind the migration head | Run `make migrate` before booting; in CI, ensure migrations are part of the deploy step. *Note:* only emitted when a caller passes `migration_state=` to `run_diagnostics` — `make doctor` and dev boot do **not** check this. The live boot enforces it separately: `check_migrations()` raises a plain `RuntimeError` (not an SM code) in dev and prod if the DB is behind head |
|
|
25
|
+
| **SM011** | WARNING | A module declares a SQLModel table that has no entry in migration history | Run `make migration msg="..."` and apply. *Note:* only emitted when `module_tables=`/`migrated_tables=` are passed to `run_diagnostics`; not surfaced by `make doctor` or boot |
|
|
24
26
|
| **SM012** | WARNING (dev only) | `register_settings` is overridden but nothing was added to `app.state.<module_lower>` | Either store the module's settings dataclass on `app.state.<module_lower>` or remove the empty override |
|
|
25
27
|
| **SM013** | WARNING | A locale file declared in `locale_dirs()` is missing for one of the supported locales | Create the file (even if empty) or trim `SM_I18N_SUPPORTED_LOCALES` |
|
|
26
28
|
| **SM014** | WARNING | A non-default locale is missing keys present in the default locale | Add the missing keys, or accept that a fallback to default applies |
|
|
@@ -32,13 +34,16 @@ You can also call `run_diagnostics(...)` from `simple_module_core` programmatica
|
|
|
32
34
|
| **SM020** | ERROR | More than one auth provider module is installed | Install exactly one auth provider (e.g. `users` OR `keycloak`, not both) |
|
|
33
35
|
| **SM021** | WARNING | No auth provider module is installed | Install an auth provider module (e.g. `simple-module-users` or `simple-module-keycloak`) |
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
`SM002`, `SM005`, `SM006` don't exist in the implementation — they're reserved/retired and never emitted. `SM012` is the one code that doesn't come from the core diagnostics runner: it's raised from the hosting layer (`check_settings_registration`, after Phase 4 settings registration) and so is only ever seen at **dev boot**, not via `make doctor`.
|
|
38
|
+
|
|
39
|
+
Each finding prints across one to three lines, formatted as `<glyph> <CODE> [<LEVEL>] <module_name>: <message>`, with the glyph `✗` (error) / `⚠` (warning) / `ℹ` (info) — e.g. `✗ SM009 [ERROR] users: Framework package '...' directly imports from module package '...'`. A `↳ <file>` line and a `↳ Suggestion: <text>` line follow when present. The run ends with `Results: N error(s), N warning(s), N info`.
|
|
36
40
|
|
|
37
|
-
Warnings are load-bearing: the framework only emits one when something concrete *will* break under a specific condition (locale switch, schema downgrade, deploy ordering).
|
|
41
|
+
Warnings are load-bearing: the framework only emits one when something concrete *will* break under a specific condition (locale switch, schema downgrade, deploy ordering). They don't fail the prod boot — that's exactly why they're easy to ignore until the condition hits in production and becomes the next on-call page. If you're suppressing warnings in CI to make it green, you're trading the CI signal for that later runtime break.
|
|
38
42
|
|
|
39
43
|
## Related skills
|
|
40
44
|
|
|
41
|
-
- **simple-module-creating** — fixing SM001/SM007/SM008
|
|
45
|
+
- **simple-module-creating** — fixing SM001/SM007/SM008/SM017/SM019/SM020/SM021
|
|
42
46
|
- **simple-module-database** + **simple-module-migrations** — fixing SM010/SM011
|
|
43
47
|
- **simple-module-inertia-pages** — fixing SM003/SM004/SM018
|
|
44
|
-
- **simple-module-
|
|
48
|
+
- **simple-module-locales** — fixing SM013/SM014/SM015/SM016
|
|
49
|
+
- **simple-module-conventions** — fixing SM009/SM012
|
|
@@ -15,7 +15,7 @@ description: Use when adding or debugging an Inertia.js page in a simple_module_
|
|
|
15
15
|
| Module page (snake-case dir) | `inertia.render("BlogPosts/Index", ...)` | `<module_pkg>/blog_posts/pages/Index.tsx` |
|
|
16
16
|
| Host page | `inertia.render("Landing", ...)` | `<host>/client_app/pages/Landing.tsx` |
|
|
17
17
|
|
|
18
|
-
The namespace is
|
|
18
|
+
The namespace is **`ModuleMeta.name`** — by default the PascalCase of the module's directory name (the scaffolder runs `to_pascal_case`), not the file system path. Directory `blog_posts` → `BlogPosts`. The manifest generator keys each module's glob by `meta.name` (`compute_module_pages` in `simple_module_hosting/manifest.py`), and `smpy host gen-pages` wires up Vite's `import.meta.glob` so the resolver (`host/client_app/pages.ts`) maps `"<meta.name>/<PageName>"` → the page module. A `.tsx` under a subdirectory of `pages/` is keyed by its relative path (`pages/admin/Edit.tsx` → `"<meta.name>/admin/Edit"`).
|
|
19
19
|
|
|
20
20
|
After adding or renaming a `.tsx`, regenerate the manifest:
|
|
21
21
|
|
|
@@ -31,21 +31,21 @@ Boot regenerates it too; mid-session adds need the manual call before HMR sees t
|
|
|
31
31
|
|
|
32
32
|
| Prop | Shape | Populated from |
|
|
33
33
|
|---|---|---|
|
|
34
|
-
| `auth.user` | object or `null` | A `principal_serializer` callable on `app.state` (registered by the auth
|
|
34
|
+
| `auth.user` | object or `null` | A `principal_serializer` callable on `app.state` (registered by the `auth` module's `register_settings`) |
|
|
35
35
|
| `auth.isAuthenticated` | bool | `request.state.user` presence |
|
|
36
36
|
| `auth.permissions` | string[] | Roles → permissions expansion via the framework's permission registry |
|
|
37
|
-
| `menus` | `{ sidebar, adminSidebar, navbar, userDropdown }` | All modules' `register_menu_items()` output, role-filtered |
|
|
38
|
-
| `i18n` | `{ locale,
|
|
37
|
+
| `menus` | `{ sidebar, adminSidebar, navbar, userDropdown }` | All modules' `register_menu_items()` output, auth/role-filtered |
|
|
38
|
+
| `i18n` | `{ locale, supportedLocales, messages }` | Active locale (`request.state.locale`) + flattened message bundle for that locale (`messages` is `null` on Inertia XHR partials when the locale is unchanged) |
|
|
39
39
|
|
|
40
|
-
**`auth.user` is `None` when no `principal_serializer` is registered**, even if the user is authenticated. The framework can't know the shape of your user object. The auth
|
|
40
|
+
**`auth.user` is `None` when no `principal_serializer` is registered**, even if the user is authenticated. The framework can't know the shape of your user object. The `auth` module registers the callable during `register_settings(app)` (`modules/auth/auth/module.py`):
|
|
41
41
|
|
|
42
42
|
```python
|
|
43
|
-
def
|
|
44
|
-
return {"id": user.id, "email": user.email, "
|
|
43
|
+
def _serialize_principal(user: UserContext) -> dict:
|
|
44
|
+
return {"id": user.id, "name": user.name, "email": user.email, "roles": user.roles}
|
|
45
45
|
|
|
46
|
-
class
|
|
46
|
+
class AuthModule(ModuleBase):
|
|
47
47
|
def register_settings(self, app: FastAPI) -> None:
|
|
48
|
-
app.state.principal_serializer =
|
|
48
|
+
app.state.principal_serializer = _serialize_principal
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
## Endpoint pattern
|
|
@@ -70,7 +70,10 @@ The `view_router` (mounted at `view_prefix`) is for HTML/Inertia. The `api_route
|
|
|
70
70
|
|
|
71
71
|
Inertia's `router.post(...)` / `.patch(...)` / `.put(...)` / `.delete(...)` expects an Inertia response back, not JSON. If a page calls `router.post("/api/orders", ...)`, Inertia rejects the JSON response and the form silently fails.
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
Two fixes, in preference order:
|
|
74
|
+
|
|
75
|
+
1. **Stay Inertia-native** — point the call at a *view* endpoint (under `view_prefix`, e.g. `/orders/...`) that returns `RedirectResponse(..., status_code=303)`. Inertia follows the redirect and re-renders the page.
|
|
76
|
+
2. **Need the JSON payload back** — use plain `fetch()` against the `/api/*` endpoint instead of Inertia's `router`:
|
|
74
77
|
|
|
75
78
|
```tsx
|
|
76
79
|
await fetch("/api/orders", {
|
|
@@ -86,8 +89,9 @@ await fetch("/api/orders", {
|
|
|
86
89
|
|
|
87
90
|
- **Translated strings in props built at module scope.** `const labels = { title: t("orders.title") }` freezes against the first render's locale — build per-request translations inside the handler or via shared props.
|
|
88
91
|
- **Imported a page TSX from another page.** Manifest-wired pages break if you import them directly; production builds drop the side-effect imports.
|
|
92
|
+
- **Pages exist but are unreachable in the UI (`SM019`).** A module with a non-empty `view_prefix` that overrides `register_routes` but neither `register_menu_items` nor `register_permissions` ships pages with no sidebar entry and no role-editor visibility. Add a menu item, register permissions, or clear `view_prefix` if it's API-only.
|
|
89
93
|
|
|
90
94
|
## Related skills
|
|
91
95
|
|
|
92
96
|
- **simple-module-creating** — where the PascalCase rule originates (matching `ModuleMeta.name`)
|
|
93
|
-
- **simple-module-doctor** — full reference for `SM003` / `SM004` / `SM018`
|
|
97
|
+
- **simple-module-doctor** — full reference for `SM003` / `SM004` / `SM018` / `SM019`
|
|
@@ -96,7 +96,16 @@ export function useOrderSchema() {
|
|
|
96
96
|
|
|
97
97
|
## Backend usage
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
Inject `TranslatorDep` (from `simple_module_hosting.i18n_deps`) into an endpoint to get a `Translator` bound to `request.state.locale` (set by `LocaleMiddleware`). Call `t.t(key, **params)` — same `{name}` placeholders, and pass `count=` for CLDR pluralization:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from simple_module_hosting.i18n_deps import TranslatorDep
|
|
103
|
+
|
|
104
|
+
async def create(t: TranslatorDep):
|
|
105
|
+
raise HTTPException(404, t.t("orders.errors.not_found", id=order_id))
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Resolution falls back to the default locale, then to the bare key. Avoid hard-coding English in error responses that surface to users — pull the message through the locale system so other languages get it for free.
|
|
100
109
|
|
|
101
110
|
## Diagnostic codes
|
|
102
111
|
|
|
@@ -107,7 +116,7 @@ Server-side, translated strings come from the same bundle via `request.state.loc
|
|
|
107
116
|
| **SM015** | WARNING | A non-default locale has keys **not** in the default | Either remove the dead keys or add them to the default file |
|
|
108
117
|
| **SM016** | ERROR | A locale JSON file is invalid or has non-string leaves | Fix the JSON. Only string leaves are allowed — interpolation is `{placeholder}` strings, not nested objects |
|
|
109
118
|
|
|
110
|
-
SM016 is
|
|
119
|
+
SM016 is an ERROR that fails **dev boot** and `make doctor` (exit 1) — in production the diagnostics pass is skipped, so a malformed locale won't crash a prod boot; catch it before deploy via `make doctor` / dev boot. SM013–SM015 are warnings, but they're the kind that turn into "Spanish users see English in production" if ignored.
|
|
111
120
|
|
|
112
121
|
## Pitfalls
|
|
113
122
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: simple-module-migrations
|
|
3
|
+
description: Use when generating, applying, or reviewing Alembic migrations in a simple_module_python host project, especially after installing or upgrading a module package. Triggers on "alembic revision", "autogenerate", "branch labels", "downgrade", "SM010", "SM011", or "migration drift".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# simple_module_python: migrations
|
|
7
|
+
|
|
8
|
+
## The cardinal rule
|
|
9
|
+
|
|
10
|
+
**Migrations live in the host, never in the module package.** A module ships SQLModel tables only. The host developer generates one migration each time a module is installed or its tables change.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
host/ # the host project (in the repo root)
|
|
14
|
+
├── alembic.ini # script_location = %(here)s/migrations
|
|
15
|
+
├── migrations/
|
|
16
|
+
│ ├── env.py # framework template
|
|
17
|
+
│ └── versions/ # ALL revisions live here, regardless of owning module
|
|
18
|
+
│ ├── 77162e7b184b_initial_schema.py # bundles the bootstrap modules
|
|
19
|
+
│ ├── 70786227af4c_add_audit_log_tables.py # branch_labels=("audit_log",)
|
|
20
|
+
│ └── 168a2882f443_keycloak_initial_schema.py # branch_labels=("keycloak",)
|
|
21
|
+
└── pyproject.toml # depends on each installed module
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Alembic always runs from the repo root with `-c host/alembic.ini`, so it shares
|
|
25
|
+
the app's `.env` / `SM_DATABASE_URL`. `host/migrations/env.py` resolves the URL
|
|
26
|
+
from `Settings()` (converting the async driver to a sync one for migrations).
|
|
27
|
+
|
|
28
|
+
## How autogenerate sees every module
|
|
29
|
+
|
|
30
|
+
The host's `host/migrations/env.py` (scaffolded by `smpy create-host`) calls:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from simple_module_db import (
|
|
34
|
+
build_module_metadata,
|
|
35
|
+
make_include_object,
|
|
36
|
+
make_process_revision_directives,
|
|
37
|
+
render_item,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
target_metadata = build_module_metadata() # imports every installed module's <pkg>.models
|
|
41
|
+
include_object = make_include_object(target_metadata)
|
|
42
|
+
process_revision_directives = make_process_revision_directives(target_metadata)
|
|
43
|
+
# context.configure(..., render_item=render_item)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`build_module_metadata()` walks the `simple_module` entry-point group, imports each module's `models` submodule, and merges every module's per-module `MetaData` into one unified `MetaData`. `make_include_object(metadata)` allowlists only tables owned by installed modules — any host-owned tables (and `alembic_version`) outside the module system are preserved untouched. So one autogenerate call covers every installed module in a single pass; editable installs and wheels behave identically.
|
|
47
|
+
|
|
48
|
+
Two extras matter for correctness: `render_item` collapses SQLModel's `AutoString` to `sa.String` and renders `StrEnum` columns so the generated file imports cleanly, and `make_process_revision_directives` re-emits expression-based (functional) indexes — e.g. `lower(email)` — that autogenerate silently drops under SQLite.
|
|
49
|
+
|
|
50
|
+
All module tables live in the **host's single shared schema** on both Postgres and SQLite (each module still owns its own `MetaData` purely so autogenerate can attribute a table to its module). There is no schema-per-module; `__tablename__` is prefixed with the module name (`orders_order`) to avoid collisions, and a cross-module FK is an ordinary same-schema reference.
|
|
51
|
+
|
|
52
|
+
## Adding a new module
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# 1. Add the module to the host environment
|
|
56
|
+
# (scaffold a new one: `make new-module name=orders` / `smpy create-module`,
|
|
57
|
+
# or install a packaged one and `uv sync --all-packages`)
|
|
58
|
+
|
|
59
|
+
# 2. Generate the migration (runs alembic from the repo root, autogenerate)
|
|
60
|
+
make migration msg="add orders module"
|
|
61
|
+
|
|
62
|
+
# 3. Review the generated file (see "Branch labels" below)
|
|
63
|
+
# 4. Apply
|
|
64
|
+
make migrate
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`make migrate` runs `alembic -c host/alembic.ini upgrade heads` — note **`heads`** (plural). Branch labels create multiple independent heads, so `upgrade head` (singular) would error or under-apply.
|
|
68
|
+
|
|
69
|
+
### Branch labels — set on the FIRST revision per module
|
|
70
|
+
|
|
71
|
+
Each new module's first revision should set a `branch_labels` tuple matching the module name, lowercased. Autogenerate does **not** add this — you edit the file by hand before applying:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# host/migrations/versions/70786227af4c_add_audit_log_tables.py
|
|
75
|
+
revision = "70786227af4c"
|
|
76
|
+
down_revision = "41cf2c53660e"
|
|
77
|
+
branch_labels = ("audit_log",) # ← add by hand on this first revision
|
|
78
|
+
depends_on = None
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Why: it lets operators roll back **just one module's** schema, which is how a module is uninstalled cleanly:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
make downgrade # back one revision; OR, to a target:
|
|
85
|
+
uv run --project host alembic -c host/alembic.ini downgrade audit_log@base
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`audit_log@base` drops everything that module owns and leaves the others. Without the label, downgrading to a bare revision id walks linear history and rolls back unrelated modules' migrations sitting between.
|
|
89
|
+
|
|
90
|
+
Subsequent revisions for the same module **don't** need a `branch_labels` — only the first; they inherit the branch.
|
|
91
|
+
|
|
92
|
+
> Exception: the repo's bootstrap `initial_schema` revision bundles several core modules (users, permissions, settings, file_storage, feature_flags, background_tasks) into one unlabeled root migration. The per-module `branch_labels` convention applies to modules added **after** that bootstrap.
|
|
93
|
+
|
|
94
|
+
## Updating a module's schema
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# Change models.py in the module (or upgrade a packaged module + uv sync)
|
|
98
|
+
make migration msg="orders: add total column"
|
|
99
|
+
make migrate
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
No branch label on subsequent revisions. The diff covers every installed module — review the generated file to confirm only the intended module's tables changed.
|
|
103
|
+
|
|
104
|
+
## Diagnostics
|
|
105
|
+
|
|
106
|
+
| Code | When |
|
|
107
|
+
|---|---|
|
|
108
|
+
| **SM010** (error) | DB revision is behind migration head |
|
|
109
|
+
| **SM011** (warning) | Module declares a table that has no entry in migration history — usually means you added a model and forgot to autogenerate |
|
|
110
|
+
|
|
111
|
+
SM010/SM011 are **not** emitted at boot or by `make doctor`. They only surface when a caller passes `migration_state=` / `module_tables=` / `migrated_tables=` to `run_diagnostics(...)` — e.g. a CI job asserting migration health. The boot-time guard is separate: `check_migrations()` (in the lifespan) raises a plain `RuntimeError` — not an SM code — in **both dev and prod** if the DB is behind head. Operationally the rule is unchanged: deploy the migration before the code that depends on it.
|
|
112
|
+
|
|
113
|
+
## Pitfalls
|
|
114
|
+
|
|
115
|
+
- **Skipped the branch label on the first revision.** `alembic downgrade <module>@base` then fails or silently rolls back unrelated revisions. Fix by editing the migration file before applying; if already deployed, write a no-op revision that adds the label retroactively.
|
|
116
|
+
- **Renamed a column.** Autogenerate emits `drop_column` + `add_column`, which loses data. Edit to `op.alter_column(..., new_column_name=...)` and write the matching `downgrade()`.
|
|
117
|
+
- **Hand-edited operations with stubbed `downgrade()`.** Server defaults, CHECK constraints, expression indexes — Alembic can't always infer these. When you fill in `upgrade()`, fill in `downgrade()` too.
|
|
118
|
+
- **Concurrent `make migrate` (`upgrade heads`) from multiple processes.** The `alembic_version` table isn't race-safe across all backends. Run upgrades from one place (a release pipeline step), not from the booting app.
|
|
119
|
+
- **Functional index missing under SQLite.** Expression indexes like `lower(email)` are re-emitted by `make_process_revision_directives`, but if your env.py wiring drops it the index silently vanishes on SQLite — verify the generated file actually contains the `op.create_index(...)` for it.
|
|
120
|
+
|
|
121
|
+
## Related skills
|
|
122
|
+
|
|
123
|
+
- **simple-module-database** — defining tables that autogenerate will pick up
|
|
124
|
+
- **simple-module-doctor** — interpreting SM010/SM011 and the other diagnostic codes
|
|
@@ -5,7 +5,9 @@ description: Use when a module needs to contribute menu items, permissions, feat
|
|
|
5
5
|
|
|
6
6
|
# simple_module_python: cross-module registries
|
|
7
7
|
|
|
8
|
-
Four registries are populated during boot from each module's `register_*` hook. They turn the modular monolith into something more than a bag of routers: navigation aggregates, permission checks expand consistently, features can be toggled per tenant, and modules emit/consume events without importing each other.
|
|
8
|
+
Four cross-cutting registries are populated during boot from each module's `register_*` hook. They turn the modular monolith into something more than a bag of routers: navigation aggregates, permission checks expand consistently, features can be toggled per tenant, and modules emit/consume events without importing each other.
|
|
9
|
+
|
|
10
|
+
All four are populated in **Phase 5** of `app_builder.build_app`, in this per-module order: `register_menu_items` → `register_permissions` → `register_feature_flags` → `register_event_handlers` → `register_health_checks` → `register_public_routes`. (The last two — health checks and anonymous-route exemptions — are also registries but out of scope here; see **simple-module-creating**.) Each is constructed once in Phase 3 and stashed on `app.state.sm` (as `menu_registry`, `permissions`, `feature_flags`, `event_bus`, …) only **after** all module hooks have run.
|
|
9
11
|
|
|
10
12
|
## Menu — `register_menu_items(registry: MenuRegistry)`
|
|
11
13
|
|
|
@@ -26,7 +28,7 @@ class OrdersModule(ModuleBase):
|
|
|
26
28
|
)
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
**Sections:** `SIDEBAR`, `ADMIN_SIDEBAR`, `NAVBAR`, `USER_DROPDOWN`. The `menus` shared prop on every Inertia response contains all four — the React layout chooses which to render where. `order` controls intra-section sorting (lower = first). `method="post"` is for items that need to be a form submission (logout) rather than a link.
|
|
31
|
+
**Sections:** `SIDEBAR`, `ADMIN_SIDEBAR`, `NAVBAR`, `USER_DROPDOWN`. The `menus` shared prop on every Inertia response contains all four — the React layout chooses which to render where. `order` controls intra-section sorting (lower = first). `method="post"` is for items that need to be a form submission (logout) rather than a link. `group="Administration"` clusters items under a sub-header within a section; ungrouped (`""`) items render flat. `requires_auth=True` (default) hides the item from anonymous users.
|
|
30
32
|
|
|
31
33
|
## Permissions — `register_permissions(registry: PermissionRegistry)`
|
|
32
34
|
|
|
@@ -45,7 +47,7 @@ class OrdersModule(ModuleBase):
|
|
|
45
47
|
|
|
46
48
|
**Convention:** permission names are `<module>.<action>` (lowercase, dot-separated). Group name is human-readable — it surfaces in the admin UI as a section header. The built-in `admin` role gets the wildcard `"*"` and skips per-permission checks.
|
|
47
49
|
|
|
48
|
-
The
|
|
50
|
+
`register_permissions` runs once at boot to declare the static catalog. The registry caches its computed views (`all_permissions`, `role_map`) and **invalidates them on every mutation** (`add_group`, `add`, `map_role`), so it is safe to mutate after boot — the Permissions module does exactly that, re-applying persisted role→permission rows into `registry.map_role` at startup and whenever an admin edits a role. Don't roll your own caching of these views; read them fresh.
|
|
49
51
|
|
|
50
52
|
To check inside an endpoint, depend on `RequiresPermission("<module>.<action>")` (from `simple_module_hosting.permissions`), not by reading the registry by hand. The dependency handles wildcard expansion and the 401-vs-403 distinction.
|
|
51
53
|
|
|
@@ -70,23 +72,29 @@ class OrdersModule(ModuleBase):
|
|
|
70
72
|
```python
|
|
71
73
|
from simple_module_core.feature_flags import is_flag_enabled, require_flag, feature_flag
|
|
72
74
|
|
|
75
|
+
# Gate a whole endpoint (preferred — standard FastAPI, composes with dependencies=[]):
|
|
76
|
+
@router.post("/import", dependencies=[Depends(require_flag("orders.bulk_import"))])
|
|
77
|
+
async def bulk_import(...): ...
|
|
78
|
+
|
|
79
|
+
# Branch inside a handler:
|
|
73
80
|
@router.post("/import")
|
|
74
81
|
async def bulk_import(request: Request):
|
|
75
82
|
if not is_flag_enabled(request, "orders.bulk_import"):
|
|
76
83
|
raise HTTPException(404)
|
|
77
84
|
...
|
|
78
85
|
|
|
79
|
-
#
|
|
86
|
+
# Decorator alternative (legacy — kept for existing sites; new code should
|
|
87
|
+
# prefer Depends(require_flag(...))). Requires a `request: Request` param.
|
|
80
88
|
@router.post("/import")
|
|
81
89
|
@feature_flag("orders.bulk_import")
|
|
82
|
-
async def bulk_import(...): ...
|
|
90
|
+
async def bulk_import(request: Request, ...): ...
|
|
83
91
|
```
|
|
84
92
|
|
|
85
|
-
|
|
93
|
+
Every helper accepts either the raw flag name or the `FeatureFlagDefinition` you registered, and reads `request.app.state.sm.feature_flags` + `request.state.tenant_id`, so the per-tenant override Just Works. `flag_enabled(flag)` is a dep factory yielding a `bool` when you need the value rather than a 404 gate.
|
|
86
94
|
|
|
87
|
-
## Events — `register_event_handlers(bus: EventBus)`
|
|
95
|
+
## Events — `register_event_handlers(bus: EventBus, app: FastAPI | None = None)`
|
|
88
96
|
|
|
89
|
-
The event bus is async and in-process. Modules emit + consume domain events without importing each other.
|
|
97
|
+
The event bus is async and in-process (backed by `pyee`'s `AsyncIOEventEmitter`). Modules emit + consume domain events without importing each other. The `app` parameter is optional and back-compat: override `register_event_handlers(self, bus, app=None)` when a handler needs `app.state.sm.db.session_factory` to persist on the framework engine (or `app.state.<module>` state) — the host passes `app` when your signature accepts it, otherwise calls the two-arg form.
|
|
90
98
|
|
|
91
99
|
```python
|
|
92
100
|
# orders/contracts/events.py
|
|
@@ -106,7 +114,7 @@ from simple_module_core.events import EventBus
|
|
|
106
114
|
from orders.contracts.events import OrderPlaced
|
|
107
115
|
|
|
108
116
|
class NotificationsModule(ModuleBase):
|
|
109
|
-
def register_event_handlers(self, bus: EventBus) -> None:
|
|
117
|
+
def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
|
|
110
118
|
bus.subscribe(OrderPlaced, self._send_receipt)
|
|
111
119
|
|
|
112
120
|
async def _send_receipt(self, event: OrderPlaced) -> None:
|
|
@@ -121,7 +129,7 @@ async def place_order(self, ...):
|
|
|
121
129
|
return order
|
|
122
130
|
```
|
|
123
131
|
|
|
124
|
-
**`publish`** awaits every handler concurrently via `asyncio.gather` and isolates handler failures (logged
|
|
132
|
+
**`publish`** awaits every handler concurrently via `asyncio.gather` and isolates handler failures (logged via `return_exceptions`, never propagated to the publisher). **`publish_nowait`** uses pyee's `emit` to schedule handlers as tasks on the running loop and returns immediately without awaiting them — use when the publisher must not block on handler latency. Both isolate handler errors; the difference is only whether the publisher waits.
|
|
125
133
|
|
|
126
134
|
The event bus has no persistence and no retry. If the host crashes between `publish` and the handler completing, the event is lost. For durable workflows use `background_tasks` (Celery) instead.
|
|
127
135
|
|
|
@@ -131,10 +139,10 @@ Module A consuming Module B's events should import only from `b.contracts.events
|
|
|
131
139
|
|
|
132
140
|
## Pitfalls
|
|
133
141
|
|
|
134
|
-
- **
|
|
142
|
+
- **Added menu items or permission *groups* after boot.** The menu/permission/flag catalogs are meant to be declared in the `register_*` hooks. (The registries do invalidate their caches on mutation, so a late `add` won't serve stale data — but per-module hooks only run once at boot, so a catalog entry added later belongs to no module and won't survive a restart.) The one sanctioned runtime mutation is `PermissionRegistry.map_role`, which the Permissions module re-applies from the DB when an admin edits a role.
|
|
135
143
|
- **Raw permission strings in endpoints (`request.state.user.permissions`).** Use `RequiresPermission(...)`. The dependency handles wildcard expansion and the 401-vs-403 distinction.
|
|
136
144
|
- **Forgot a feature flag's `default_enabled=False`.** A flag added with `default_enabled=True` is on for every tenant on first deploy — defeats the point of gating a rollout. Default to `False`; flip via override after the rollout window.
|
|
137
|
-
- **
|
|
145
|
+
- **Tried to subscribe to an event in `register_settings` instead of `register_event_handlers`.** `register_settings` receives only `app`, and `app.state.sm` (which holds `event_bus`) isn't assigned until **after** every registration hook runs — so the bus is unreachable there. Subscribe in `register_event_handlers`, which is handed the bus directly.
|
|
138
146
|
- **Used `publish_nowait` inside a request handler that needs the listener to commit a DB row in the same transaction.** It returns immediately — the handler runs after the request has already committed/rolled back. For "in this request, do X then Y", just call Y directly.
|
|
139
147
|
|
|
140
148
|
## Related skills
|
|
@@ -7,10 +7,11 @@ description: Use when writing or running pytest tests in a simple_module_python
|
|
|
7
7
|
|
|
8
8
|
## What's already wired up
|
|
9
9
|
|
|
10
|
-
Root `conftest.py` provides app-level fixtures that **every** test directory inherits — module tests don't redeclare them. `pyproject.toml` sets `asyncio_mode = "auto"` and
|
|
10
|
+
Root `conftest.py` provides app-level fixtures that **every** test directory inherits — module tests don't redeclare them. `pyproject.toml` sets `asyncio_mode = "auto"` and `addopts = "-m 'not e2e and not perf' --durations=20 --benchmark-disable"`, so:
|
|
11
11
|
|
|
12
12
|
- `async def test_*` works without `@pytest.mark.asyncio`.
|
|
13
|
-
- `make test` excludes the `e2e`
|
|
13
|
+
- `make test` excludes the `e2e` and `perf` markers by default; only `make test-e2e` runs e2e, and only `make bench` runs the `perf` benchmarks (`tests/benchmarks`).
|
|
14
|
+
- Every run prints `--durations=20` (the 20 slowest tests) so a slow function-scoped fixture surfaces without an opt-in flag.
|
|
14
15
|
|
|
15
16
|
| Fixture | What you get |
|
|
16
17
|
|---|---|
|
|
@@ -52,11 +53,11 @@ uv run pytest modules/orders/tests/test_service.py
|
|
|
52
53
|
# One test
|
|
53
54
|
uv run pytest modules/orders/tests/test_service.py::test_creates_order
|
|
54
55
|
|
|
55
|
-
# One JS test
|
|
56
|
-
npx vitest run
|
|
56
|
+
# One JS test (vitest's `include` covers packages/** and host/client_app/** only)
|
|
57
|
+
npx vitest run packages/ui/src/components/StatCard.test.tsx
|
|
57
58
|
```
|
|
58
59
|
|
|
59
|
-
`make test` runs `test-py` then `test-js`. `make test-py` and `make test-js` run only one suite each.
|
|
60
|
+
`make test` runs `test-py` then `test-js`. `make test-py` (`uv run pytest`) and `make test-js` (`npm test` → `vitest run --passWithNoTests`) run only one suite each. Vitest's `include` globs (`vitest.config.ts`) only match `packages/**` and `host/client_app/**`, so a `.test.tsx` placed under `modules/**` is **not** picked up by `make test-js` — colocate UI tests with the page in `host/client_app/` or in a `packages/*` workspace.
|
|
60
61
|
|
|
61
62
|
## E2E tests (Playwright)
|
|
62
63
|
|
|
@@ -81,10 +82,10 @@ modules/orders/
|
|
|
81
82
|
├── orders/ # package
|
|
82
83
|
└── tests/
|
|
83
84
|
├── __init__.py
|
|
84
|
-
└──
|
|
85
|
+
└── test_orders.py # imports orders.service — name from the scaffold
|
|
85
86
|
```
|
|
86
87
|
|
|
87
|
-
The directory must be listed in the root `pyproject.toml` under `[tool.pytest.ini_options].testpaths` for `make test` to pick it up. `smpy create-module`
|
|
88
|
+
The directory must be listed in the root `pyproject.toml` under `[tool.pytest.ini_options].testpaths` for `make test` to pick it up. Both `smpy create-module` and `make new-module name=<name>` add this entry (via `scripts/new_module.py`'s `update_root_pyproject`); if you scaffolded a module by hand, add it.
|
|
88
89
|
|
|
89
90
|
## Pitfalls
|
|
90
91
|
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: simple-module-migrations
|
|
3
|
-
description: Use when generating, applying, or reviewing Alembic migrations in a simple_module_python host project, especially after installing or upgrading a module package. Triggers on "alembic revision", "autogenerate", "branch labels", "downgrade", "SM010", "SM011", or "migration drift".
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# simple_module_python: migrations
|
|
7
|
-
|
|
8
|
-
## The cardinal rule
|
|
9
|
-
|
|
10
|
-
**Migrations live in the host, never in the module package.** A module ships SQLModel tables only. The host developer generates one migration each time a module is installed or its tables change.
|
|
11
|
-
|
|
12
|
-
```
|
|
13
|
-
my_host/ # the host project
|
|
14
|
-
├── alembic.ini
|
|
15
|
-
├── migrations/
|
|
16
|
-
│ ├── env.py # framework template
|
|
17
|
-
│ └── versions/ # ALL revisions live here, regardless of owning module
|
|
18
|
-
│ ├── 20240101_initial_users.py # branch_labels=("users",)
|
|
19
|
-
│ ├── 20240115_initial_orders.py # branch_labels=("orders",)
|
|
20
|
-
│ └── 20240210_orders_add_total.py
|
|
21
|
-
└── pyproject.toml # depends on each installed module
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## How autogenerate sees every module
|
|
25
|
-
|
|
26
|
-
The host's `migrations/env.py` (scaffolded by `smpy create-host`) calls:
|
|
27
|
-
|
|
28
|
-
```python
|
|
29
|
-
from simple_module_db import build_module_metadata, make_include_object
|
|
30
|
-
|
|
31
|
-
target_metadata = build_module_metadata() # imports every installed module's <pkg>.models
|
|
32
|
-
include_object = make_include_object(target_metadata)
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
`build_module_metadata()` walks the `simple_module` entry-point group, imports each module's `models` submodule, and returns a unified `MetaData`. `make_include_object(metadata)` allowlists only tables owned by installed modules — any host-owned tables outside the module system are preserved untouched. So one `alembic revision --autogenerate` call covers every installed module in a single pass; editable installs and wheels behave identically.
|
|
36
|
-
|
|
37
|
-
## Adding a new module
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
# 1. Install the module into the host environment
|
|
41
|
-
pip install simple_module_orders
|
|
42
|
-
# or for a workspace: uv sync
|
|
43
|
-
|
|
44
|
-
# 2. Generate the migration
|
|
45
|
-
uv run alembic revision --autogenerate -m "add orders module"
|
|
46
|
-
|
|
47
|
-
# 3. Review the generated file (see "Branch labels" below)
|
|
48
|
-
# 4. Apply
|
|
49
|
-
uv run alembic upgrade head
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
### Branch labels — set on the FIRST revision per module
|
|
53
|
-
|
|
54
|
-
Each module's first revision must set a `branch_labels` tuple matching the module name, lowercased:
|
|
55
|
-
|
|
56
|
-
```python
|
|
57
|
-
# migrations/versions/20240115_initial_orders.py
|
|
58
|
-
revision = "abc123"
|
|
59
|
-
down_revision = "previous_head"
|
|
60
|
-
branch_labels = ("orders",) # ← required on this first revision
|
|
61
|
-
depends_on = None
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Why: it lets operators roll back **just one module's** schema:
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
alembic downgrade orders@base # drops everything orders-owned, leaves other modules
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Without the label, `downgrade <revision>` walks linear history and rolls back unrelated modules' migrations sitting between. The autogenerate template doesn't add the label automatically; you have to edit the file before applying.
|
|
71
|
-
|
|
72
|
-
Subsequent revisions for the same module **don't** need a `branch_labels` — only the first.
|
|
73
|
-
|
|
74
|
-
## Updating a module's schema
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
# Change models.py in the module (or pip install --upgrade <module>)
|
|
78
|
-
uv run alembic revision --autogenerate -m "orders: add total column"
|
|
79
|
-
uv run alembic upgrade head
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
No branch label on subsequent revisions. The diff covers every installed module — review the generated file to confirm only the intended module's tables changed.
|
|
83
|
-
|
|
84
|
-
## Diagnostics
|
|
85
|
-
|
|
86
|
-
| Code | When |
|
|
87
|
-
|---|---|
|
|
88
|
-
| **SM010** (error) | DB revision is behind migration head — production fails boot, dev warns |
|
|
89
|
-
| **SM011** (warning) | Module declares a table that has no entry in migration history — usually means you added a model and forgot to autogenerate |
|
|
90
|
-
|
|
91
|
-
Both fire at boot. SM010 in production is fatal: deploy a migration before deploying the code that depends on it.
|
|
92
|
-
|
|
93
|
-
## Pitfalls
|
|
94
|
-
|
|
95
|
-
- **Skipped the branch label on the first revision.** `alembic downgrade <module>@base` then fails or silently rolls back unrelated revisions. Fix by editing the migration file before applying; if already deployed, write a no-op revision that adds the label retroactively.
|
|
96
|
-
- **Renamed a column.** Autogenerate emits `drop_column` + `add_column`, which loses data. Edit to `op.alter_column(..., new_column_name=...)` and write the matching `downgrade()`.
|
|
97
|
-
- **Hand-edited operations with stubbed `downgrade()`.** Server defaults, CHECK constraints, expression indexes — Alembic can't always infer these. When you fill in `upgrade()`, fill in `downgrade()` too.
|
|
98
|
-
- **Concurrent `alembic upgrade head` from multiple processes.** The `alembic_version` table isn't race-safe across all backends. Run upgrades from one place (a release pipeline step), not from the booting app.
|
|
99
|
-
|
|
100
|
-
## Related skills
|
|
101
|
-
|
|
102
|
-
- **simple-module-database** — defining tables that autogenerate will pick up
|
|
103
|
-
- **simple-module-doctor** — interpreting SM010/SM011 and other boot-time codes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/.env.example
RENAMED
|
File without changes
|
{simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/.gitignore
RENAMED
|
File without changes
|
{simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/Makefile
RENAMED
|
File without changes
|
{simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/README.md.tpl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/alembic.ini
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/main.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/host/routes.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/module/.gitignore
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_cli-0.0.20 → simple_module_cli-0.0.22}/simple_module_cli/templates/workspace/Makefile
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|