Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ All notable changes to this project are documented in this file. The format is b

### Fixed

- The first-module guidance now agrees with itself. The guides (`docs/index.md`, quickstart, installation, Your first module) wire a new module with `smpy add ../modules/orders --path host`, the command `smpy create-module` prints, which adds it to `host/pyproject.toml` as a workspace source. They used to say `uv add ./modules/orders`, which in a `smpy new` workspace edits the root `pyproject.toml` instead. A flat app uses `smpy add ./modules/orders`. The landing page `smpy new` generates drops the `create-admin` step (the `/setup` wizard already creates the first administrator), lists the wiring and migration steps after `create-module`, and its "Read the docs" button opens the first-module guide on docs.py.simplemodule.dev instead of the GitHub repo. The first-module guide and the `simple-module-conventions` skill note that SQLModel's `Field(regex=...)` is accepted but not enforced on a DTO, and show a `field_validator` instead (#898).
- A fresh app's `make dev` no longer prints Vite errors or warnings, and `npm audit` reports nothing. The `simple_module_settings` wheel no longer ships `ui/components/validation-errors.test.ts`: that test made the host's Vite dependency scan fail with "Failed to run dependency scan … vitest". It now lives in `modules/settings/tests-js/`, and `make ci-build-packages` fails if any wheel contains a JS or TS `*.test.*` or `*.spec.*` file, a `test-dom.tsx` or a `page-tests/` directory. The scaffold's `client_app/vite.config.ts` loads under Vite 8's native config loader: it uses `import.meta.dirname` and `.ts` import extensions, and the template `tsconfig.json` sets `allowImportingTsExtensions`. It also uses `optimizeDeps.rolldownOptions` instead of the deprecated `esbuildOptions`. New apps' root `package.json` overrides `postcss-selector-parser` to `^6.1.5`, which is what `@tailwindcss/typography` pulls in, as this repo does. An existing app can copy `host/client_app/vite.config.ts` and `tsconfig.json` from a new app, and add the override (#900).
- The `Browse`, `Create` and `Edit` pages that `smpy create-module` (and `make new-module`) generates now render inside the app shell: each sets `<Page>.layout = [AuthenticatedLayout]`. Before, they had no sidebar, header or footer, so the module's sidebar entry led to a bare page with no way back. To fix a module generated earlier, add `import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';` and that assignment to each of its pages, or use `AdminLayout` for pages you moved under `/admin` and `ADMIN_SIDEBAR` (#897).
- A fresh `smpy new` app passes its own `make lint`. `host/alembic.ini` gains a `[post_write_hooks]` entry that runs the project's ruff (`check --select I --fix`, then `format`) on every new revision, so the initial-schema revision and each `make migration` output no longer fail `ruff format --check` and `ruff check` (this repo's `host/alembic.ini` has it too); a standalone `smpy create-host` project, which has no workspace root, gets a `dev` dependency group with `ruff` for the hook. `create-module` sorts the `module.py` constants import for any module name (it was unsorted for names that sort before `group`, such as `bookmarks`). The `# ty: ignore[invalid-assignment]` in `contracts/schemas.py` is generated only for a module inside this repo, where ty checks that rule, and new apps pin `ty>=0.0.90,<0.0.100`, since ty 0.0.100 renamed the `unresolved-attribute` rule the scaffold's config names. The hooks use Alembic's `module` runner, so `simple_module_db` and the host template now require `alembic>=1.16.3`. An existing app can copy the `[post_write_hooks]` block from a new app's `host/alembic.ini` (and raise its own `alembic` requirement to `>=1.16.3`), drop the unused ignore comments and pin ty the same way (#896).
Expand Down
25 changes: 23 additions & 2 deletions docs/guide/first-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ Assumes you've completed the [Quickstart](/guide/quickstart) and have an app on

```bash
smpy create-module orders --dest modules/orders
uv add ./modules/orders
smpy add ../modules/orders --path host
```

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).
This creates `modules/orders/` with a working CRUD implementation against a single-field `Order` table. `smpy add` wires it into the host: it adds `simple_module_orders` to `host/pyproject.toml` as a uv workspace source (`{ workspace = true }`), runs `uv sync` and regenerates the page manifest. `create-module` prints this same command. In a `smpy new --flat` app the module is not a workspace member, so wire it with `smpy add ./modules/orders` instead. 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/`,
Expand Down Expand Up @@ -77,6 +77,27 @@ class OrderOut(SQLModel):

DTOs are plain `SQLModel` subclasses — **not** `BaseModel` and **not** `table=True`. They're the public surface other modules import from `orders.contracts`.

Length and range limits (`min_length`, `max_length`, `ge`, `le`) are enforced, but a format check is not. SQLModel's `Field` accepts `regex=` and silently **ignores** it, and it rejects pydantic's `pattern=` with a `TypeError`. Check formats with a `field_validator`, as `tenants.contracts.TenantCreate` does (the scaffold's `_reject_explicit_null` is the same mechanism). For example, to restrict `status`, give the `OrderUpdate` above a validator:

```python
import re

