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
17 changes: 12 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@
SM_DATABASE_URL=sqlite+aiosqlite:///./app.db
# SM_DATABASE_URL=postgresql+asyncpg://sm:sm@localhost:5432/simple_module

# Keycloak
SM_KEYCLOAK_URL=http://localhost:8080
SM_KEYCLOAK_REALM=simple-module
SM_KEYCLOAK_CLIENT_ID=simple-module-app
SM_KEYCLOAK_CLIENT_SECRET=change-me-in-production
# Keycloak (auth module settings — prefix must match AuthSettings.env_prefix)
SM_AUTH_KEYCLOAK_URL=http://localhost:8080
SM_AUTH_KEYCLOAK_REALM=simple-module
SM_AUTH_KEYCLOAK_CLIENT_ID=simple-module-app
SM_AUTH_KEYCLOAK_CLIENT_SECRET=change-me-in-production

# App
SM_ENVIRONMENT=development
SM_SECRET_KEY=change-me-in-production
SM_VITE_DEV_URL=http://localhost:5173

# Multi-tenancy (default off). Turn on only for deployments that actually
# partition data by tenant. SM_TENANT_HEADER enables a header source for
# tenant resolution when there's no authenticated user — leave empty in
# production to force tenant resolution through the auth token.
# SM_MULTI_TENANT=false
# SM_TENANT_HEADER=
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ ci-js-typecheck:

# Diagnostics
doctor:
uv run python -m simple_module_hosting.diagnostics
uv run python -m simple_module_core

# Database migrations
migrate: ## Run migrations to head
Expand Down
121 changes: 121 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,123 @@
# simple_module_python

A modular-monolith framework for Python. Each feature lives in its own self-contained module — its own SQLAlchemy models, schema, FastAPI endpoints, React pages — but everything ships as one FastAPI + Inertia.js + React app. No microservice tax, no API-client glue; just plugin modules that compose at boot.

## Stack

- **Backend:** Python 3.12, FastAPI, SQLAlchemy async, Alembic
- **Frontend:** Inertia.js + React + Tailwind CSS 4, Vite HMR
- **Auth:** Keycloak (OIDC, cookie-based sessions)
- **Tooling:** uv workspaces, Ruff, ty, Biome, pytest

## Quickstart

```bash
# 1. Install Python and JS deps
make install

# 2. Copy env template (defaults work for local SQLite dev)
cp .env.example .env

# 3. Start Keycloak + Postgres (skip if sticking with SQLite)
make docker-up

# 4. Run migrations
make migrate

# 5. Start API + Vite dev server in parallel
make dev
```

Hit `http://localhost:8000` — you land on the public page. `/auth/login` takes you through Keycloak, `/dashboard` is the authenticated home, `/products` is a fully-working example module.

## Create a new module

```bash
make new-module name=orders
```

That scaffolds `modules/orders/` with a working CRUD module end-to-end — `ModuleMeta`, SQLAlchemy model with `AuditMixin`, Pydantic contracts, service layer, REST + Inertia view endpoints, `Browse/Create/Edit.tsx` pages, and tests. Next:

```bash
# 1. Edit modules/orders/orders/models.py to your actual schema
# 2. Generate a migration
make migration msg="add orders tables"
# 3. Apply it
make migrate
# 4. Run the scaffolded tests
make test
```

The new module is automatically discovered (via Python entry points), its routes register at `/api/orders` and `/orders`, and its sidebar entry appears in the menu.

## Project layout

```
framework/
core/ # module system, discovery, events, diagnostics
db/ # per-module Base, session, mixins, listeners
hosting/ # app_builder, middleware, settings, Inertia glue
modules/ # plugin modules (auth, dashboard, products, ...)
host/
main.py # FastAPI entry point
routes.py # host-level routes (landing page)
client_app/ # Vite + React client app
migrations/ # Alembic migrations
packages/
ui/ # shared shadcn/ui components & layouts
scripts/
new_module.py # module scaffolder (called by `make new-module`)
docs/
plans/ # design + implementation plans
framework-conventions.md # invariants for module authors
```

## Common commands

