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
8 changes: 8 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,15 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
# Need uv too — pages.ts imports ./modules.generated, which is
# produced by `sm gen-pages` from the installed Python modules.
- uses: astral-sh/setup-uv@v8.0.0
with:
enable-cache: true
cache-dependency-glob: ${{ env.UV_CACHE_GLOB }}
- run: make install-py
- run: make install-js
- run: make gen-pages
- run: make ci-js-typecheck

# Single required status check for branch protection.
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ node_modules/
# Vite
host/static/dist/

# Auto-generated frontend module manifest (regenerated by the host at boot
# or via `make gen-pages`).
host/client_app/modules.manifest.json
host/client_app/modules.generated.ts

# Worktrees
.worktrees/

Expand Down
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install install-py install-js dev dev-api dev-ui build test lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck
.PHONY: install install-py install-js dev dev-api dev-ui build test lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck

# Install
install:
Expand All @@ -13,7 +13,7 @@ install-js:
npm ci

# Development
dev: docker-up
dev: docker-up gen-pages
@echo "Starting API and UI dev servers..."
$(MAKE) -j2 dev-api dev-ui

Expand All @@ -23,6 +23,10 @@ dev-api:
dev-ui:
npm run dev

# Regenerate host/client_app/modules.{manifest.json,generated.ts} from installed modules.
gen-pages:
uv run --project host sm gen-pages --host-dir=host/client_app

# Build
build:
npm run build
Expand Down
4 changes: 3 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"host/client_app/**",
"packages/**",
"modules/*/*/pages/**",
"modules/*/*/components/**"
"modules/*/*/components/**",
"!host/client_app/modules.generated.ts",
"!host/client_app/modules.manifest.json"
],
"ignoreUnknown": true
},
Expand Down
208 changes: 208 additions & 0 deletions docs/module-authoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Module authoring guide

This is the reference for authoring a module that is installable from PyPI
and assembled into a host by the `sm create-host` scaffold. It describes the
contract a module must follow, the env-var conventions, the migration
workflow the host developer uses, and the API-version / semver rules.

## Anatomy of a module package

```text
my-module/
├── pyproject.toml # declares entry point + framework dep
├── my_module/
│ ├── __init__.py
│ ├── module.py # ModuleBase subclass
│ ├── models.py # SQLAlchemy models (optional)
│ ├── endpoints/ # FastAPI routes
│ ├── pages/ # Inertia TSX pages (optional)
│ ├── templates/ # Jinja2 templates (optional)
│ ├── static/dist/ # pre-built frontend assets (optional)
│ └── contracts/events.py # domain events (optional)
└── tests/
```

### Minimal `pyproject.toml`

```toml
[project]
name = "simple-module-my-module"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"simple-module-core>=1.0,<2.0",
"simple-module-db>=1.0,<2.0",
"simple-module-hosting>=1.0,<2.0",
]

[project.entry-points.simple_module]
my_module = "my_module.module:MyModule"
```

### Minimal `module.py`

```python
from simple_module_core import ModuleBase, ModuleMeta

class MyModule(ModuleBase):
meta = ModuleMeta(
name="MyModule",
route_prefix="/api/my-module",
view_prefix="/my-module",
depends_on=[], # other module names
version="0.1.0", # your module's semver
requires_framework=">=1.0,<2.0", # framework API range
)
```

## API stability contract

`simple_module_core` exposes `FRAMEWORK_API_VERSION` (PEP 440 string). At
boot the host rejects any installed module whose
`Meta.requires_framework` spec does not accept the current framework
version, raising `FrameworkVersionError` with the offending modules named.

**Public surface** (breaking changes bump the major version):

- `ModuleBase`, `ModuleMeta`, and every `register_*` hook signature
- All `*Registry` classes (`MenuRegistry`, `PermissionRegistry`,
`FeatureFlagRegistry`, `HealthRegistry`)
- `EventBus.publish`, `.publish_nowait`, `.subscribe`
- `create_module_base`, `build_module_metadata`, `make_include_object`
- Model mixins: `AuditMixin`, `SoftDeleteMixin`, `MultiTenantMixin`,
`VersionedMixin`
- `build_app()` entry point

**Internal** (free to change without bumping major):

- `app_builder._phase_*` helpers and middleware ordering
- Discovery internals beyond the `discover_modules()` signature
- Inertia plumbing
- Logging format

## Settings

Each module's settings are loaded via its `register_settings(app)` hook.
Convention: read environment variables under the prefix `SM_<MODULE>_`
(e.g. `SM_AUTH_CLIENT_ID`) and store the result on
`app.state.<module_name_lower>_settings`. Hosts can declare
`SM_MODULES_ENABLED='["Auth","MyModule"]'` to load only a subset of
installed modules.

## Migrations workflow

