From 472932ca949e22bfa5a27b14e7325fbed7f8bbf2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 5 Nov 2026 15:45:55 +0100 Subject: [PATCH 1/2] feat(layout): core/web/ui module layout for new modules; scan ui/ for Tailwind and Vite Scaffolded modules now keep only looked-up files at the package root (module.py, models, tasks.py, contracts/, pages/, locales/, CSS) and put the rest in core/, web/ and ui/ areas, with tests mirroring the areas. Wheel-installed modules get /ui scanned for Tailwind classes and resolved by the host Vite reader. docs/module-layout.md describes the layout and the moves-only migration; host/tests/test_module_layout.py freezes the current roots of existing modules so they only shrink. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 8 + CLAUDE.md | 44 ++-- biome.json | 1 + docs/.vitepress/config.ts | 1 + docs/framework-conventions.md | 48 ++-- docs/framework/lifecycle.md | 2 +- docs/framework/middleware.md | 2 +- docs/guide/first-module.md | 22 +- docs/guide/project-structure.md | 42 ++-- docs/module-authoring.md | 58 +++-- docs/module-layout.md | 210 ++++++++++++++++++ .../cli/simple_module_cli/framework_repo.py | 1 + .../cli/simple_module_cli/scaffolding.py | 3 + .../host/client_app/module-assets.ts | 10 +- .../templates/module/README.md.tpl | 11 + .../__PACKAGE__/contracts/schemas.py.tpl | 2 +- .../module/__PACKAGE__/core/__init__.py | 1 + .../__PACKAGE__/{ => core}/constants.py.tpl | 0 .../__PACKAGE__/{ => core}/services.py.tpl | 2 +- .../__PACKAGE__/{ => core}/settings.py.tpl | 0 .../module/__PACKAGE__/endpoints/__init__.py | 0 .../module/__PACKAGE__/models.py.tpl | 2 +- .../module/__PACKAGE__/module.py.tpl | 10 +- .../module/__PACKAGE__/web/__init__.py | 1 + .../module/__PACKAGE__/{ => web}/deps.py.tpl | 2 +- .../__PACKAGE__/web/endpoints/__init__.py | 1 + .../{ => web}/endpoints/api.py.tpl | 16 +- .../{ => web}/endpoints/views.py.tpl | 14 +- .../__PACKAGE__/{ => web}/service.py.tpl | 0 .../_optional/framework_repo/README.md.tpl | 10 +- .../framework_repo/pyproject.toml.tpl | 1 + .../templates/module/pyproject.toml.tpl | 1 + .../templates/module/tests/conftest.py | 1 + .../contracts/test___PACKAGE___schemas.py.tpl | 26 +++ .../module/test___PACKAGE___module.py.tpl | 27 +++ .../crud/test___PACKAGE___crud.py.tpl} | 54 +---- .../cli/tests/test_cli_new_regressions.py | 2 +- .../cli/tests/test_cli_new_scaffold_layout.py | 3 + framework/cli/tests/test_module_css_assets.py | 1 + .../cli/tests/test_scaffolding_area_layout.py | 64 ++++++ .../tests/test_scaffolding_framework_repo.py | 7 +- .../cli/tests/test_scaffolding_module.py | 29 ++- .../cli/tests/test_scaffolding_module_js.py | 4 +- .../hosting/simple_module_hosting/assets.py | 13 +- .../hosting/tests/test_hosting_ui_assets.py | 52 +++++ host/client_app/module-assets.test.ts | 33 +++ host/client_app/module-assets.ts | 10 +- host/tests/test_module_layout.py | 188 ++++++++++++++++ scripts/check_no_double_cast.py | 2 +- scripts/tests/test_check_no_double_cast.py | 1 + skills/simple-module-cli/SKILL.md | 2 +- skills/simple-module-conventions/SKILL.md | 5 + skills/simple-module-creating/SKILL.md | 47 ++-- skills/simple-module-inertia-pages/SKILL.md | 2 +- skills/simple-module-registries/SKILL.md | 2 +- skills/simple-module-testing/SKILL.md | 10 +- 56 files changed, 888 insertions(+), 223 deletions(-) create mode 100644 docs/module-layout.md create mode 100644 framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/__init__.py rename framework/cli/simple_module_cli/templates/module/__PACKAGE__/{ => core}/constants.py.tpl (100%) rename framework/cli/simple_module_cli/templates/module/__PACKAGE__/{ => core}/services.py.tpl (87%) rename framework/cli/simple_module_cli/templates/module/__PACKAGE__/{ => core}/settings.py.tpl (100%) delete mode 100644 framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py create mode 100644 framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/__init__.py rename framework/cli/simple_module_cli/templates/module/__PACKAGE__/{ => web}/deps.py.tpl (84%) create mode 100644 framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/__init__.py rename framework/cli/simple_module_cli/templates/module/__PACKAGE__/{ => web}/endpoints/api.py.tpl (93%) rename framework/cli/simple_module_cli/templates/module/__PACKAGE__/{ => web}/endpoints/views.py.tpl (93%) rename framework/cli/simple_module_cli/templates/module/__PACKAGE__/{ => web}/service.py.tpl (100%) create mode 100644 framework/cli/simple_module_cli/templates/module/tests/conftest.py create mode 100644 framework/cli/simple_module_cli/templates/module/tests/core/contracts/test___PACKAGE___schemas.py.tpl create mode 100644 framework/cli/simple_module_cli/templates/module/tests/core/module/test___PACKAGE___module.py.tpl rename framework/cli/simple_module_cli/templates/module/tests/{test___PACKAGE__.py.tpl => web/crud/test___PACKAGE___crud.py.tpl} (61%) create mode 100644 framework/cli/tests/test_scaffolding_area_layout.py create mode 100644 framework/hosting/tests/test_hosting_ui_assets.py create mode 100644 host/client_app/module-assets.test.ts create mode 100644 host/tests/test_module_layout.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6c16b03..97658c6bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index a53f8d73e..6913a3811 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,29 +135,26 @@ The Python package name (`feature_flags`) keys the i18n namespace, `app.state.` and table prefixes. Don't swap them: `keys.` 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/// -├── 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. container built in register_settings -├── service.py # business logic -├── deps.py # FastAPI dependencies -├── endpoints/api.py # REST (JSON) under /api/ -├── 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/.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///`, with +no `__init__.py`, unique `test__*.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` @@ -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, @@ -422,8 +420,8 @@ development and testing (`session_cookie_secure`). `docs/framework/i18n.md`. - **JS tests** go in `modules//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 @@ -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 diff --git a/biome.json b/biome.json index 127283d55..3ac435e8c 100644 --- a/biome.json +++ b/biome.json @@ -5,6 +5,7 @@ "host/client_app/**", "packages/**", "modules/*/*/pages/**", + "modules/*/*/ui/**", "modules/*/*/**/components/**", "modules/*/tests-js/**", "framework/hosting/simple_module_hosting/setup_wizard/**", diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7086fd7af..533a23e29 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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" }, ], diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 1f0eed7e0..ec93f638a 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -4,23 +4,37 @@ Short reference for the invariants module authors rely on. When in doubt, read t ## Module structure -``` +```text modules// -├── pyproject.toml # entry point: simple_module = ".module:Module" -└── / - ├── __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 --dest modules/` — 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/` to register it on your app. +├── pyproject.toml, package.json, tsconfig.json +├── / +│ ├── __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/ # .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__module.py + ├── core/contracts/test__schemas.py + └── web/crud/test__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 --dest modules/` (or +`make new-module 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__*.py` basenames. JS tests stay in `tests-js/`. ## ModuleMeta @@ -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 nonce="{{ request.state.csp_nonce }}", 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 diff --git a/docs/framework/lifecycle.md b/docs/framework/lifecycle.md index 438109491..bf444ceb1 100644 --- a/docs/framework/lifecycle.md +++ b/docs/framework/lifecycle.md @@ -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 nonce="{{ request.state.csp_nonce }}", 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 diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index 2f71dee62..4fe9f4c76 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -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 nonce="{{ request.state.csp_nonce }}" 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. diff --git a/docs/guide/first-module.md b/docs/guide/first-module.md index 0d0804969..8ca9a2c0e 100644 --- a/docs/guide/first-module.md +++ b/docs/guide/first-module.md @@ -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///`, 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`: @@ -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 @@ -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): @@ -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) @@ -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"]) @@ -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 diff --git a/docs/guide/project-structure.md b/docs/guide/project-structure.md index 4481ae2a5..2efede032 100644 --- a/docs/guide/project-structure.md +++ b/docs/guide/project-structure.md @@ -57,35 +57,33 @@ See [Bundled modules](/modules/) for details on each. ## Anatomy of a module -Every module under `modules//` (or installed from PyPI as `simple_module_`) 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/ # .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 diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 6676d85b8..0007baabb 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -9,21 +9,32 @@ 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 # SQLModel tables (optional) -│ ├── endpoints/ # FastAPI routes -│ ├── pages/ # Inertia TSX pages (optional) -│ ├── templates/ # Jinja2 templates (optional) -│ ├── static/dist/ # pre-built frontend assets (optional) -│ ├── contracts/schemas.py # SQLModel DTOs — the public surface -│ └── contracts/events.py # domain events (optional) +modules// +├── pyproject.toml, package.json, tsconfig.json +├── / +│ ├── __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/ # .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__module.py + ├── core/contracts/test__schemas.py + └── web/crud/test__crud.py ``` +New modules use this layout. Existing bundled modules migrate one at a time; +see [Module layout](module-layout.md) for the exact root contract and shim policy. +Area initializers contain only a docstring; private helpers live in their area. +Tests have no `__init__.py`, use globally unique `test__*.py` basenames, +and keep module-wide fixtures in `modules//tests/conftest.py`. JS tests stay in `tests-js/`. + ### Standalone vs in-repo: `.github/` workflows `smpy create-module` ships a `.github/` with `ci.yml` + `publish.yml` (PyPI @@ -74,7 +85,7 @@ and `uv sync` work too. ### Service types: concrete class, not Protocol -Export the concrete service class from `.service` and have consumers +Export public service types through `.contracts` and have consumers type-hint against it. Do **not** ship a `contracts/service.py` with an `IFooService` Protocol by default — it's dead boilerplate when there's only one implementation. @@ -88,7 +99,7 @@ mock against the Protocol. If none of those apply to your module, skip it. ### Service errors: domain exceptions Services raise domain exceptions derived from a per-module base error in -`/exceptions.py` or `errors.py`; they must not raise `HTTPException`. +`/core/errors.py`; they must not raise `HTTPException`. Keep the failure and its context independent of HTTP so CLI commands, tasks and durable handlers can reuse the same business logic. @@ -230,7 +241,7 @@ tenant override > system override > definition default. They accept either a `FeatureFlagDefinition` (preferred) or the raw name. ```python -# my_module/constants.py +# my_module/core/constants.py from simple_module_core import FeatureFlagDefinition FLAG_BULK_IMPORT = FeatureFlagDefinition( @@ -253,7 +264,7 @@ class MyModule(ModuleBase): ``` ```python -# endpoints/api.py — four ways to consume, all accept the constant directly +# web/endpoints/api.py — four ways to consume, all accept the constant directly from typing import Annotated from fastapi import APIRouter, Depends, Request from simple_module_core import feature_flag, flag_enabled, is_flag_enabled, require_flag @@ -469,12 +480,12 @@ The host mounts each entry as `StaticFiles` during boot. Use the sibling's **npm package name** — the `name` in its `package.json`: ```tsx -import { BlockRegistry } from '@simple-module-py/pagebuilder/components/blockRegistry'; +import { BlockRegistry } from '@simple-module-py/pagebuilder/ui/components/blockRegistry'; ``` The host builds that alias from `modules.assets.json` and points it at the sibling's **Python package directory** (`my_module/`). So everything after the -package name is a path *inside* that package — `components/…`, `pages/…`, +package name is a path *inside* that package — `ui/components/…`, `pages/…`, mirroring the layout in [Anatomy of a module package](#anatomy-of-a-module-package). That anchor is forced rather than chosen, and it is worth understanding because @@ -495,11 +506,11 @@ This means the shape npm gives you for a workspace member is the wrong one: ```tsx // ✅ same file in both layouts -import x from '@simple-module-py/foo/components/Widget'; +import x from '@simple-module-py/foo/ui/components/Widget'; // ❌ workspace-only. npm symlinks @simple-module-py/foo -> modules/foo/, so // this happens to resolve in a checkout and breaks once foo is wheel-installed. -import x from '@simple-module-py/foo/foo/components/Widget'; +import x from '@simple-module-py/foo/foo/ui/components/Widget'; ``` Declare the sibling in your `package.json` `peerDependencies` so the dependency @@ -615,7 +626,7 @@ origin/scheme token, validated 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 nonce="{{ request.state.csp_nonce }}", 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 @@ -837,6 +848,11 @@ building inside a freshly scaffolded host of that version. It needs `uv`, ### `smpy module build` — static_mounts() assets +This legacy command retains its `assets_src/` and `static/dist/` root paths. +They are outside the new [module layout](module-layout.md#frontend) allowed set; +PR 0 preserves the command without adding these roots to new in-repo modules. +Use the host's build pipeline for new Inertia pages and `ui/` sources. + If (and only if) your module ships assets outside the Inertia page pipeline, put an entry file at `/assets_src/index.ts` and run: diff --git a/docs/module-layout.md b/docs/module-layout.md new file mode 100644 index 000000000..dfab8a0d9 --- /dev/null +++ b/docs/module-layout.md @@ -0,0 +1,210 @@ +# Module layout + +New modules use this layout. Existing modules under `modules/` migrate in +separate PRs, one module at a time. No existing module moves in PR 0. +The downstream `smpy_modules` layout is the reference for areas; this repo +keeps its own pytest naming and collection conventions. + +The aim is to find code by asking which feature it belongs to. A small module +needs only `core/` and `web/`; do not create empty domain areas for future work. + +## Module directory + +```text +modules// + / Python package and frontend sources + tests/ Python tests mirroring package areas + tests-js/ Vitest tests, when needed + docs/ module-specific documentation, when needed + README.md, LICENSE, pyproject.toml, package.json, tsconfig.json +``` + +## Package root: lookup paths + +Only files looked up by name and the area directories belong at the root. +Consumer locations below refer to this repository; Python imports are resolved +by the interpreter, while `py.typed` is the PEP 561 marker for external type +checkers, not a framework runtime lookup. + +| Root entry | Consumer (file:line) and reason | +| --- | --- | +| `__init__.py` | Python package import; `framework/hosting/simple_module_hosting/assets.py:117` uses `importlib.resources.files(pkg_name)` to find it. | +| `py.typed` | PEP 561 consumers; `framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl:33` selects the whole package for the wheel, including this marker. | +| `module.py` | `framework/core/simple_module_core/discovery.py:80` loads the `simple_module` entry point; the scaffold declares its target in `framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl:15`. | +| `models.py` or `models/` | `framework/db/simple_module_db/migrations.py:74` imports the module's models for metadata; `framework/core/simple_module_core/diagnostics/_tenancy.py:94` and `framework/core/simple_module_core/diagnostics/_expression_index.py:52` do the same. A `models/` package must load its tables. | +| `tasks.py` | `modules/background_tasks/background_tasks/celery_app.py:215` autodiscovers with `related_name="tasks"`; `framework/core/simple_module_core/diagnostics/_heavy_task_imports.py:65` checks this root file (SM028). | +| `contracts/` | Public imports for sibling modules; the scaffold consumer is `framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/service.py.tpl:15`. Keep public SQLModel DTOs, events and real extension contracts here. | +| `pages/` | `framework/hosting/simple_module_hosting/manifest.py:97` discovers pages; `framework/hosting/simple_module_hosting/page_globs.py:34` emits their Vite glob; `framework/core/simple_module_core/diagnostics/_pages.py:170` checks page names. | +| `pages/api.ts`, `pages/api.generated.ts` | `framework/cli/simple_module_cli/openapi_types.py:130` selects the root pages directory for generation; `framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/api.ts.tpl:8` consumes the generated types. | +| `locales/` | The scaffold's `framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl:88` resolves the root directory in `locale_dirs()`; `framework/core/simple_module_core/module.py:210` documents the hook contract. | +| `theme.css`, `styles.css` | `framework/hosting/simple_module_hosting/assets.py:126` discovers both; `framework/core/simple_module_core/diagnostics/_css.py:171` and `framework/core/simple_module_core/diagnostics/_css.py:193` check their different cascade rules (SM022/SM023). | +| `cli.py` | Only when a declared entry point targets it: `framework/cli/simple_module_cli/plugins.py:41` loads CLI plugins. | + +Other root files and folders are legacy exceptions, tracked explicitly by +`host/tests/test_module_layout.py:1`. Keep named lookup paths pinned with tests +that load them as their consumer does. Generated wheel metadata such as +`package.json` is packaging output, not another source area. + +## Areas + +| Area | Holds | +| --- | --- | +| `core/` | `constants.py`, `errors.py`, `settings.py` (the `DbBackedSettings` subclass), `services.py` (the `app.state.` container), events and cross-cutting helpers. | +| `web/` | `endpoints/{api,views}.py`, `deps.py`, CSRF, private request schemas, and `service.py` or `services/` for reads and writes endpoints share. | +| One folder per domain subject | Only for a subject with several features of its own; for example `ingest/` with uploads and imports. | +| `ui/` | Frontend components, hooks and utilities that are not Inertia pages. | + +Choose an area by ownership: + +- Used by most features: `core/`. A helper used by two features belongs to + the closest owning feature rather than automatically becoming core. +- Part of answering an HTTP request: `web/`. +- Otherwise: the domain subject it implements. One feature does not need a + new root area; put it in the nearest existing area. + +An area's `__init__.py` holds only a docstring. Public exports belong in +`contracts/`, not area initializers. Private helpers live in the package they +serve with a leading underscore, such as `web/_validation.py`. +Every Python module must import in a fresh interpreter. Break import cycles +at the ownership boundary; when a call-time import is necessary, explain why. +The 300-line cap still applies, including tests; split by responsibility. + +The root ratchet permits `core/`, `web/`, `ui/` and the lookup paths above. +Declare a new multi-feature domain area in its `DOMAIN_AREAS` map when needed. +Do not expand `LEGACY_FLAT` to accommodate new code. + +## Frontend + +| Path | Holds | +| --- | --- | +| `pages/` | Real Inertia pages, plus `api.ts` and `api.generated.ts`. Keep page names and render keys stable. | +| `ui/components//` | Reusable components grouped by feature. Shared components may sit directly in `ui/components/`. | +| `ui/hooks/`, `ui/utils/` | Hooks and plain TypeScript helpers. | +| `modules//tests-js/` | All new JS tests and their support; Vitest collects here. | + +Tailwind scans `ui/`, pages and legacy root `components/` on wheel-installed +modules, including nested source directories hidden by a venv's ignore file. +The asset manifest exposes `ui`; the pages manifest still contains only +`pages/`. Biome covers `ui/**`; the untranslated-string and double-cast checks +already recurse throughout the package. Module tsconfig includes recurse too. +Both host and scaffold `client_app/module-assets.ts` readers also register UI +source prefixes for Vite's bare-import fallback and dependency scan. Existing +hosts must update that reader when adopting UI sources; upgrading Python +packages regenerates manifests but does not rewrite host-owned Vite config. +Wheel templates include UI sources and exclude `**/*.test.*`, `**/*.spec.*`, +`**/test-dom.tsx` and `**/page-tests/**` so old frontend test helpers do not ship. +Keep CSS tokens in root `theme.css` and component rules in root `styles.css`. + +The separate legacy `smpy module build` command still uses root `assets_src/` +and `static/dist/`. PR 0 preserves that contract and the optional scaffold +`static_mounts()` hook; neither directory exists in a fresh scaffold. These +roots are not added to the new layout's allowed set. Migrating that bundler, +or adopting it in a new in-repo module, needs a separate layout policy change; +normal Inertia pages and `ui/` sources use the host's build pipeline. + +## Tests + +```text +tests/ + conftest.py + core/module/test_orders_module.py + core/contracts/test_orders_schemas.py + web/crud/test_orders_crud.py + //test_orders_.py +``` + +Mirror package ownership, using `tests///test__*.py`. +Keep module-wide `conftest.py` at `tests/` root. Do not add `__init__.py` in test +directories. Basenames must be globally unique: default pytest import mode +loads non-package tests by basename, even when they live in different folders. +Existing flat test names are grandfathered only while their module remains +in `LEGACY_FLAT`; migration renames them with the module prefix. + +The root `pyproject.toml:137` lists `modules//tests` in `testpaths`, and +`framework/cli/simple_module_cli/framework_repo.py` wires that root. Pytest +recursively collects nested areas without listing each child directory. +A scratch copy of the auth suite with a nested collection probe verified this: +`uv run pytest --collect-only -q modules/layout_probe/tests` collected 233 tests, +including the nested probe; the scratch copy was removed afterwards. +JS tests stay in `tests-js/`, not in package `ui/`. + +## Compatibility for move PRs + +Inventory old import paths used outside the module, both in this repository +and downstream. Every such old path becomes a shim using +`simple_module_core.deprecation.deprecated_module`, with `remove_in="0.0.46"`. +Update internal imports to the new path; do not make new code depend on shims. +Register every shim in `host/tests/test_deprecation_expiry.py`'s import list. +The expiry test fails once the installed framework reaches the removal release. +See [Deprecations](framework/deprecations.md) for the API. + +For example, after moving `orders.service` to `orders.web.service`, leave this +at `orders/service.py` if external callers use that old path: + +```python +"""Compatibility import for the module-layout migration.""" + +from simple_module_core.deprecation import deprecated_module + +from orders.web.service import OrderService as OrderService + +deprecated_module("orders.service", remove_in="0.0.46", use="orders.web.service") +``` + +Add `"orders.service"` to both the expiry test's `_SHIM_MODULES` tuple and +`_EXPECTED` set, and pin that +both paths expose the same class, with an import-time `DeprecationWarning`. +Keep `service.py` in the module's frozen legacy entries until the shim is +removed. A completely migrated module leaves `LEGACY_FLAT` only when its extra +roots, including compatibility shims, are gone and its Python tests are nested. + +## Moving an existing module + +One module per PR; no logic changes in a move PR. + +1. **Pin first.** Test root lookup paths through their actual discovery + consumer, public contracts, page render keys and task names. Inventory + imports in this repo and downstream before changing any paths. +2. **Move with a script.** Use `git mv` and rewrite imports, including + `from import x`, `mock.patch` targets and `monkeypatch` dotted strings. + Rewrite relative TS imports from their old location. Do not rewrite + `app.state.` attributes: these are runtime containers, not imports. +3. Move tests to matching areas/features, keep root `conftest.py`, remove test + package initializers and prefix each basename with the module name. + JS tests remain in `tests-js/`. +4. Add external-import shims with removal release `0.0.46`, register them in + the expiry test, shrink the exact frozen root exceptions, and update the + status table below. Domain areas must be named in `DOMAIN_AREAS`. +5. Run `python -c "import ."` in a **fresh interpreter for every + module**, not one interpreter importing all modules in sequence. +6. Run the module suite plus `host/tests`, `make lint`, and `make doctor`. + Exercise its browser specs where present. Where tasks exist, start a Celery + worker and check registration under the unchanged task names. +7. Review the diff as moves and import rewrites only. Verify wheel contents, + UI scanning and module lookup paths. Keep logic changes in another PR. + +## Migration status (2026-11-05) + +Counts refer to the source package root and direct `tests/test_*.py` files; +cache directories are ignored. All modules are flat in PR 0. Update this table +when a later module PR moves files. + +| Module | Root `.py` files | Flat `tests/test_*.py` | State | +| --- | ---: | ---: | --- | +| api_tokens | 15 | 13 | flat | +| audit_log | 13 | 18 | flat | +| auth | 8 | 20 | flat | +| background_tasks | 49 | 83 | flat | +| branding | 13 | 20 | flat | +| dashboard | 5 | 8 | flat | +| feature_flags | 8 | 9 | flat | +| file_storage | 39 | 65 | flat | +| keycloak | 13 | 19 | flat | +| mail | 8 | 5 | flat | +| oidc | 16 | 24 | flat | +| permissions | 12 | 14 | flat | +| settings | 31 | 43 | flat | +| sharing | 13 | 6 | flat | +| site_lock | 8 | 5 | flat | +| tenants | 19 | 23 | flat | +| users | 31 | 97 | flat | diff --git a/framework/cli/simple_module_cli/framework_repo.py b/framework/cli/simple_module_cli/framework_repo.py index 666cae546..42c9ba6d3 100644 --- a/framework/cli/simple_module_cli/framework_repo.py +++ b/framework/cli/simple_module_cli/framework_repo.py @@ -70,6 +70,7 @@ def update_root_pyproject(root: Path, name: str) -> None: root_toml = root / "pyproject.toml" content = root_toml.read_text() src_path = f"modules/{name}" + # Pytest recursively collects area/feature tests from this one root. test_path = f"modules/{name}/tests" if f'"{src_path}",' in content and f'"{test_path}"' in content: diff --git a/framework/cli/simple_module_cli/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py index f9fa8b5e0..bab3c425b 100644 --- a/framework/cli/simple_module_cli/scaffolding.py +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -194,6 +194,9 @@ def create_module( ) -> Path: """Scaffold a module package at ``dest``. + Implementation lives in ``core/`` and ``web/``; nested tests mirror those + areas without test-package initializers. Root lookup paths stay stable. + When ``framework_version`` is a concrete version, the template's forward-looking ``simple_module_*`` ranges are rewritten to an exact pin so the module resolves against that framework version (e.g. ``uv add`` into the diff --git a/framework/cli/simple_module_cli/templates/host/client_app/module-assets.ts b/framework/cli/simple_module_cli/templates/host/client_app/module-assets.ts index 0efacbfcd..03815eaec 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/module-assets.ts +++ b/framework/cli/simple_module_cli/templates/host/client_app/module-assets.ts @@ -14,7 +14,7 @@ export type ModuleAssetIndex = { fsAllow: string[]; optimizeEntries: string[]; pkgJsonPaths: string[]; - /** `` prefixes for module-owned TSX (pages/ and components/). */ + /** `` prefixes for module-owned TSX (pages/, ui/ and legacy components/). */ pagesPrefixes: string[]; aliases: Alias[]; npmNames: Set; @@ -93,6 +93,7 @@ export function loadModuleAssets(clientAppDir: string): ModuleAssetIndex { npm_name?: string | null; // Wheel modules ship widgets here; the pages-keyed manifest never sees them. components?: string | null; + ui?: string | null; }; const aliases: { find: string; replacement: string }[] = []; const npmNames = new Set(); @@ -112,10 +113,11 @@ export function loadModuleAssets(clientAppDir: string): ModuleAssetIndex { if (!fsAllow.includes(entry.package)) fsAllow.push(entry.package); // Without this the bare-specifier fallback below skips widgets, and a // component's `@simple-module-py/ui` import fails to resolve. - if (entry.components) { - const prefix = entry.components + path.sep; + for (const source of [entry.components, entry.ui]) { + if (!source) continue; + const prefix = source + path.sep; if (!pagesPrefixes.includes(prefix)) pagesPrefixes.push(prefix); - optimizeEntries.push(path.join(entry.components, '**/*.tsx')); + optimizeEntries.push(path.join(source, '**/*.{ts,tsx}')); } } // Keep the alias list in a stable, longest-first order. Vite matches a string diff --git a/framework/cli/simple_module_cli/templates/module/README.md.tpl b/framework/cli/simple_module_cli/templates/module/README.md.tpl index df8a4d479..475dd43b1 100644 --- a/framework/cli/simple_module_cli/templates/module/README.md.tpl +++ b/framework/cli/simple_module_cli/templates/module/README.md.tpl @@ -98,3 +98,14 @@ short-lived OIDC token scoped to this repo + workflow + environment. The `Meta.requires_framework` field declares which `simple_module_core` versions this module supports. Update the spec on each framework major bump after verifying compatibility. + +## Source layout + +`core/` holds constants, DB-backed settings and the app-state container. +`web/` holds dependencies, the CRUD service and `endpoints/{api,views}.py`. +Keep models, contracts, pages and locales at the package root; reusable +frontend code belongs in `ui/{components,hooks,utils}/` when needed. +Area initializers contain only a docstring. Python tests mirror +`tests///`, have no `__init__.py`, and use unique +`test_{{PACKAGE_NAME}}_*.py` basenames. Shared fixtures go in `tests/conftest.py`; +JS tests belong in `tests-js/`. diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/contracts/schemas.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/contracts/schemas.py.tpl index 6c4293efb..cba773ad8 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/contracts/schemas.py.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/contracts/schemas.py.tpl @@ -7,7 +7,7 @@ from datetime import datetime from pydantic import ConfigDict, field_validator from sqlmodel import Field, SQLModel -from {{PACKAGE_NAME}}.constants import DESCRIPTION_MAX_LENGTH, NAME_MAX_LENGTH +from {{PACKAGE_NAME}}.core.constants import DESCRIPTION_MAX_LENGTH, NAME_MAX_LENGTH class {{SINGULAR_CLASS}}Out(SQLModel): diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/__init__.py b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/__init__.py new file mode 100644 index 000000000..ed500bc90 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/__init__.py @@ -0,0 +1 @@ +"""Core area.""" diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/constants.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/constants.py.tpl similarity index 100% rename from framework/cli/simple_module_cli/templates/module/__PACKAGE__/constants.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/constants.py.tpl diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/services.py.tpl similarity index 87% rename from framework/cli/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/services.py.tpl index 7c42a90ca..76df667a9 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/services.py.tpl @@ -12,7 +12,7 @@ from __future__ import annotations from dataclasses import dataclass -from {{PACKAGE_NAME}}.settings import {{MODULE_NAME}}Settings +from {{PACKAGE_NAME}}.core.settings import {{MODULE_NAME}}Settings @dataclass diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/settings.py.tpl similarity index 100% rename from framework/cli/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/core/settings.py.tpl diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/models.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/models.py.tpl index 15d396017..750b39ac7 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/models.py.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/models.py.tpl @@ -6,7 +6,7 @@ from simple_module_db.base import create_module_base from simple_module_db.mixins import AuditMixin from sqlmodel import Field -from {{PACKAGE_NAME}}.constants import ( +from {{PACKAGE_NAME}}.core.constants import ( DESCRIPTION_MAX_LENGTH, NAME_MAX_LENGTH, TABLE_{{SINGULAR_UPPER}}, diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl index 1b78320de..dd070304e 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl @@ -11,7 +11,7 @@ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection from simple_module_core.module_settings import register_module_settings from simple_module_core.permissions import PermissionRegistry -from {{PACKAGE_NAME}}.constants import ( +from {{PACKAGE_NAME}}.core.constants import ( API_PREFIX, LOCALE_NAMESPACE, MENU_ICON, @@ -39,8 +39,8 @@ class {{MODULE_NAME}}Module(ModuleBase): ) def register_settings(self, app: FastAPI) -> None: - from {{PACKAGE_NAME}}.services import {{MODULE_NAME}}Services - from {{PACKAGE_NAME}}.settings import {{MODULE_NAME}}Settings + from {{PACKAGE_NAME}}.core.services import {{MODULE_NAME}}Services + from {{PACKAGE_NAME}}.core.settings import {{MODULE_NAME}}Settings # Records the class so the host hydrates it from the settings table # (DB → default) and mounts the services on app.state.{{PACKAGE_NAME}}. @@ -52,8 +52,8 @@ class {{MODULE_NAME}}Module(ModuleBase): ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: - from {{PACKAGE_NAME}}.endpoints.api import router as api - from {{PACKAGE_NAME}}.endpoints.views import router as views + from {{PACKAGE_NAME}}.web.endpoints.api import router as api + from {{PACKAGE_NAME}}.web.endpoints.views import router as views api_router.include_router(api) view_router.include_router(views) diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/__init__.py b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/__init__.py new file mode 100644 index 000000000..752967ecf --- /dev/null +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/__init__.py @@ -0,0 +1 @@ +"""Web area.""" diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/deps.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/deps.py.tpl similarity index 84% rename from framework/cli/simple_module_cli/templates/module/__PACKAGE__/deps.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/deps.py.tpl index 544195672..1a9b745d2 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/deps.py.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/deps.py.tpl @@ -6,7 +6,7 @@ from fastapi import Depends from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession -from {{PACKAGE_NAME}}.service import {{SINGULAR_CLASS}}Service +from {{PACKAGE_NAME}}.web.service import {{SINGULAR_CLASS}}Service async def get_{{SINGULAR}}_service( diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/__init__.py b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/__init__.py new file mode 100644 index 000000000..86c5d702a --- /dev/null +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/__init__.py @@ -0,0 +1 @@ +"""HTTP endpoints.""" diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/api.py.tpl similarity index 93% rename from framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/api.py.tpl index f2fbffc9e..a0821f073 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/api.py.tpl @@ -5,20 +5,20 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException from simple_module_hosting.permissions import RequiresPermission -from {{PACKAGE_NAME}}.constants import ( +from {{PACKAGE_NAME}}.contracts.schemas import ( + {{SINGULAR_CLASS}}Create, + {{SINGULAR_CLASS}}Out, + {{SINGULAR_CLASS}}Update, +) +from {{PACKAGE_NAME}}.core.constants import ( NOT_FOUND_DETAIL, PERM_{{PACKAGE_NAME_UPPER}}_CREATE, PERM_{{PACKAGE_NAME_UPPER}}_DELETE, PERM_{{PACKAGE_NAME_UPPER}}_EDIT, PERM_{{PACKAGE_NAME_UPPER}}_VIEW, ) -from {{PACKAGE_NAME}}.contracts.schemas import ( - {{SINGULAR_CLASS}}Create, - {{SINGULAR_CLASS}}Out, - {{SINGULAR_CLASS}}Update, -) -from {{PACKAGE_NAME}}.deps import get_{{SINGULAR}}_service -from {{PACKAGE_NAME}}.service import {{SINGULAR_CLASS}}Service +from {{PACKAGE_NAME}}.web.deps import get_{{SINGULAR}}_service +from {{PACKAGE_NAME}}.web.service import {{SINGULAR_CLASS}}Service router = APIRouter() diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/views.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/views.py.tpl similarity index 93% rename from framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/views.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/views.py.tpl index 593683751..522cd6ece 100644 --- a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/views.py.tpl +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/endpoints/views.py.tpl @@ -8,7 +8,11 @@ from simple_module_hosting.permissions import RequiresPermission from simple_module_inertia import InertiaResponse from starlette.responses import RedirectResponse -from {{PACKAGE_NAME}}.constants import ( +from {{PACKAGE_NAME}}.contracts.schemas import ( + {{SINGULAR_CLASS}}Create, + {{SINGULAR_CLASS}}Update, +) +from {{PACKAGE_NAME}}.core.constants import ( MENU_URL, PAGE_BROWSE, PAGE_CREATE, @@ -17,12 +21,8 @@ from {{PACKAGE_NAME}}.constants import ( PERM_{{PACKAGE_NAME_UPPER}}_EDIT, PERM_{{PACKAGE_NAME_UPPER}}_VIEW, ) -from {{PACKAGE_NAME}}.contracts.schemas import ( - {{SINGULAR_CLASS}}Create, - {{SINGULAR_CLASS}}Update, -) -from {{PACKAGE_NAME}}.deps import get_{{SINGULAR}}_service -from {{PACKAGE_NAME}}.service import {{SINGULAR_CLASS}}Service +from {{PACKAGE_NAME}}.web.deps import get_{{SINGULAR}}_service +from {{PACKAGE_NAME}}.web.service import {{SINGULAR_CLASS}}Service router = APIRouter() diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/service.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/service.py.tpl similarity index 100% rename from framework/cli/simple_module_cli/templates/module/__PACKAGE__/service.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/service.py.tpl diff --git a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/README.md.tpl b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/README.md.tpl index b42ee79ba..0aaba7f1e 100644 --- a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/README.md.tpl +++ b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/README.md.tpl @@ -29,6 +29,14 @@ Document the real routes, settings and any events the module emits here. ## Configuration -Settings are a `DbBackedSettings` subclass (`{{PACKAGE_NAME}}/settings.py`) read +Settings are a `DbBackedSettings` subclass (`{{PACKAGE_NAME}}/core/settings.py`) read from the settings table and exposed on `app.state.{{PACKAGE_NAME}}`; nothing is read from environment variables. + +## Source layout + +Shared settings and constants live in `{{PACKAGE_NAME}}/core/`; HTTP code +lives in `{{PACKAGE_NAME}}/web/`. Root lookup files stay in place. Reusable +frontend code belongs in `ui/`; JS tests stay in `tests-js/`. Python tests +mirror `tests///test_{{PACKAGE_NAME}}_*.py`, without package +initializers. See the repository's `docs/module-layout.md` for migration rules. diff --git a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/pyproject.toml.tpl index 60e8f1ed9..4f588d1f3 100644 --- a/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/module/_optional/framework_repo/pyproject.toml.tpl @@ -32,6 +32,7 @@ build-backend = "hatchling.build" # this the wheel builds empty and the entry point fails to import. [tool.hatch.build.targets.wheel] packages = ["{{PACKAGE_NAME}}"] +exclude = ["**/*.test.*", "**/*.spec.*", "**/test-dom.tsx", "**/page-tests/**"] # Ship the module-root package.json inside the wheel so the host can # discover JS deps via importlib.resources after a pip install. diff --git a/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl index 16bfa7f85..9a6fa52e0 100644 --- a/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl @@ -31,6 +31,7 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["{{PACKAGE_NAME}}"] +exclude = ["**/*.test.*", "**/*.spec.*", "**/test-dom.tsx", "**/page-tests/**"] # Ship the built frontend bundle inside the wheel when present. static/dist/ # is gitignored, so it needs an explicit artifacts entry — run # `smpy module build` before `uv build` to populate it. (artifacts, unlike diff --git a/framework/cli/simple_module_cli/templates/module/tests/conftest.py b/framework/cli/simple_module_cli/templates/module/tests/conftest.py new file mode 100644 index 000000000..60cccd9d8 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/module/tests/conftest.py @@ -0,0 +1 @@ +"""Module-wide fixtures; shared fixtures come from simple_module_test.""" diff --git a/framework/cli/simple_module_cli/templates/module/tests/core/contracts/test___PACKAGE___schemas.py.tpl b/framework/cli/simple_module_cli/templates/module/tests/core/contracts/test___PACKAGE___schemas.py.tpl new file mode 100644 index 000000000..06d8af784 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/module/tests/core/contracts/test___PACKAGE___schemas.py.tpl @@ -0,0 +1,26 @@ +"""Tests for the {{MODULE_NAME}} contracts feature.""" + +# ruff: noqa: I001 + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from {{PACKAGE_NAME}}.contracts.schemas import {{SINGULAR_CLASS}}Create, {{SINGULAR_CLASS}}Update + + +class TestSchemas: + def test_create_valid(self): + data = {{SINGULAR_CLASS}}Create(name="Test") + assert data.name == "Test" + assert data.description is None + + def test_create_empty_name_rejected(self): + with pytest.raises(ValidationError): + {{SINGULAR_CLASS}}Create(name="") + + def test_update_all_optional(self): + data = {{SINGULAR_CLASS}}Update() + assert data.name is None + assert data.is_active is None diff --git a/framework/cli/simple_module_cli/templates/module/tests/core/module/test___PACKAGE___module.py.tpl b/framework/cli/simple_module_cli/templates/module/tests/core/module/test___PACKAGE___module.py.tpl new file mode 100644 index 000000000..6854a32b6 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/module/tests/core/module/test___PACKAGE___module.py.tpl @@ -0,0 +1,27 @@ +"""Tests for the {{MODULE_NAME}} module feature.""" + +# ruff: noqa: I001 + +from __future__ import annotations + +from simple_module_test import effective_route_paths + +from {{PACKAGE_NAME}}.module import {{MODULE_NAME}}Module + + +class TestMeta: + def test_meta_name(self): + assert {{MODULE_NAME}}Module.meta.name == "{{MODULE_NAME}}" + + def test_meta_requires_framework(self): + assert {{MODULE_NAME}}Module.meta.requires_framework is not None + + +class TestBoot: + async def test_app_boots_with_module(self, build_test_app): + """Module registers cleanly into a minimal FastAPI host.""" + app = build_test_app({{MODULE_NAME}}Module) + # effective_route_paths reads the OpenAPI schema: FastAPI >= 0.137 + # includes routers lazily, so ``app.routes`` no longer lists them. + paths = effective_route_paths(app) + assert any(p.startswith("/api/{{MODULE_SLUG}}") for p in paths) diff --git a/framework/cli/simple_module_cli/templates/module/tests/test___PACKAGE__.py.tpl b/framework/cli/simple_module_cli/templates/module/tests/web/crud/test___PACKAGE___crud.py.tpl similarity index 61% rename from framework/cli/simple_module_cli/templates/module/tests/test___PACKAGE__.py.tpl rename to framework/cli/simple_module_cli/templates/module/tests/web/crud/test___PACKAGE___crud.py.tpl index 0b9b07730..0d03df1a7 100644 --- a/framework/cli/simple_module_cli/templates/module/tests/test___PACKAGE__.py.tpl +++ b/framework/cli/simple_module_cli/templates/module/tests/web/crud/test___PACKAGE___crud.py.tpl @@ -1,56 +1,18 @@ -"""Tests for the {{MODULE_NAME}} module: schemas, service CRUD, API, boot. +"""Tests for the {{MODULE_NAME}} crud feature.""" -The ``build_test_app``, ``db_session`` and ``authenticated_client`` fixtures -come from the ``simple_module_test`` pytest plugin (registered via -entry_points when that package is installed). No conftest.py is required. -""" - -# Import order depends on whether ruff counts this module first-party, which -# differs between an in-repo module and a standalone package; don't fight it. # ruff: noqa: I001 from __future__ import annotations import httpx -import pytest -from pydantic import ValidationError -from simple_module_test import effective_route_paths from sqlalchemy.ext.asyncio import AsyncSession -from {{PACKAGE_NAME}}.contracts.schemas import ( - {{SINGULAR_CLASS}}Create, - {{SINGULAR_CLASS}}Update, -) -from {{PACKAGE_NAME}}.module import {{MODULE_NAME}}Module -from {{PACKAGE_NAME}}.service import {{SINGULAR_CLASS}}Service +from {{PACKAGE_NAME}}.contracts.schemas import {{SINGULAR_CLASS}}Create, {{SINGULAR_CLASS}}Update +from {{PACKAGE_NAME}}.web.service import {{SINGULAR_CLASS}}Service API = "/api/{{MODULE_SLUG}}/" -class TestMeta: - def test_meta_name(self): - assert {{MODULE_NAME}}Module.meta.name == "{{MODULE_NAME}}" - - def test_meta_requires_framework(self): - assert {{MODULE_NAME}}Module.meta.requires_framework is not None - - -class TestSchemas: - def test_create_valid(self): - data = {{SINGULAR_CLASS}}Create(name="Test") - assert data.name == "Test" - assert data.description is None - - def test_create_empty_name_rejected(self): - with pytest.raises(ValidationError): - {{SINGULAR_CLASS}}Create(name="") - - def test_update_all_optional(self): - data = {{SINGULAR_CLASS}}Update() - assert data.name is None - assert data.is_active is None - - class TestService: async def test_create(self, db_session: AsyncSession): svc = {{SINGULAR_CLASS}}Service(db_session) @@ -114,13 +76,3 @@ class TestAPI: async def test_create_invalid_data(self, authenticated_client: httpx.AsyncClient): resp = await authenticated_client.post(API, json={"name": ""}) assert resp.status_code == 422 - - -class TestBoot: - async def test_app_boots_with_module(self, build_test_app): - """Module registers cleanly into a minimal FastAPI host.""" - app = build_test_app({{MODULE_NAME}}Module) - # effective_route_paths reads the OpenAPI schema: FastAPI >= 0.137 - # includes routers lazily, so ``app.routes`` no longer lists them. - paths = effective_route_paths(app) - assert any(p.startswith("/api/{{MODULE_SLUG}}") for p in paths) diff --git a/framework/cli/tests/test_cli_new_regressions.py b/framework/cli/tests/test_cli_new_regressions.py index 2a7938152..5b48ccb7a 100644 --- a/framework/cli/tests/test_cli_new_regressions.py +++ b/framework/cli/tests/test_cli_new_regressions.py @@ -94,7 +94,7 @@ def test_sm_new_sample_module_views_carry_a_menu_item(tmp_path: Path) -> None: ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) module_py = (target / "modules" / "hello" / "hello" / "module.py").read_text() - constants = (target / "modules" / "hello" / "hello" / "constants.py").read_text() + constants = (target / "modules" / "hello" / "hello" / "core" / "constants.py").read_text() assert "view_prefix=VIEW_PREFIX" in module_py assert 'VIEW_PREFIX = "/hello"' in constants assert "register_menu_items" in module_py diff --git a/framework/cli/tests/test_cli_new_scaffold_layout.py b/framework/cli/tests/test_cli_new_scaffold_layout.py index 7416f3aa2..81839e1eb 100644 --- a/framework/cli/tests/test_cli_new_scaffold_layout.py +++ b/framework/cli/tests/test_cli_new_scaffold_layout.py @@ -30,6 +30,9 @@ def test_sm_new_default_scaffolds_sample_hello_module(tmp_path: Path) -> None: assert result.exit_code == 0, result.output assert (target / "modules" / "hello" / "pyproject.toml").is_file() assert (target / "modules" / "hello" / "hello" / "module.py").is_file() + assert (target / "modules/hello/hello/core/settings.py").is_file() + assert (target / "modules/hello/hello/web/endpoints/api.py").is_file() + assert (target / "modules/hello/tests/web/crud/test_hello_crud.py").is_file() def test_sm_new_default_lays_down_workspace_layout(tmp_path: Path) -> None: diff --git a/framework/cli/tests/test_module_css_assets.py b/framework/cli/tests/test_module_css_assets.py index 9dbf87640..2eb068d77 100644 --- a/framework/cli/tests/test_module_css_assets.py +++ b/framework/cli/tests/test_module_css_assets.py @@ -251,6 +251,7 @@ async def test_writes_assets_json(self, tmp_path): "package", "pages", "components", + "ui", "theme", "styles", "npm_name", diff --git a/framework/cli/tests/test_scaffolding_area_layout.py b/framework/cli/tests/test_scaffolding_area_layout.py new file mode 100644 index 000000000..cde2127f7 --- /dev/null +++ b/framework/cli/tests/test_scaffolding_area_layout.py @@ -0,0 +1,64 @@ +"""Scaffolded areas keep imports and wheel source selection usable.""" + +from __future__ import annotations + +import ast +import os +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest +from simple_module_cli.scaffolding import create_module + + +@pytest.mark.parametrize("variant", ["workspace", "standalone", "framework_repo"]) +def test_scaffolded_modules_import_independently(tmp_path: Path, variant: str): + dest = create_module( + tmp_path / "probe", + name="layout_import_probe", + standalone=variant == "standalone", + framework_repo=variant == "framework_repo", + ) + package = dest / "layout_import_probe" + assert {p.name for p in package.iterdir()} == { + "__init__.py", + "py.typed", + "module.py", + "models.py", + "contracts", + "core", + "web", + "pages", + "locales", + } + for area in ("core", "web", "web/endpoints"): + tree = ast.parse((package / area / "__init__.py").read_text()) + assert len(tree.body) == 1 and ast.get_docstring(tree) + for source in sorted(package.rglob("*.py")): + name = ".".join(source.relative_to(dest).with_suffix("").parts) + if name.endswith(".__init__"): + name = name.removesuffix(".__init__") + result = subprocess.run( + [sys.executable, "-c", f"import {name}"], + env={**os.environ, "PYTHONPATH": str(dest)}, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, f"{name}: {result.stderr}" + wheel = tomllib.loads((dest / "pyproject.toml").read_text())["tool"]["hatch"]["build"][ + "targets" + ]["wheel"] + assert wheel["packages"] == ["layout_import_probe"] + assert set(wheel["exclude"]) == { + "**/*.test.*", + "**/*.spec.*", + "**/test-dom.tsx", + "**/page-tests/**", + } + import json + + config = json.loads((dest / "tsconfig.json").read_text()) + assert config["include"] == ["layout_import_probe/**/*.ts", "layout_import_probe/**/*.tsx"] diff --git a/framework/cli/tests/test_scaffolding_framework_repo.py b/framework/cli/tests/test_scaffolding_framework_repo.py index 1fd5dfb9b..395a4b01d 100644 --- a/framework/cli/tests/test_scaffolding_framework_repo.py +++ b/framework/cli/tests/test_scaffolding_framework_repo.py @@ -55,7 +55,8 @@ def test_every_python_file_parses_and_has_the_crud_layers(self, tmp_path: Path, for py in dest.rglob("*.py"): ast.parse(py.read_text(), filename=str(py)) for rel in [ - "constants.py", "models.py", "service.py", "deps.py", "py.typed", + "core/constants.py", "models.py", "web/service.py", "web/deps.py", "py.typed", + "core/__init__.py", "core/settings.py", "core/services.py", "web/__init__.py", "contracts/schemas.py", "locales/en.json", "pages/Browse.tsx", "pages/Edit.tsx", ]: # fmt: skip assert (pkg / rel).is_file(), rel @@ -83,14 +84,14 @@ def test_no_magic_strings_in_module_and_endpoints(self, orders: Path): """Permissions, page keys and menu labels come from constants.py.""" pkg = orders / "orders" module_py = (pkg / "module.py").read_text() - views = (pkg / "endpoints" / "views.py").read_text() + views = (pkg / "web" / "endpoints" / "views.py").read_text() assert "label_key=MENU_LABEL_KEY" in module_py assert "register_module_settings(" in module_py assert "app.state.orders =" not in module_py assert "PAGE_BROWSE," in views assert 'render("Orders/' not in views assert '"orders.view"' not in module_py - assert 'PERM_ORDERS_VIEW = "orders.view"' in (pkg / "constants.py").read_text() + assert 'PERM_ORDERS_VIEW = "orders.view"' in (pkg / "core" / "constants.py").read_text() def test_locale_catalog_carries_the_menu_key(self, orders: Path): data = json.loads((orders / "orders" / "locales" / "en.json").read_text()) diff --git a/framework/cli/tests/test_scaffolding_module.py b/framework/cli/tests/test_scaffolding_module.py index b3f1c5b89..b398400df 100644 --- a/framework/cli/tests/test_scaffolding_module.py +++ b/framework/cli/tests/test_scaffolding_module.py @@ -17,14 +17,17 @@ async def test_creates_expected_module_files(self, tmp_path): "pyproject.toml", "my_feature/__init__.py", "my_feature/module.py", - "my_feature/endpoints/__init__.py", - "my_feature/endpoints/api.py", - "tests/test_my_feature.py", + "my_feature/web/endpoints/__init__.py", + "my_feature/web/endpoints/api.py", + "tests/core/module/test_my_feature_module.py", ".gitignore", "README.md", ]: assert (dest / relpath).is_file(), f"missing: {relpath}" + assert not list((dest / "tests").rglob("__init__.py")) + assert not list((dest / "tests").glob("test_*.py")) + # `tests/__init__.py` is intentionally NOT shipped: under # `--import-mode=importlib`, having one in two modules makes # pytest try to register `tests.conftest` as a plugin twice. @@ -36,7 +39,7 @@ async def test_generated_test_uses_effective_route_paths(self, tmp_path): dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") - text = (dest / "tests" / "test_my_feature.py").read_text() + text = (dest / "tests" / "core" / "module" / "test_my_feature_module.py").read_text() assert "effective_route_paths(app)" in text assert "app.routes" not in text.replace("``app.routes``", "") @@ -54,11 +57,19 @@ async def test_test_file_basename_is_unique_per_module(self, tmp_path): alpha = create_module(tmp_path / "alpha", name="Alpha") beta = create_module(tmp_path / "beta", name="Beta") - alpha_tests = [p.name for p in (alpha / "tests").glob("test_*.py")] - beta_tests = [p.name for p in (beta / "tests").glob("test_*.py")] - - assert alpha_tests == ["test_alpha.py"] - assert beta_tests == ["test_beta.py"] + alpha_tests = [p.name for p in (alpha / "tests").rglob("test_*.py")] + beta_tests = [p.name for p in (beta / "tests").rglob("test_*.py")] + + assert sorted(alpha_tests) == [ + "test_alpha_crud.py", + "test_alpha_module.py", + "test_alpha_schemas.py", + ] + assert sorted(beta_tests) == [ + "test_beta_crud.py", + "test_beta_module.py", + "test_beta_schemas.py", + ] assert not set(alpha_tests) & set(beta_tests) async def test_pyproject_declares_entry_point_and_deps(self, tmp_path): diff --git a/framework/cli/tests/test_scaffolding_module_js.py b/framework/cli/tests/test_scaffolding_module_js.py index 999d8b177..5be841f5f 100644 --- a/framework/cli/tests/test_scaffolding_module_js.py +++ b/framework/cli/tests/test_scaffolding_module_js.py @@ -62,9 +62,9 @@ async def test_scaffold_ships_browse_page_and_view(self, tmp_path): page = (dest / "my_feature" / "pages" / "Browse.tsx").read_text(encoding="utf-8") assert "PageShell" in page - views = (dest / "my_feature" / "endpoints" / "views.py").read_text(encoding="utf-8") + views = (dest / "my_feature" / "web" / "endpoints" / "views.py").read_text(encoding="utf-8") assert "PAGE_BROWSE" in views - constants = (dest / "my_feature" / "constants.py").read_text(encoding="utf-8") + constants = (dest / "my_feature" / "core" / "constants.py").read_text(encoding="utf-8") assert '"MyFeature/Browse"' in constants module_py = (dest / "my_feature" / "module.py").read_text(encoding="utf-8") assert "view_prefix=VIEW_PREFIX" in module_py diff --git a/framework/hosting/simple_module_hosting/assets.py b/framework/hosting/simple_module_hosting/assets.py index 1304df219..4a8238d43 100644 --- a/framework/hosting/simple_module_hosting/assets.py +++ b/framework/hosting/simple_module_hosting/assets.py @@ -50,6 +50,7 @@ PACKAGE_JSON = "package.json" PAGES_DIR = "pages" COMPONENTS_DIR = "components" +UI_DIR = "ui" @dataclass(frozen=True) @@ -64,6 +65,7 @@ class ModuleAssets: styles_css: Path | None npm_name: str | None = None components_dir: Path | None = None + ui_dir: Path | None = None def find_npm_name(pkg_root: Path) -> str | None: @@ -120,6 +122,7 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]: continue pages_dir = pkg_root / PAGES_DIR components_dir = pkg_root / COMPONENTS_DIR + ui_dir = pkg_root / UI_DIR theme = pkg_root / THEME_CSS styles = pkg_root / STYLES_CSS entry = ModuleAssets( @@ -131,8 +134,11 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]: styles_css=styles.resolve() if styles.is_file() else None, npm_name=find_npm_name(pkg_root), components_dir=components_dir.resolve() if components_dir.is_dir() else None, + ui_dir=ui_dir.resolve() if ui_dir.is_dir() else None, ) - if entry.pages_dir or entry.theme_css or entry.styles_css or entry.components_dir: + if any( + (entry.pages_dir, entry.theme_css, entry.styles_css, entry.components_dir, entry.ui_dir) + ): result.append(entry) return result @@ -206,7 +212,7 @@ def render_modules_css( ``@import`` is emitted for *every* module, in-repo and wheel alike, because there is no static-glob equivalent for CSS. - Both ``pages/`` and ``components/`` are scanned. A wheel module's widgets + ``pages/``, ``ui/`` and legacy ``components/`` are scanned. A wheel module's widgets live under ``components/``, and leaving them out meant every host had to hand-write a ``@source`` pointing into ``.venv`` — which cannot be written portably: POSIX venvs nest under ``lib/python3.x/site-packages`` while @@ -225,7 +231,7 @@ def render_modules_css( source_lines = [ f'@source "{s.as_posix()}/**/*.{{ts,tsx}}";' for e in assets - for d in (e.pages_dir, e.components_dir) + for d in (e.pages_dir, e.components_dir, e.ui_dir) if d and not in_repo(d) for s in _source_dirs(d) ] @@ -270,6 +276,7 @@ def render_assets_json(assets: Sequence[ModuleAssets]) -> str: "package": e.package_dir.as_posix(), "pages": e.pages_dir.as_posix() if e.pages_dir else None, "components": e.components_dir.as_posix() if e.components_dir else None, + "ui": e.ui_dir.as_posix() if e.ui_dir else None, "theme": e.theme_css.as_posix() if e.theme_css else None, "styles": e.styles_css.as_posix() if e.styles_css else None, "npm_name": e.npm_name, diff --git a/framework/hosting/tests/test_hosting_ui_assets.py b/framework/hosting/tests/test_hosting_ui_assets.py new file mode 100644 index 000000000..5da05c6d6 --- /dev/null +++ b/framework/hosting/tests/test_hosting_ui_assets.py @@ -0,0 +1,52 @@ +"""Wheel-installed UI sources reach Tailwind without becoming Inertia pages.""" + +from __future__ import annotations + +import importlib.util +import json +import sys + +import pytest +from simple_module_core import ModuleBase, ModuleMeta +from simple_module_hosting.assets import ( + compute_module_assets, + render_assets_json, + render_modules_css, +) +from simple_module_hosting.manifest import compute_module_pages + + +@pytest.mark.parametrize("with_pages", [False, True]) +def test_ui_sources_are_discovered(tmp_path, monkeypatch, with_pages): + pkg = tmp_path / "layout_ui_fixture" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + ui = pkg / "ui" + widget = ui / "components" / "cards" + widget.mkdir(parents=True) + (widget / "Card.tsx").write_text("export const Card = () =>
;") + if with_pages: + (pkg / "pages").mkdir() + (pkg / "pages" / "Browse.tsx").write_text("export default () =>
;") + monkeypatch.syspath_prepend(str(tmp_path)) + spec = importlib.util.spec_from_file_location("layout_ui_fixture", pkg / "__init__.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setitem(sys.modules, "layout_ui_fixture", module) + + class UiModule(ModuleBase): + meta = ModuleMeta(name="LayoutUi") + + UiModule.__module__ = "layout_ui_fixture.module" + assets = compute_module_assets([UiModule()]) + assert len(assets) == 1 + assert assets[0].ui_dir == ui.resolve() + css = render_modules_css(assets, in_repo=lambda _: False) + assert f'@source "{ui}/**/*.{{ts,tsx}}";' in css + assert f'@source "{widget}/**/*.{{ts,tsx}}";' in css + assert "@source" not in render_modules_css(assets, in_repo=lambda _: True) + assert json.loads(render_assets_json(assets))["LayoutUi"]["ui"] == str(ui) + pages = compute_module_pages([UiModule()]) + assert bool(pages) == with_pages + assert all(path.name == "pages" for path in pages.values()) diff --git a/host/client_app/module-assets.test.ts b/host/client_app/module-assets.test.ts new file mode 100644 index 000000000..883aa8fd8 --- /dev/null +++ b/host/client_app/module-assets.test.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { loadModuleAssets as loadScaffoldAssets } from '../../framework/cli/simple_module_cli/templates/host/client_app/module-assets'; +import { loadModuleAssets } from './module-assets'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +it.each([ + ['host', loadModuleAssets], + ['scaffold', loadScaffoldAssets], +] as const)('%s resolves UI-only wheel sources and legacy components', (_name, load) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'smp-ui-assets-')); + roots.push(root); + const pkg = path.join(root, 'wheel', 'widgets'); + const ui = path.join(pkg, 'ui'); + const components = path.join(pkg, 'components'); + fs.writeFileSync( + path.join(root, 'modules.assets.json'), + JSON.stringify({ Widgets: { package_name: 'widgets', package: pkg, ui, components } }), + ); + + const assets = load(root); + expect(assets.fsAllow).toContain(pkg); + expect(assets.pagesPrefixes).toContain(ui + path.sep); + expect(assets.pagesPrefixes).toContain(components + path.sep); + expect(assets.optimizeEntries).toContain(path.join(ui, '**/*.{ts,tsx}')); + expect(assets.optimizeEntries).toContain(path.join(components, '**/*.{ts,tsx}')); +}); diff --git a/host/client_app/module-assets.ts b/host/client_app/module-assets.ts index 11e7ba8eb..e867eecc8 100644 --- a/host/client_app/module-assets.ts +++ b/host/client_app/module-assets.ts @@ -14,6 +14,7 @@ type ModuleAsset = { package: string; npm_name?: string | null; components?: string | null; + ui?: string | null; }; export type Alias = { find: string; replacement: string }; @@ -25,7 +26,7 @@ export type ModuleAssetIndex = { optimizeEntries: string[]; /** Each module's package.json — its deps declare what its pages may import. */ pkgJsonPaths: string[]; - /** `` prefixes for module-owned TSX (pages/ and components/), + /** `` prefixes for module-owned TSX (pages/, ui/ and legacy components/), * for cheaply testing "is this importer module source?". */ pagesPrefixes: string[]; /** `#module/` and `` aliases. */ @@ -113,10 +114,11 @@ export function loadModuleAssets(clientAppDir: string): ModuleAssetIndex { // resolver below skips them and a widget's `@simple-module-py/ui` import // dies with "Failed to resolve import" — the JS half of GH #258, whose // CSS half is the @source emission in simple_module_hosting.assets. - if (entry.components) { - const prefix = entry.components + path.sep; + for (const source of [entry.components, entry.ui]) { + if (!source) continue; + const prefix = source + path.sep; if (!pagesPrefixes.includes(prefix)) pagesPrefixes.push(prefix); - optimizeEntries.push(path.join(entry.components, '**/*.tsx')); + optimizeEntries.push(path.join(source, '**/*.{ts,tsx}')); } } diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py new file mode 100644 index 000000000..15604ca19 --- /dev/null +++ b/host/tests/test_module_layout.py @@ -0,0 +1,188 @@ +"""Ratchet package roots while existing modules migrate one PR at a time.""" + +# Keep the frozen filename inventory readable as whitespace-separated data. +# ruff: noqa: SIM905 + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import pytest + +MODULES = Path(__file__).resolve().parents[2] / "modules" +ALLOWED_ROOT = frozenset( + { + "__init__.py", + "py.typed", + "module.py", + "models.py", + "models", + "tasks.py", + "contracts", + "pages", + "locales", + "theme.css", + "styles.css", + "cli.py", + "core", + "web", + "ui", + } +) +# Add named domain areas here only when they contain several related features. +DOMAIN_AREAS: dict[str, frozenset[str]] = {} + +# Frozen inventory, not a computed baseline: moves must remove their old entries. +LEGACY_FLAT: dict[str, frozenset[str]] = { + "api_tokens": frozenset( + """ + audit.py constants.py deps.py endpoints owner.py resolver.py scopes.py service.py + services.py settings.py tenant_cleanup.py tokens.py user_cleanup.py + """.split() + ), + "audit_log": frozenset( + """ + activity.py capture.py constants.py deps.py endpoints export.py filters.py + resolve.py service.py services.py settings.py + """.split() + ), + "auth": frozenset( + """ + _route_match.py constants.py deps.py middleware.py oidc_common state.py + """.split() + ), + "background_tasks": frozenset( + """ + _lifecycle.py _signal_bus.py _signal_support.py after_commit.py celery_app.py + config_guards.py constants.py db_settings.py deps.py endpoints filters.py health.py + invalidation.py log_context.py log_purge.py log_service.py log_sink.py + outbox_claim.py outbox_clock.py outbox_dispatch.py outbox_guard.py outbox_loop.py + outbox_service.py outbox_support.py outbox_sweep.py outbox_transport.py + outbox_worker.py retry_service.py runner.py service.py services.py settings.py + settings_celery.py settings_env_checks.py settings_logs.py signals.py sync_db.py + task_heartbeat.py task_resource.py task_routing.py tenant_celery.py + tenant_context.py tracking.py worker_inspector.py worker_settings.py + workers_state.py + """.split() + ), + "branding": frozenset( + """ + components constants.py deps.py endpoints images.py presets.py reaper.py service.py + services.py settings.py shared_props.py tenant_branding.py tenant_definitions.py + """.split() + ), + "dashboard": frozenset( + """ + constants.py doctor.py endpoints stats.py + """.split() + ), + "feature_flags": frozenset( + """ + constants.py deps.py endpoints invalidation.py service.py tenant_cleanup.py + """.split() + ), + "file_storage": frozenset( + """ + aggregates.py audit_labels.py backend_guard.py backends body_limits.py checksum.py + constants.py cookieless.py cors.py csp.py deletes.py deps.py downloads.py endpoints + errors.py format.py health.py media_types.py object_reaper.py queries.py ranges.py + reads.py resolve.py scope.py service.py services.py serving.py settings.py + tenant_cache.py tenant_deletion.py thumbnails.py upload_guards.py upload_support.py + uploads.py visibility.py worker.py worker_support.py + """.split() + ), + "keycloak": frozenset( + """ + constants.py directory.py endpoints jwks.py oidc.py persistence.py provider.py + renewal.py settings.py state.py tokens.py + """.split() + ), + "mail": frozenset( + """ + constants.py data_compatibility.py runtime.py sender.py settings.py state.py + """.split() + ), + "oidc": frozenset( + """ + client.py constants.py directory.py discovery.py endpoints jwks.py persistence.py + presets.py provider.py renewal.py runtime.py settings.py state.py tokens.py + """.split() + ), + "permissions": frozenset( + """ + _registry_sync.py constants.py deps.py endpoints grants.py group_labels.py + role_invalidation.py service.py user_cleanup.py write_safety.py + """.split() + ), + "settings": frozenset( + """ + _announce.py _field_meta.py _listing.py _managed_keys.py _module_settings.py + _module_settings_props.py _row_masking.py _secrets.py _unique_write.py + browse_query.py constants.py deps.py endpoints env_vars.py errors.py exceptions.py + hydrate.py invalidation.py known_keys.py module_registry.py registration.py + reload.py service.py services.py settings.py store.py tenant_scope.py tenant_view.py + """.split() + ), + "sharing": frozenset( + """ + audience.py constants.py deps.py endpoints policy.py present.py service.py + services.py settings.py tenant_cleanup.py user_cleanup.py + """.split() + ), + "site_lock": frozenset( + """ + constants.py middleware.py page.py rate_limit.py settings.py state.py templates + """.split() + ), + "tenants": frozenset( + """ + _deletion.py _membership_ops.py _owner_guard.py components constants.py deps.py + endpoints errors.py hooks host_resolver.py invitation_mail.py invitations.py + resolver.py service.py services.py settings.py shared_props.py templates + token_tenant.py user_cleanup.py + """.split() + ), + "users": frozenset( + """ + admin administrators.py audit.py auth_local backend.py bootstrap.py constants.py + credentials.py data_compatibility.py db_adapter.py demo deps.py directory.py + errors.py exceptions.py invites mailer manager.py middleware.py oauth provider.py + registration.py reset_revocation.py roles_cache.py session_revocation.py + session_version_cache.py settings.py setup.py setup_action.py shared_props.py + startup.py state.py token_storage.py token_strategy.py + """.split() + ), +} + + +@pytest.mark.parametrize("module", sorted(p.name for p in MODULES.iterdir() if p.is_dir())) +def test_module_root_layout(module: str) -> None: + package = MODULES / module / module + assert package.is_dir(), f"Missing package for {module}" + entries = { + p.name + for p in package.iterdir() + if p.suffix == ".py" or (p.is_dir() and p.name != "__pycache__") + } + allowed = ALLOWED_ROOT | DOMAIN_AREAS.get(module, frozenset()) + legacy = LEGACY_FLAT.get(module, frozenset()) + if module in LEGACY_FLAT: + assert legacy, f"{module}: remove the empty LEGACY_FLAT entry" + assert not entries - allowed - legacy, ( + f"{module}: unexpected roots {entries - allowed - legacy}" + ) + assert legacy <= entries, f"{module}: remove stale LEGACY_FLAT entries {legacy - entries}" + assert not legacy & allowed, f"{module}: remove allowed entries from LEGACY_FLAT" + if (package / "cli.py").is_file(): + project = tomllib.loads((MODULES / module / "pyproject.toml").read_text())["project"] + targets = [ + value for group in project.get("entry-points", {}).values() for value in group.values() + ] + assert any(value.split(":")[0] == f"{module}.cli" for value in targets) + if module not in LEGACY_FLAT: + assert not list((MODULES / module / "tests").glob("test_*.py")), module + + +def test_legacy_modules_still_exist() -> None: + assert all((MODULES / name / name).is_dir() for name in LEGACY_FLAT) diff --git a/scripts/check_no_double_cast.py b/scripts/check_no_double_cast.py index 0d53b8df4..8dfeb16a8 100644 --- a/scripts/check_no_double_cast.py +++ b/scripts/check_no_double_cast.py @@ -8,7 +8,7 @@ // cast-ok: -Covers ``.ts``/``.tsx`` under ``modules///pages``, ``components``, ``hooks``, ``lib``, +Covers ``.ts``/``.tsx`` under ``modules///pages``, ``ui/`` and legacy frontend directories, ``packages/ui/src`` and ``host/client_app``. Test files are exempt. Usage: diff --git a/scripts/tests/test_check_no_double_cast.py b/scripts/tests/test_check_no_double_cast.py index b656b775d..f30caa49c 100644 --- a/scripts/tests/test_check_no_double_cast.py +++ b/scripts/tests/test_check_no_double_cast.py @@ -33,6 +33,7 @@ def test_is_checked_directories(): assert is_checked(Path("modules/users/users/pages/Users/Edit.tsx")) assert is_checked(Path("modules/branding/branding/components/dirty.ts")) assert is_checked(Path("modules/users/users/hooks/useThing.ts")) + assert is_checked(Path("modules/users/users/ui/utils/format.ts")) assert is_checked(Path("modules/users/users/auth_local/components/AuthIntro.tsx")) assert is_checked(Path("modules/users/users/lib/format.ts")) assert is_checked(Path("packages/ui/src/layouts/SidebarLayout.tsx")) diff --git a/skills/simple-module-cli/SKILL.md b/skills/simple-module-cli/SKILL.md index adc890dee..43a85a742 100644 --- a/skills/simple-module-cli/SKILL.md +++ b/skills/simple-module-cli/SKILL.md @@ -97,7 +97,7 @@ smpy create-module orders # writes ./simple_module_orders/ smpy create-module orders --dest ./packages/orders ``` -The result is a complete package: `pyproject.toml` with the entry point declared, `module.py` with `ModuleBase`/`ModuleMeta` skeleton, `models.py`, `contracts/`, `endpoints/{api,views}.py`, `pages/`, `locales/en.json`, plus a tests directory wired up with `simple_module_test` fixtures. +The result is a complete package: `pyproject.toml` with the entry point declared, `module.py` with `ModuleBase`/`ModuleMeta` skeleton, `models.py`, `contracts/`, `web/endpoints/{api,views}.py`, `pages/`, `locales/en.json`, `core/{constants,settings,services}.py`, `web/{deps,service}.py`, plus nested `tests///test__*.py` wired up with `simple_module_test` fixtures. `` accepts any case — `orders`, `Orders`, `ORDERS`, `blog_posts` all work. The CLI lowercases it for the directory and PascalCases it for `ModuleMeta.name`. diff --git a/skills/simple-module-conventions/SKILL.md b/skills/simple-module-conventions/SKILL.md index 86970fe76..19ae9706c 100644 --- a/skills/simple-module-conventions/SKILL.md +++ b/skills/simple-module-conventions/SKILL.md @@ -36,6 +36,11 @@ Forbidden in module code: `pydantic.BaseModel` for DTOs, and SQLAlchemy `Declara Treat 300 lines as the design pressure on every `.py`, `.ts`, and `.tsx` file (the reference codebase enforces this in CI). When you approach it, **split by responsibility** — don't golf comments and blank lines to squeeze under. +New module settings and their app-state container live in `core/settings.py` +and `core/services.py`; HTTP dependencies and services live in `web/`. Keep +root lookup files in place and put non-page frontend code in `ui/`. See +[Module layout](../../docs/module-layout.md); existing modules migrate one at a time. + ### 3. Per-module settings: `register_module_settings` → `app.state.` ```python diff --git a/skills/simple-module-creating/SKILL.md b/skills/simple-module-creating/SKILL.md index e664b1cce..2212f96ca 100644 --- a/skills/simple-module-creating/SKILL.md +++ b/skills/simple-module-creating/SKILL.md @@ -25,7 +25,7 @@ make migrate `make new-module` (→ `scripts/new_module.py`) is the in-repo path. It calls the same `create_module` as `smpy create-module`, so both produce the same tree -below (permissions, menu item with `label_key`, `constants.py`, CRUD sample, +below (permissions, menu item with `label_key`, `core/constants.py`, CRUD sample, `locales/en.json`); the in-repo variant adds workspace-sourced `pyproject.toml` metadata, `LICENSE`, and i18n-keyed pages. For a **standalone publishable package** (its own repo, with CI + PyPI `publish.yml`), use @@ -35,26 +35,25 @@ host. ## What scaffolding produces -``` +```text modules/orders/ -├── pyproject.toml # entry point declared here -├── package.json # per-module JS deps -├── tsconfig.json -├── tests/test_orders.py -└── orders/ - ├── module.py # OrdersModule(ModuleBase) with ModuleMeta - ├── constants.py # PERM_*, PAGE_*, MENU_* — no magic strings elsewhere - ├── models.py # Base = create_module_base("orders") - ├── contracts/schemas.py # SQLModel DTOs — public surface - ├── service.py # business logic - ├── services.py # app.state.orders state container (settings) - ├── settings.py # DbBackedSettings subclass (DB-backed, no env) - ├── deps.py # FastAPI dependencies - ├── endpoints/{api,views}.py - ├── pages/{Browse,Create,Edit}.tsx # *.tsx, auto-discovered by Vite - ├── pages/api.ts # typed /api client: createApi() - ├── pages/api.generated.ts # from `smpy openapi gen-types`; never edit - └── locales/en.json +├── pyproject.toml, package.json, tsconfig.json +├── orders/ +│ ├── __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/ # .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_orders_module.py + ├── core/contracts/test_orders_schemas.py + └── web/crud/test_orders_crud.py ``` `make new-module` also edits `host/pyproject.toml` (adds the workspace dep), the @@ -63,6 +62,12 @@ root `pyproject.toml` (type-check + test paths) and the `publish-pypi` matrix in test` without further wiring. Commit the `uv.lock`, `package-lock.json` and regenerated i18n key files it leaves behind. +New modules use this area layout; existing modules migrate one at a time. +Area initializers hold only a docstring. Private helpers stay in their area. +Tests have no `__init__.py`, keep `conftest.py` at their root and use globally +unique `test_orders_*.py` basenames; JS tests stay in `tests-js/`. +See [Module layout](../../docs/module-layout.md) for the lookup consumers and shim policy. + ## The contract: pyproject.toml + module.py 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). @@ -130,7 +135,7 @@ The new module should appear in the registered-modules log line. If it has a vie - **Registered the module by hand in host code.** Don't — discovery is entry-point-only; host code never imports module code. Services raise domain exceptions derived from a per-module base in -`exceptions.py` or `errors.py`, never `HTTPException`. Map them in endpoints or +`core/errors.py`, never `HTTPException`. Map them in endpoints or `register_exception_handlers(app)` (as in `file_storage`); translate at the request boundary. Preserve existing status codes, JSON bodies and headers. Endpoint-level `HTTPException` for pure request validation remains fine. diff --git a/skills/simple-module-inertia-pages/SKILL.md b/skills/simple-module-inertia-pages/SKILL.md index 6f079c340..16722f6d7 100644 --- a/skills/simple-module-inertia-pages/SKILL.md +++ b/skills/simple-module-inertia-pages/SKILL.md @@ -56,7 +56,7 @@ class AuthModule(ModuleBase): Use `InertiaDep` from `simple_module_hosting.inertia_deps` — it injects an `Inertia` instance with shared props already attached: ```python -# orders/endpoints/views.py +# orders/web/endpoints/views.py from fastapi import APIRouter from simple_module_hosting.inertia_deps import InertiaDep diff --git a/skills/simple-module-registries/SKILL.md b/skills/simple-module-registries/SKILL.md index 3cf6b719e..ee4669abf 100644 --- a/skills/simple-module-registries/SKILL.md +++ b/skills/simple-module-registries/SKILL.md @@ -131,7 +131,7 @@ class NotificationsModule(ModuleBase): ``` ```python -# orders/service.py +# orders/web/service.py async def place_order(self, ...): order = ... await self._bus.publish(OrderPlaced(order_id=order.id, ...)) diff --git a/skills/simple-module-testing/SKILL.md b/skills/simple-module-testing/SKILL.md index c17dc4a50..1eafdfe91 100644 --- a/skills/simple-module-testing/SKILL.md +++ b/skills/simple-module-testing/SKILL.md @@ -80,10 +80,10 @@ No fixture is needed. Metadata is available as `deferred_props`, `merge_props`, ```bash # One file -uv run pytest modules/orders/tests/test_service.py +uv run pytest modules/orders/tests/web/crud/test_orders_crud.py # One test -uv run pytest modules/orders/tests/test_service.py::test_creates_order +uv run pytest modules/orders/tests/web/crud/test_orders_crud.py::test_creates_order # One JS test (vitest's `include` covers packages/** and host/client_app/** only) npx vitest run packages/ui/src/components/StatCard.test.tsx @@ -114,9 +114,13 @@ modules/orders/ ├── orders/ # package └── tests/ ├── __init__.py - └── test_orders.py # imports orders.service — name from the scaffold + └── web/crud/test_orders_crud.py # imports orders.web.service ``` +Tests mirror `tests///`, have no `__init__.py`, and use globally +unique `test__*.py` basenames. Keep shared fixtures at `modules//tests/conftest.py`. +Pytest recurses from the tests root; JS tests stay in `tests-js/`. + 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=` add this entry (via `simple_module_cli.framework_repo.update_root_pyproject`); if you scaffolded a module by hand, add it. ## Pitfalls From 3977edc6f8e95e51ff1ee5f0d5cca469ecc92a1e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 5 Nov 2026 15:50:47 +0100 Subject: [PATCH 2/2] docs: update remaining guide paths for core/web layout; drop unverifiable probe claim Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- docs/guide/configuration.md | 10 +++++----- docs/guide/installation.md | 2 +- docs/guide/quickstart.md | 6 +++--- docs/module-layout.md | 3 --- skills/simple-module-testing/SKILL.md | 2 +- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index f7600f959..3c67f945d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -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.` (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 @@ -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 @@ -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) diff --git a/docs/guide/installation.md b/docs/guide/installation.md index d58eaa4fb..c2dc86922 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -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 diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 5807f98a4..bdeabefc4 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -57,9 +57,9 @@ This generates a publishable starter module at `modules/orders/` with: - `pyproject.toml` — `[project.entry-points.simple_module]` → `orders.module:OrdersModule` - `package.json` + `tsconfig.json` — JS workspace metadata so `tsc --noEmit` covers the module's `.tsx` pages. - `orders/module.py` — `ModuleBase` subclass with `meta = ModuleMeta(name="Orders", ...)`, wiring `register_routes` and `register_settings`. -- `orders/services.py` — module-scoped state container (stored on `app.state.orders` by `register_settings`). -- `orders/settings.py` — the module's `pydantic_settings` settings class. -- `orders/endpoints/api.py` — starter REST endpoints at `/api/orders`. +- `orders/core/services.py` — module-scoped state container (stored on `app.state.orders` by `register_settings`). +- `orders/core/settings.py` — the module's `pydantic_settings` settings class. +- `orders/web/endpoints/api.py` — starter REST endpoints at `/api/orders`. - `orders/pages/` — empty page dir; add `.tsx` pages (and a `register_routes` view router) as you build views. - `tests/test_orders.py` — pytest smoke test. Named after the package so a repo with several modules doesn't collide at collection: without `tests/__init__.py`, diff --git a/docs/module-layout.md b/docs/module-layout.md index dfab8a0d9..7cadcbfa8 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -123,9 +123,6 @@ in `LEGACY_FLAT`; migration renames them with the module prefix. The root `pyproject.toml:137` lists `modules//tests` in `testpaths`, and `framework/cli/simple_module_cli/framework_repo.py` wires that root. Pytest recursively collects nested areas without listing each child directory. -A scratch copy of the auth suite with a nested collection probe verified this: -`uv run pytest --collect-only -q modules/layout_probe/tests` collected 233 tests, -including the nested probe; the scratch copy was removed afterwards. JS tests stay in `tests-js/`, not in package `ui/`. ## Compatibility for move PRs diff --git a/skills/simple-module-testing/SKILL.md b/skills/simple-module-testing/SKILL.md index 1eafdfe91..03ef1f99f 100644 --- a/skills/simple-module-testing/SKILL.md +++ b/skills/simple-module-testing/SKILL.md @@ -113,7 +113,7 @@ Each module ships its own `tests/` next to its package: modules/orders/ ├── orders/ # package └── tests/ - ├── __init__.py + ├── conftest.py └── web/crud/test_orders_crud.py # imports orders.web.service ```