| Command | What it does |
|---|---|
| `make install` | Install Python (`uv sync`) and JS (`npm install`) deps |
| `make dev` | Docker up + API + Vite dev servers in parallel |
| `make test` | Run the pytest suite |
| `make lint` | Ruff + ty + Biome + tsc |
| `make doctor` | Run module diagnostics (orphan pages, missing meta, coupling violations) |
| `make migrate` | Apply pending Alembic migrations |
| `make migration msg="..."` | Autogenerate a new migration |
| `make new-module name=<name>` | Scaffold a new module |
| `make kill` | Stop any running dev servers (ports 8000, 5173) |
| `make docker-up` / `docker-down` | Manage Keycloak + Postgres containers |

## Configuration

All settings are `SM_`-prefixed env vars. Defaults in `.env.example` cover local dev. Key knobs:

| Variable | Default | Notes |
|---|---|---|
| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | Async URL. Postgres: `postgresql+asyncpg://...` |
| `SM_ENVIRONMENT` | `development` | Anything else triggers strict module discovery |
| `SM_SECRET_KEY` | _(placeholder)_ | **Must** change in production — signs session cookies |
| `SM_AUTH_KEYCLOAK_URL` | `http://localhost:8080` | Auth module settings (note the `SM_AUTH_` prefix) |
| `SM_MULTI_TENANT` | `false` | Set `true` to enable `TenantMiddleware` |
| `SM_TENANT_HEADER` | `` | Empty = token-only; set e.g. `X-Tenant-ID` to enable header fallback |

See `framework-conventions.md` for the settings-per-module convention.

## Architecture

- **Modules**: discovered via Python entry points at boot. Each module subclasses `ModuleBase` and opts into the lifecycle hooks it needs (`register_routes`, `register_menu_items`, `register_permissions`, `register_middleware`, `on_startup`, ...).
- **Database isolation**: PostgreSQL → one schema per module. SQLite → single schema, `__tablename__` prefixed with the module name.
- **Middleware pipeline** (LIFO order of execution): CorrelationId → RequestLogging → SecurityHeaders → Session → `<module middleware>` → Tenant (opt-in) → InertiaLayoutData → app.
- **Diagnostics**: `make doctor` runs a static analyzer over installed modules looking for orphan pages, phantom renders, empty modules, framework/plugin coupling, and migration drift. Errors fail the boot in production.

Deeper dives in `docs/plans/`:

- [Module lifecycle hooks](docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md)
- [Alembic migrations design](docs/plans/2026-04-13-alembic-migrations-design.md)
- [DB state refactor](docs/plans/2026-04-13-eliminate-global-mutable-db-state-design.md)
- [DX hardening (latest)](docs/plans/2026-04-14-dx-hardening-design.md)

## Contributing

- Write tests with the fixtures in `conftest.py` (`db_session`, `authenticated_client`).
- Lint with `make lint` before pushing; CI runs all four checks in parallel.
- Stick to the conventions in `docs/framework-conventions.md` — they're what diagnostics enforce.
10 changes: 9 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,19 @@

@pytest.fixture
def settings() -> Settings:
"""Settings configured for testing with in-memory SQLite."""
"""Settings configured for testing with in-memory SQLite.

Multi-tenancy stays on so the existing ``TenantMiddleware`` tests
(and the ``X-Tenant-ID`` header paths they rely on) keep working.
Individual tests that want the tenant middleware absent construct
their own ``Settings(multi_tenant=False, ...)`` in the test body.
"""
return Settings(
database_url="sqlite+aiosqlite:///:memory:",
environment="testing",
secret_key="test-secret-key",
multi_tenant=True,
tenant_header="X-Tenant-ID",
)


Expand Down
217 changes: 217 additions & 0 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# Framework Conventions

Short reference for the invariants module authors rely on. When in doubt, read the linked source — it's the source of truth; this document exists so you don't have to reverse-engineer it.

## Module structure

