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
modules/<name>/
<package>/ 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.jsonPackage 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.<pkg> 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/<feature>/ | Reusable components grouped by feature. Shared components may sit directly in ui/components/. |
ui/hooks/, ui/utils/ | Hooks and plain TypeScript helpers. |
modules/<name>/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
tests/
conftest.py
core/module/test_orders_module.py
core/contracts/test_orders_schemas.py
web/crud/test_orders_crud.py
<area>/<feature>/test_orders_<feature>.pyMirror package ownership, using tests/<area>/<feature>/test_<module>_*.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/<name>/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. 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 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:
"""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.
- 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.
- Move with a script. Use
git mvand rewrite imports, includingfrom <pkg> import x,mock.patchtargets andmonkeypatchdotted strings. Rewrite relative TS imports from their old location. Do not rewriteapp.state.<pkg>attributes: these are runtime containers, not imports. - 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 intests-js/. - 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 inDOMAIN_AREAS. - Run
python -c "import <pkg>.<mod>"in a fresh interpreter for every module, not one interpreter importing all modules in sequence. - Run the module suite plus
host/tests,make lint, andmake doctor. Exercise its browser specs where present. Where tasks exist, start a Celery worker and check registration under the unchanged task names. - 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 |
| 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 |