from pydantic import field_validator

_STATUS_RE = re.compile(r"^[a-z_]{1,20}$")


class OrderUpdate(SQLModel):
status: str | None = Field(default=None, max_length=20)

@field_validator("status")
@classmethod
def _status_shape(cls, value: str | None) -> str | None:
if value is not None and not _STATUS_RE.fullmatch(value):
raise ValueError("status must be lowercase letters and underscores")
return value
```

## 4. Generate a migration

```bash
Expand Down
8 changes: 6 additions & 2 deletions docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,17 @@ 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), `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:
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. Wire it into the host, create its tables and restart:

```bash
uv add ./modules/orders
smpy add ../modules/orders --path host
make migration msg="add orders tables"
make migrate
make dev
```

In a `smpy new --flat` app the module is not a workspace member, so wire it with `smpy add ./modules/orders` instead.

The full walkthrough is in [Your first module](/guide/first-module).

## Update framework versions
Expand Down
28 changes: 13 additions & 15 deletions docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The API and Vite dev servers start side by side. Visit:

## 4. Create an admin

In another terminal, from inside `myapp`:
Until an administrator exists, every page redirects to the `/setup` wizard, which asks for one. To create it from a terminal instead (from inside `myapp`):

```bash
uv run smpy users create-admin --email admin@example.com --password changeme
Expand All @@ -49,25 +49,23 @@ Sign in at `/users/login` and you land on the dashboard.

```bash
smpy create-module orders --dest modules/orders
uv add ./modules/orders
smpy add ../modules/orders --path host
```

This generates a publishable starter module at `modules/orders/` with:
This generates a publishable module at `modules/orders/` with a working CRUD sample for one `Order` table:

- `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/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. <!-- path-check: ignore --> Named after the package so a repo
with several modules doesn't collide at collection: without `tests/__init__.py`,
pytest derives the test module's name from the basename alone.

You add the domain model (`models.py`), DTOs (`contracts/`), service, Inertia views, and pages yourself — the [first-module guide](/guide/first-module) walks through that.

`uv add ./modules/orders` adds the package to your app's dependencies; on the next `uv sync` (which `uv add` also runs) the entry point is registered and the module becomes discoverable on next boot.
- `orders/module.py` — `ModuleBase` subclass with `meta = ModuleMeta(name="Orders", ...)`: settings, routes, a sidebar menu item and permissions.
- `orders/models.py` — the `Order` table; `orders/contracts/schemas.py` — its DTOs.
- `orders/core/` — `constants.py` (permissions, page keys, menu), `settings.py` (a `DbBackedSettings` class) and `services.py` (stored on `app.state.orders`).
- `orders/web/` — the service plus REST endpoints at `/api/orders` and Inertia views at `/orders/`.
- `orders/pages/` — `Browse`, `Create` and `Edit` pages; `orders/locales/en.json` — their strings.
- `tests/` — pytest tests mirroring the areas (`tests/core/…`, `tests/web/crud/…`), with module-prefixed basenames such as `test_orders_crud.py`.

The [first-module guide](/guide/first-module) shows how to grow it: your own fields, validation, a migration and tests.

`smpy add ../modules/orders --path host` adds the package to `host/pyproject.toml` as a uv workspace source and runs `uv sync`, which registers its entry point; the module is discovered on the next boot. In a `smpy new --flat` app the module is not a workspace member, so wire it with `smpy add ./modules/orders` instead.

## 6. Generate a migration

Expand Down
6 changes: 4 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,12 @@ Then in another terminal, inside `myapp`:

```bash
smpy create-module orders --dest modules/orders
uv add ./modules/orders
smpy add ../modules/orders --path host
make migration msg="add orders tables" # review the generated revision
make migrate
```

That generates `modules/orders/` with a `ModuleMeta`, a SQLModel table, contracts, a service, REST + Inertia endpoints, three React pages, locales, and a smoke test — all wired in once the dev server reloads. The full walkthrough is in [Your first module](/guide/first-module).
That generates `modules/orders/` with a `ModuleMeta`, a SQLModel table, contracts, a service, REST + Inertia endpoints, three React pages, locales, and a smoke test, wires it into the host and creates its table; the dev server picks it up when it reloads. The full walkthrough is in [Your first module](/guide/first-module).

## Where to start

Expand Down
28 changes: 28 additions & 0 deletions docs/superpowers/specs/2026-11-11-first-module-guidance-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# First-module guidance agrees with itself (#898)

## Scope

- **Wiring.** The guides said `uv add ./modules/orders`. In a `smpy new` workspace that runs
against the workspace root, which is `package = false`, not against the host.
`smpy create-module` prints `smpy add ../modules/orders --path host`, which writes a
`{ workspace = true }` source into `host/pyproject.toml`; this was verified in a fresh
workspace app. Every guide now uses that command, with `smpy add ./modules/orders` for a
`--flat` app, also verified. The steps now also include the migration that follows.
- **Landing page template.**
- Drop "Create an admin": `/setup` creates the first administrator, and `/` redirects
to `/setup` until it does.
- List create-module, then wire, then migrate.
- "Read the docs" goes to `https://docs.py.simplemodule.dev/guide/first-module` (live,
200).
- **DTO validation.** Verified on SQLModel 0.0.48: `Field(regex=...)` is accepted and
ignored on a non-table model, and `Field(pattern=...)` raises `TypeError`. The
first-module guide gets a tested `field_validator` example, and the conventions skill
gets the rule. `tenants.contracts.TenantCreate` already works around it this way.

