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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ All notable changes to this project are documented in this file. The format is b

## [Unreleased]

### Changed

- Scaffold new modules with `core/` and `web/` areas and mirrored nested Python tests; existing modules migrate individually. See `docs/module-layout.md`.

### Fixed

- Include module `ui/` sources in wheel-installed Tailwind scanning and the asset manifest; retain legacy `components/` support.

## [0.0.43] — 2026-11-04

### Added
Expand Down
44 changes: 21 additions & 23 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,29 +135,26 @@ The Python package name (`feature_flags`) keys the i18n namespace,
`app.state.<package>` and table prefixes. Don't swap them: `keys.<ns>` breaks if
the namespace is the meta name.

**Module layout** (`make new-module` scaffolds the files below except
`tasks.py` and the CSS, plus `pyproject.toml`, `README.md`, `LICENSE`,
`package.json`, `tsconfig.json`, `tests/`):
**Module layout.** New modules use the area layout below; existing modules
migrate one by one. See [Module layout](docs/module-layout.md) for root lookup consumers,
tests, compatibility shims and the migration checklist.

```
```text
modules/<name>/<name>/
├── module.py # ModuleBase subclass, meta = ModuleMeta(...)
├── constants.py # PERM_*, PAGE_*, MENU_*, ROLE_*, _MODULE_* — see § Conventions (scaffolded)
├── models.py # SQLModel tables (table=True)
├── contracts/ # SQLModel DTOs — the public surface; add a Protocol only for real extension points
├── settings.py # DbBackedSettings subclass
├── services.py # app.state.<name> container built in register_settings
├── service.py # business logic
├── deps.py # FastAPI dependencies
├── endpoints/api.py # REST (JSON) under /api/<name>
├── endpoints/views.py # Inertia view endpoints under view_prefix
├── pages/*.tsx # Inertia pages; lowercase-leading subdirs (components/) are helpers, not pages
├── tasks.py # optional Celery tasks (see § Conventions)
├── theme.css # optional @theme tokens, imported UNLAYERED
├── styles.css # optional component rules, imported into layer(components)
└── locales/<lang>.json
├── module.py, models.py, contracts/ # entry point, tables, public imports
├── core/ # constants.py, settings.py, services.py, errors.py
├── web/ # deps.py, service.py, endpoints/{api,views}.py
├── pages/, locales/ # lookup paths stay at the root
├── ui/ # components/, hooks/, utils/ (when needed)
└── tasks.py, theme.css, styles.css # optional lookup files
```

Area `__init__.py` files hold only a docstring. Private helpers stay in their
area with a leading underscore. Tests mirror `tests/<area>/<feature>/`, with
no `__init__.py`, unique `test_<name>_*.py` basenames, and `conftest.py` at
`tests/` root. JS tests stay in `tests-js/`. The scaffold includes packaging,
`py.typed`, CRUD pages and nested tests; tasks and CSS are optional.

`gen-pages` writes `modules.generated.css` with an `@import` per CSS file that
exists. A `@theme` inside a cascade layer is inert and an unlayered rule beats
every Tailwind utility, so keep tokens in `theme.css` and rules in `styles.css`
Expand Down Expand Up @@ -351,7 +348,8 @@ development and testing (`session_cookie_secure`).
- **No magic strings in non-test Python** (`scripts/check_hardcoded_strings.py`):
`RequiresPermission(...)`, `registry.map_role(...)`, `registry.add_group(...)`
items, `inertia.render(...)` keys and `depends_on=[...]` entries must be
constants from `constants.py` (`PERM_*`, role names, `_PAGE_*`, `_MODULE_*`).
constants from `core/constants.py` (legacy modules: `constants.py`):
`PERM_*`, role names, `_PAGE_*`, `_MODULE_*`.
- **300-line cap** on `.py`/`.ts`/`.tsx`, tests included, untracked files
included (`scripts/check_file_size.py`). Exempt: shadcn components under
`packages/ui/src/components/ui/**`, generated i18n files,
Expand Down Expand Up @@ -422,8 +420,8 @@ development and testing (`session_cookie_secure`).
`docs/framework/i18n.md`.
- **JS tests** go in `modules/<m>/tests-js/*.test.ts(x)`, `packages/**` or
`host/client_app/**`; Vitest collects nothing else. Biome (single quotes,
2 spaces, width 100) lints `host/client_app`, `packages`, module `pages/` and
`components/` dirs; `tsc` runs per workspace, and a module with `.tsx` but no
2 spaces, width 100) lints `host/client_app`, `packages`, module `pages/`, `ui/` and
legacy `components/` dirs; `tsc` runs per workspace, and a module with `.tsx` but no
`tsconfig.json` fails `make ci-js-typecheck`.
- **`ty` rules.** SQLModel false positives (`unresolved-attribute`,
`unsupported-operator`, `unknown-argument`, `no-matching-overload`) are
Expand All @@ -448,7 +446,7 @@ once. The `framework_repo` variant is a template overlay

It generates a CRUD sample (model, DTOs, service, REST + Inertia endpoints,
Browse/Create/Edit pages, `locales/en.json`, tests) whose permissions, page keys
and menu item come from `constants.py` (`label_key` set, routes guarded), so a
and menu item come from `core/constants.py` (`label_key` set, routes guarded), so a
fresh module passes `make lint`. Then
`simple_module_cli.framework_repo.wire_module` adds it to `host/pyproject.toml`,
the root `testpaths`/`extra-paths` and the `publish-pypi` matrix in
Expand Down
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"host/client_app/**",
"packages/**",
"modules/*/*/pages/**",
"modules/*/*/ui/**",
"modules/*/*/**/components/**",
"modules/*/tests-js/**",
"framework/hosting/simple_module_hosting/setup_wizard/**",
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export default defineConfig({
link: "/framework-conventions",
},
{ text: "Module authoring", link: "/module-authoring" },
{ text: "Module layout", link: "/module-layout" },
{ text: "E2E testing", link: "/e2e-testing" },
{ text: "Release playbook", link: "/release" },
],
Expand Down
48 changes: 31 additions & 17 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,37 @@ Short reference for the invariants module authors rely on. When in doubt, read t

## Module structure

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

Scaffold a fresh module with `smpy create-module <name> --dest modules/<name>` — it generates a working CRUD sample (`module.py`, `constants.py`, `models.py`, `contracts/`, `service.py`, `deps.py`, `settings.py`, `services.py`, `endpoints/{api,views}.py`, `pages/{Browse,Create,Edit}.tsx`, `locales/en.json`, plus packaging files and tests). `make new-module` uses the same templates for a module inside this repo. Then run `uv add ./modules/<name>` to register it on your app.
├── pyproject.toml, package.json, tsconfig.json
├── <name>/
│ ├── __init__.py, py.typed, module.py
│ ├── models.py # or models/; discovered by name
│ ├── contracts/ # public SQLModel DTOs and extension contracts
│ ├── core/ # constants.py, settings.py, services.py, errors.py
│ ├── web/ # deps.py, service.py (or services/), private schemas
│ │ └── endpoints/ # api.py, views.py
│ ├── pages/ # Inertia pages, api.ts, api.generated.ts
│ ├── locales/ # <lang>.json
│ ├── ui/ # components/, hooks/, utils/ (when needed)
│ └── tasks.py, theme.css, styles.css # optional root lookup files
└── tests/
├── conftest.py # module-wide fixtures
├── core/module/test_<name>_module.py
├── core/contracts/test_<name>_schemas.py
└── web/crud/test_<name>_crud.py
```

New scaffolded modules use `core/` and `web/`; existing modules migrate one at a
time. See [Module layout](module-layout.md) for the root lookup table, area
selection, test naming, and compatibility policy. Area initializers contain
only a docstring. Keep private helpers in the area they serve.

Scaffold with `smpy create-module <name> --dest modules/<name>` (or
`make new-module name=<name>` inside this repo). Both generate the CRUD sample,
settings container, contracts, pages, locales, and nested tests from the same
templates. Tests have no package initializers, keep `conftest.py` at the tests
root, and use unique `test_<name>_*.py` basenames. JS tests stay in `tests-js/`.

## ModuleMeta

Expand Down Expand Up @@ -478,7 +492,7 @@ single tokens; an invalid declaration raises at boot.

Production script directives use a per-request nonce and `strict-dynamic`.
In CSP3 browsers, script host allowlists are ignored: executable scripts in
custom templates must carry `nonce="{{ request.state.csp_nonce }}"`, including
custom templates must carry <code v-pre>nonce="{{ request.state.csp_nonce }}"</code>, including
external script entries. The Inertia adapter adds it to its module scripts;
imports loaded by those trusted entries inherit trust. Registered script
origins remain in the policy for older browsers; other asset directives
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ land in both the development (Vite-widened) and production policies.

Production script directives use a per-request nonce and `strict-dynamic`.
In CSP3 browsers, script host allowlists are ignored: executable scripts in
custom templates must carry `nonce="{{ request.state.csp_nonce }}"`, including
custom templates must carry <code v-pre>nonce="{{ request.state.csp_nonce }}"</code>, including
external script entries. The Inertia adapter adds it to its module scripts;
imports loaded by those trusted entries inherit trust. Registered script
origins remain in the policy for older browsers; other asset directives
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ nonce matches its body; for the same reason a nonce-bearing document must not
be stored by a shared cache (`Cache-Control: public`), which would replay one
nonce to every visitor. Each HTTP request gets `request.state.csp_nonce`. The host's
pre-paint script and Inertia's module entry carry that nonce; custom templates
must put `nonce="{{ request.state.csp_nonce }}"` on every executable script,
must put <code v-pre>nonce="{{ request.state.csp_nonce }}"</code> on every executable script,
including external entries. JSON page data and JSON-LD are inert and need no
nonce. Development retains the permissive Vite/HMR policy.

Expand Down
10 changes: 5 additions & 5 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ From then on, edit at `/admin/settings/` (requires the `settings.manage` permiss
When you write your own module, declare settings as a `DbBackedSettings` subclass (`simple_module_core.settings_base`; constructor + DB, no env source) and register it from `register_settings(app)` with `register_module_settings`, which mounts your services object on `app.state.<module_lower>` (the lowercase package name) and lets the host hydrate it from the database before `on_startup`.

```python
# modules/orders/orders/settings.py
# modules/orders/orders/core/settings.py
from pydantic_settings import SettingsConfigDict
from simple_module_core.settings_base import DbBackedSettings

Expand All @@ -97,9 +97,9 @@ class OrdersSettings(DbBackedSettings):
max_items_per_order: int = 100


# modules/orders/orders/services.py
# modules/orders/orders/core/services.py
from dataclasses import dataclass
from orders.settings import OrdersSettings
from orders.core.settings import OrdersSettings


@dataclass
Expand All @@ -113,8 +113,8 @@ from simple_module_core.module_settings import register_module_settings

class OrdersModule(ModuleBase):
def register_settings(self, app: FastAPI) -> None:
from orders.services import OrdersServices
from orders.settings import OrdersSettings
from orders.core.services import OrdersServices
from orders.core.settings import OrdersSettings

register_module_settings(
app, "orders", OrdersSettings, lambda s: OrdersServices(settings=s)
Expand Down
22 changes: 14 additions & 8 deletions docs/guide/first-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ uv add ./modules/orders

This creates `modules/orders/` with a working CRUD implementation against a single-field `Order` table, and adds the package to your app's dependencies. Open the generated files in your editor — you'll see the full file layout described in [project structure](/guide/project-structure).

New modules put shared settings and constants in `core/`, HTTP code in `web/`,
and reusable frontend code in `ui/`. Root lookup paths (`models.py`, `contracts/`,
`pages/`, `locales/`) stay fixed. Tests mirror `tests/<area>/<feature>/`, without
`__init__.py`, with module-prefixed basenames and root `conftest.py`; JS tests
stay in `tests-js/`. See [Module layout](/module-layout).

## 2. Define your domain model

Edit `modules/orders/orders/models.py`:
Expand Down Expand Up @@ -93,7 +99,7 @@ make migrate
The scaffold already generated working CRUD. Trim it to match your domain or extend it:

```python
# modules/orders/orders/service.py
# modules/orders/orders/web/service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select

Expand Down Expand Up @@ -136,8 +142,8 @@ from simple_module_core.module import ModuleBase, ModuleMeta
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
from simple_module_core.permissions import PermissionRegistry

from orders.endpoints.api import router as api_router
from orders.endpoints.views import router as view_router
from orders.web.endpoints.api import router as api_router
from orders.web.endpoints.views import router as view_router


class OrdersModule(ModuleBase):
Expand Down Expand Up @@ -172,8 +178,8 @@ class OrdersModule(ModuleBase):
)

def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
from orders.endpoints.api import router as api
from orders.endpoints.views import router as views
from orders.web.endpoints.api import router as api
from orders.web.endpoints.views import router as views

api_router.include_router(api)
view_router.include_router(views)
Expand All @@ -185,12 +191,12 @@ class OrdersModule(ModuleBase):
## 7. Enforce permissions on endpoints

```python
# modules/orders/orders/endpoints/api.py
# modules/orders/orders/web/endpoints/api.py
from fastapi import APIRouter, Depends
from simple_module_hosting.permissions import RequiresPermission

from orders.contracts.schemas import OrderCreate, OrderOut
from orders.deps import OrderServiceDep
from orders.web.deps import OrderServiceDep

router = APIRouter(tags=["orders"])

Expand Down Expand Up @@ -279,7 +285,7 @@ Keys flatten at boot: `orders.menu.orders`, `orders.browse.title`, etc. See [Int
## 9. Write a test

```python
# modules/orders/tests/test_orders.py
# modules/orders/tests/web/crud/test_orders_crud.py
import pytest


Expand Down
2 changes: 1 addition & 1 deletion docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Then `make migrate && make dev`.
smpy create-module orders --dest modules/orders
```

That generates a publishable module package at `modules/orders/` with the starter layout — `module.py` (its `ModuleBase` subclass + entry point), `services.py`, `settings.py`, a REST `endpoints/api.py`, an empty `pages/` dir, tests, and `pyproject.toml`/`package.json`/`tsconfig.json`. Flesh out models, contracts, views, and pages as your domain needs them. Add the package to your app's dependencies and re-sync:
That generates a publishable module package at `modules/orders/` with the starter layout — `module.py` (its `ModuleBase` subclass + entry point), `core/services.py`, `core/settings.py`, a REST `web/endpoints/api.py`, an empty `pages/` dir, tests, and `pyproject.toml`/`package.json`/`tsconfig.json`. Flesh out models, contracts, views, and pages as your domain needs them. Add the package to your app's dependencies and re-sync:

```bash
uv add ./modules/orders
Expand Down
42 changes: 20 additions & 22 deletions docs/guide/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,35 +57,33 @@ See [Bundled modules](/modules/) for details on each.

## Anatomy of a module

Every module under `modules/<name>/` (or installed from PyPI as `simple_module_<name>`) follows the same shape:
New scaffolded modules use the area layout. Existing bundled modules migrate
one by one; [Module layout](/module-layout) tracks their status and the full contract.

```text
modules/orders/
├── pyproject.toml # entry point: simple_module = "orders.module:OrdersModule"
├── package.json # npm workspace member (for page TSX + Biome)
├── tsconfig.json # TypeScript project for the pages
├── pyproject.toml, package.json, tsconfig.json
├── orders/
│ ├── __init__.py
│ ├── module.py # ModuleBase subclass with meta = ModuleMeta(...)
│ ├── models.py # SQLModel tables (optional — some modules are UI-only)
│ ├── contracts/ # SQLModel DTOs — the PUBLIC surface for other modules
│ │ └── schemas.py
│ ├── service.py # business logic — takes AsyncSession, returns DTOs
│ ├── services.py # module-scoped state container — assigned to app.state.orders
│ ├── deps.py # FastAPI dependencies (auth requirements, etc.)
│ ├── endpoints/
│ │ ├── api.py # REST (JSON) endpoints
│ │ └── views.py # Inertia view endpoints
│ ├── pages/ # *.tsx — auto-discovered by Vite via modules.generated.ts
│ │ ├── Browse.tsx
│ │ ├── Create.tsx
│ │ └── Edit.tsx
│ └── locales/
│ └── en.json # translations, namespaced by module name
│ ├── __init__.py, py.typed, module.py
│ ├── models.py # or models/; discovered by name
│ ├── contracts/ # public SQLModel DTOs and extension contracts
│ ├── core/ # constants.py, settings.py, services.py, errors.py
│ ├── web/ # deps.py, service.py (or services/), private schemas
│ │ └── endpoints/ # api.py, views.py
│ ├── pages/ # Inertia pages, api.ts, api.generated.ts
│ ├── locales/ # <lang>.json
│ ├── ui/ # components/, hooks/, utils/ (when needed)
│ └── tasks.py, theme.css, styles.css # optional root lookup files
└── tests/
└── test_orders.py # smoke test — extend with service/endpoint tests as needed
├── conftest.py # module-wide fixtures
├── core/module/test_orders_module.py
├── core/contracts/test_orders_schemas.py
└── web/crud/test_orders_crud.py
```

Tests have no `__init__.py`; basenames are globally unique and start with
`test_orders_`. JS tests stay in `modules/orders/tests-js/`.

See the [module authoring guide](/module-authoring) for the full contract.

## Where things get generated
Expand Down
Loading
Loading