```
modules/<name>/
├── pyproject.toml # entry point: simple_module = "<name>.module:<Class>Module"
└── <name>/
├── __init__.py
├── module.py # ModuleBase subclass with meta = ModuleMeta(...)
├── models.py # SQLAlchemy models (optional)
├── service.py # business logic
├── deps.py # FastAPI dependencies
├── contracts/ # Pydantic DTOs + Protocol interfaces (public)
├── endpoints/
│ ├── api.py # REST endpoints (JSON)
│ └── views.py # Inertia view endpoints
└── pages/ # *.tsx — auto-discovered by Vite
```

Scaffold a fresh module with `make new-module name=<name>` — it generates all of the above.

## ModuleMeta

Every `ModuleBase` subclass **must** declare a `meta` class attribute:

```python
class OrdersModule(ModuleBase):
meta = ModuleMeta(
name="Orders", # PascalCase, unique
route_prefix="/api/orders", # mounted on the API router
view_prefix="/orders", # mounted on the view router
depends_on=["Products"], # strict load order
version="1.0.0",
)
```

- `name` is unique across the app — it's the schema name for PostgreSQL, the SQLite table prefix, the Inertia component namespace, and the diagnostic reporter name.
- `depends_on` expresses a hard ordering requirement; the framework topo-sorts modules and invokes lifecycle hooks in that order.
- Missing or invalid `meta` fails the boot in production (strict discovery) and logs a `SM001` warning in development.

## Discovery & entry points

Modules are registered via `[project.entry-points.simple_module]` in the module's `pyproject.toml`:

```toml
[project.entry-points.simple_module]
orders = "orders.module:OrdersModule"
```

The framework calls `discover_modules()` at app-build time. No manual wiring in the host.

In production (`SM_ENVIRONMENT != development`) discovery runs in **strict mode**: any entry-point load failure or structural error (missing `meta`, wrong base class) raises `InvalidModuleError` at boot. In development, errors are logged and the module is skipped.

## Middleware pipeline

Starlette's `add_middleware` is **LIFO** — the last middleware added is the first executed on a request. The framework installs middleware in this order inside `create_app`:

```
# Added last → first executed
CorrelationIdMiddleware
RequestLoggingMiddleware
SecurityHeadersMiddleware
SessionMiddleware
<module-registered middleware> # each module's register_middleware()
TenantMiddleware (if multi_tenant=True)
InertiaLayoutDataMiddleware
# Added first → last executed
```

**Execution order on a request:**

```
CorrelationId → RequestLogging → SecurityHeaders → Session
→ <modules> → Tenant → InertiaLayoutData → app
```

### Module-registered middleware ordering

When two modules at the same dependency tier both call `app.add_middleware(...)` in their `register_middleware` hook, the order is governed by topological sort and the LIFO rule: the module that sorts **later** wraps its middleware **outermost** (runs first).

If you need a specific relative order, express it with `ModuleMeta.depends_on`. Don't rely on alphabetical names.

## Settings

Framework settings live on `Settings` (`simple_module_hosting.settings`), prefix `SM_`:

```
SM_DATABASE_URL, SM_ENVIRONMENT, SM_SECRET_KEY, SM_VITE_DEV_URL,
SM_DEBUG, SM_LOG_LEVEL, SM_LOG_FORMAT, SM_MULTI_TENANT, SM_TENANT_HEADER
```

Module settings should:
- Use a per-module prefix: `SM_<MODULE>_*` (e.g. `SM_AUTH_KEYCLOAK_URL`).
- Be stored on `app.state.<module>_settings` during `register_settings(app)`.
- `SM012` diagnostic fires if `register_settings` is overridden but no `app.state.<module>_settings` is added.

```python
class AuthModule(ModuleBase):
def register_settings(self, app: FastAPI) -> None:
app.state.auth_settings = AuthSettings()
```

## Database

### Per-module Base

```python
from simple_module_db.base import create_module_base

Base = create_module_base("orders") # provider auto-detected from SM_DATABASE_URL
```

- **PostgreSQL**: the module gets its own schema (`orders`). Tables live at `orders.<table>`.
- **SQLite**: single schema. Prefix `__tablename__` with the module name to avoid collisions (`orders_order`).