Also fixed: the quickstart's list of what `create-module` generates (it described an empty `pages/` dir and a single `tests/test_orders.py`).

## Testing

- `test_landing_page_next_steps_match_the_first_module_guide`: fails on `main`, passes now.
- Biome check on the template.
- The DTO example was run: `"pending"` is accepted, `"Bad Value"` is rejected.
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ export default function Landing({ isAuthenticated }: LandingProps) {
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
simple_module_py
</p>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
Your app is up.
</h1>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">Your app is up.</h1>
<p className="text-base text-muted-foreground sm:text-lg">
This is the host's landing page — generated by{' '}
<code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-sm">smpy new</code>.
Expand All @@ -42,7 +40,7 @@ export default function Landing({ isAuthenticated }: LandingProps) {
{primaryLabel}
</a>
<a
href="https://github.com/antosubash/simple_module_python"
href="https://docs.py.simplemodule.dev/guide/first-module"
className="inline-flex items-center justify-center rounded-md border border-border bg-background px-5 py-2.5 text-sm font-medium transition-colors hover:bg-secondary"
>
Read the docs
Expand All @@ -53,15 +51,26 @@ export default function Landing({ isAuthenticated }: LandingProps) {
<h2 className="mb-3 text-sm font-semibold">Next steps</h2>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
Create an admin:{' '}
Scaffold a module:{' '}
<code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground">
uv run smpy users create-admin
smpy create-module orders --dest modules/orders
</code>
</li>
<li>
Scaffold a module:{' '}
Wire it into the host:{' '}
<code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground">
smpy create-module orders --dest modules/orders
smpy add ../modules/orders --path host
</code>{' '}
(
<code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground">
smpy add ./modules/orders
</code>{' '}
in a flat app)
</li>
<li>
Create its tables:{' '}
<code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground">
make migration msg="add orders" && make migrate
</code>
</li>
<li>
Expand Down
15 changes: 15 additions & 0 deletions framework/cli/tests/test_cli_new_scaffold_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,18 @@ def test_sm_new_generated_app_passes_its_own_ruff(tmp_path: Path) -> None:
assert fmt.returncode == 0, f"`ruff format --check` failed:\n{fmt.stdout}\n{fmt.stderr}"
check = subprocess.run([ruff, "check", "."], cwd=target, capture_output=True, text=True)
assert check.returncode == 0, f"`ruff check` failed:\n{check.stdout}\n{check.stderr}"


def test_landing_page_next_steps_match_the_first_module_guide() -> None:
"""GH #898: `/setup` already creates the first admin, a module still has to
be wired into the host, and "Read the docs" goes to the docs site."""
from simple_module_cli import app_project

landing = (
Path(app_project.__file__).parent / "templates" / "host" / "client_app" / "pages"
) / "Landing.tsx"
text = landing.read_text(encoding="utf-8")
assert "create-admin" not in text
assert "smpy add ../modules/orders --path host" in text
assert "make migrate" in text
assert "https://docs.py.simplemodule.dev/" in text
2 changes: 2 additions & 0 deletions skills/simple-module-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class OrderOut(SQLModel): # DTO — plain SQLModel, no table=True
name: str
```

On a DTO, `Field(regex=...)` is accepted but never enforced, and `Field(pattern=...)` raises `TypeError`, so check formats with a pydantic `field_validator`. Length and range limits work.

Forbidden in module code: `pydantic.BaseModel` for DTOs, and SQLAlchemy `DeclarativeBase` + `Mapped[...]` / `mapped_column(...)` for tables. Autogenerate, fixtures, Inertia serialization, and the shared mixins all assume the SQLModel metaclass.

### 2. The file-size cap
Expand Down
Loading