Migrations live **in the host scaffold** (`<host>/migrations/versions/`),
not inside the module package. The module ships its SQLAlchemy models only;
the host developer generates a migration each time a new module is
installed or a module's models change:

```bash
pip install simple-module-my-module
alembic revision --autogenerate -m "add my-module"
# review the generated file
alembic upgrade head
```

The host's `env.py` (scaffolded from the framework's template) calls:

```python
from simple_module_db import build_module_metadata, make_include_object

target_metadata = build_module_metadata() # every installed module
include_object = make_include_object(target_metadata)
```

`build_module_metadata()` imports each installed module's `<pkg>.models`
submodule via `importlib` — the same mechanism works for editable installs
and pip-installed wheels, so the flow does not change when moving from
local development to production.

`make_include_object(metadata)` returns an Alembic `include_object` filter
that allowlists only tables owned by installed modules. Any host-defined
table (e.g. a user table the host dev added directly) is preserved
untouched by autogenerate.

### Multi-module branches

Each module's first revision should set a `branch_labels` tuple matching
the module name:

```python
# host/migrations/versions/my_module/<id>_initial_my_module.py
branch_labels = ("my_module",)
```

This lets operators roll back a single module's schema with
`alembic downgrade my_module@base` without touching other modules.

## Frontend assets

Modules may ship TSX pages in `my_module/pages/*.tsx`. On host boot (and on
`make gen-pages`) the framework emits:

- `host/client_app/modules.manifest.json` — machine-readable paths
- `host/client_app/modules.generated.ts` — per-module `import.meta.glob`
calls with absolute paths resolved via `importlib.resources`

Vite's `server.fs.allow` is extended to cover each installed module's
package root, so pages shipped inside a wheel work for the dev server and
production build alike.

For production builds, ship a pre-bundled `my_module/static/dist/` inside
the wheel and expose it via `ModuleBase.static_mounts()`:

```python
from importlib.resources import files

class MyModule(ModuleBase):
def static_mounts(self):
root = files("my_module")
return {"/modules/my-module/static": root / "static" / "dist"}
```

The host mounts each entry as `StaticFiles` during boot.

## Templates

Jinja2 template directories contributed via `ModuleBase.template_dirs()`
are appended to the host's template search path. The host's own
`host/templates/` is searched first so hosts can override module
templates by copying + editing.

## Testing during development

Install `simple-module-testing` as a dev dependency (the `sm create-module`
scaffold does this automatically):

```toml
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"simple-module-testing>=0.1,<1.0",
]
```

The package registers pytest fixtures via a `pytest11` entry_point — no
`conftest.py` is needed in your module repo. Available fixtures:

| Fixture | What it gives you |
|---|---|
| `build_test_app` | Callable `(ModuleCls) -> FastAPI` — wraps a single module in a minimal FastAPI app with its routes registered. |
| `fake_event_bus` | A `FakeEventBus` that records every `publish`/`publish_nowait` call so tests can assert emitted events. |

Example test:

```python
from my_feature.module import MyFeatureModule

async def test_api_emits_event(build_test_app, fake_event_bus):
app = build_test_app(MyFeatureModule)
# ... exercise the route via httpx.AsyncClient ...
fake_event_bus.assert_published(MyFeatureCreated)
```

`FakeEventBus` subclasses the real `EventBus`, so subscribers you wire up
still fire — recording is additive. This means behaviour your tests cover
against the fake behaves identically when the module runs inside a real
host.
1 change: 1 addition & 0 deletions framework/core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ authors = [
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"packaging>=23.0",
"pydantic>=2.0",
"pydantic-settings>=2.0",
"pyee>=12.0",
Expand Down
12 changes: 11 additions & 1 deletion framework/core/simple_module_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
print_diagnostics,
run_diagnostics,
)
from simple_module_core.discovery import discover_modules, topological_sort
from simple_module_core.discovery import (
discover_modules,
get_module_package_name,
topological_sort,
)
from simple_module_core.events import Event, EventBus
from simple_module_core.exceptions import (
CircularDependencyError,
FrameworkVersionError,
InvalidModuleError,
ModuleError,
NotFoundError,
Expand All @@ -20,8 +25,11 @@
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
from simple_module_core.module import ModuleBase, ModuleMeta
from simple_module_core.permissions import PermissionRegistry
from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility

__all__ = [
"FRAMEWORK_API_VERSION",
"check_framework_compatibility",
"ModuleBase",
"ModuleMeta",
"MenuItem",
Expand All @@ -37,8 +45,10 @@
"Event",
"EventBus",
"discover_modules",
"get_module_package_name",
"topological_sort",
"CircularDependencyError",
"FrameworkVersionError",
"InvalidModuleError",
"ModuleError",
"NotFoundError",
Expand Down
Loading