You can pin the provider for tests (`provider=DatabaseProvider.SQLITE`), but code that ships should let auto-detection handle it.

### Mixins

- `AuditMixin` — `created_at`, `updated_at`, `created_by`, `updated_by` (auto-populated from the current user in listeners).
- `SoftDeleteMixin` — `is_deleted`, `deleted_at`, `deleted_by`. `delete()` converts to soft-delete; `SELECT` auto-filters. Bypass with `stmt.execution_options(include_deleted=True)`.
- `MultiTenantMixin` — `tenant_id`. Auto-populated on insert; `SELECT` auto-filters when `current_tenant_id` is set.
- `VersionedMixin` — `version`, auto-incremented on update.

### Session lifecycle (`get_db`)

Each request opens one session:

- Commit fires on success **only if** the session has pending writes (`has_writes` flag set by `after_flush` listener, or live `.new/.dirty/.deleted`).
- Read-only requests exit via rollback — cheaper, and keeps the session out of write-side profiling.
- Exceptions always rollback.

Service code should not call `session.commit()` directly. Flush for intermediate reads if you need DB-assigned values, then let the dependency commit.

## Inertia

### Page keys

`inertia.render("<ModuleName>/<PageName>", ...)` maps to `modules/<name>/<name>/pages/<PageName>.tsx`.

- `<ModuleName>` is the PascalCase of the module directory: `blog_posts` → `BlogPosts`.
- Host-level pages under `host/client_app/pages/<PageName>.tsx` render with just `<PageName>` as the key (e.g. `inertia.render("Landing", ...)`).
- Mismatched keys fire `SM003` (orphan page) and `SM004` (phantom render) at diagnostic time.

### Shared props

`InertiaLayoutDataMiddleware` populates `request.state.inertia_shared` with:

- `auth.user`, `auth.isAuthenticated`, `auth.permissions` (expanded from roles).
- `menus` — grouped by `MenuSection` (sidebar, adminSidebar, navbar, userDropdown), role-filtered.
- `csrf_token` (for authenticated users).

Use `InertiaDep` from `simple_module_hosting.inertia_deps` — it attaches the shared data automatically.

## Permissions

Modules declare permissions in `register_permissions(registry)` grouped by a name prefix:

```python
registry.add_group("Orders", [
"orders.view", "orders.create", "orders.edit", "orders.delete",
])
```

Enforce with the `RequiresPermission` dependency:

```python
@router.post("/", dependencies=[Depends(RequiresPermission("orders.create"))])
async def create_order(...): ...
```

`DEFAULT_ROLE_PERMISSIONS` in `simple_module_hosting.permissions` ships only `admin: ["*"]`. Host apps configure their own role → permission map; the framework does not know about plugin permission strings.

## Events

Base class: `Event` from `simple_module_core.events`. Subclass per domain event:

```python
@dataclass
class OrderPlaced(Event):
order_id: int
total: Decimal
```

Subscribe in `register_event_handlers(bus)`:

```python
def register_event_handlers(self, bus: EventBus) -> None:
bus.subscribe(OrderPlaced, self._on_order_placed)
```

Publish from anywhere with a bus handle:

```python
await bus.publish(OrderPlaced(order_id=42, total=Decimal("99")))
```

Dispatch walks the event's MRO, so subscribing to a base class delivers subclass events.

## Diagnostic codes

| Code | Level | Trigger |
|---|---|---|
| SM001 | ERROR | Module missing `meta` |
| SM003 | WARNING | `pages/<name>.tsx` exists but no matching `inertia.render()` call |
| SM004 | WARNING | `inertia.render("<Module>/<name>")` but no matching `.tsx` |
| SM007 | INFO | Module defines no `register_*` hooks |
| SM008 | ERROR | Duplicate module name / schema prefix conflict |
| SM009 | ERROR | Framework package directly imports from a plugin module |
| SM010 | ERROR | DB revision behind migration head |
| SM011 | WARNING | Module table not in migration history |
| SM012 | WARNING | `register_settings` overridden but nothing added to `app.state` |

Run diagnostics manually: `make doctor`.
Loading
Loading