Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f6242cc
refactor(tenants): split tenants service by responsibility (#522)
antosubash Oct 22, 2026
483d745
refactor(db): split migrations.py by responsibility (#522)
antosubash Oct 22, 2026
170fb71
refactor(permissions): split service by responsibility (#522)
antosubash Oct 22, 2026
e8cfd69
refactor(file_storage): split service by responsibility (#522)
antosubash Oct 22, 2026
a796514
refactor(file_storage): split module.py by responsibility (#522)
antosubash Oct 22, 2026
3b28525
refactor(background_tasks): split settings by responsibility (#522)
antosubash Oct 22, 2026
023ecec
refactor(core): split permissions.py by responsibility (#522)
antosubash Oct 22, 2026
a029133
refactor(core): split i18n.py by responsibility (#522)
antosubash Oct 22, 2026
e4680fb
refactor(cli): split app_project.py by responsibility (#522)
antosubash Oct 22, 2026
92af9ac
refactor(background_tasks): keep DEFAULT_RETENTION_DAYS importable fr…
antosubash Oct 22, 2026
4a8feb8
refactor(cli): split add_cmd.py by responsibility (#522)
antosubash Oct 22, 2026
a7f7e60
refactor(hosting): split host_settings.py by responsibility (#522)
antosubash Oct 22, 2026
281ff6e
refactor(hosting): split app_builder.py by responsibility (#522)
antosubash Oct 22, 2026
f1774b9
refactor(hosting): split _phase_helpers.py by responsibility (#522)
antosubash Oct 22, 2026
aa1bc0a
refactor(db): split bulk_audit.py by responsibility (#522)
antosubash Oct 22, 2026
3074280
Merge branch 'split-522-fw' into chore/file-splits-522
antosubash Oct 22, 2026
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
2 changes: 1 addition & 1 deletion docs/framework/request-guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,4 @@ the shared store instead (see [Sharing the store](#sharing-the-store)).
- `simple_module_core.body_limits.BodyLimitRegistry`, `simple_module_core.rate_limit`
(rate parsing, in-process store), `PublicRoute.rate`
- `simple_module_hosting._body_limit`, `_rate_limit`, `_request_guard_responses`
- Wiring: `_phase_helpers.install_middleware`; registry on `app.state.body_limits`
- Wiring: `_middleware_pipeline.install_middleware`; registry on `app.state.body_limits`
59 changes: 59 additions & 0 deletions framework/cli/simple_module_cli/_add_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Post-install steps and shared helpers for ``smpy add``."""

from __future__ import annotations

import subprocess
from collections.abc import Callable
from pathlib import Path

import typer

ExecRunner = Callable[[list[str], Path], int]


_ENTRYPOINT_CHECK = (
"import sys\n"
"from importlib.metadata import distributions\n"
"target = sys.argv[1].replace('-', '_').lower()\n"
"for dist in distributions():\n"
" if any(ep.group == 'simple_module' for ep in dist.entry_points):\n"
" name = (dist.metadata['Name'] or '').replace('-', '_').lower()\n"
" if name == target:\n"
" sys.exit(0)\n"
"sys.exit(1)\n"
)


def _default_exec_runner(cmd: list[str], cwd: Path) -> int:
return subprocess.run(cmd, cwd=cwd, check=False).returncode


def _fail(message: str) -> typer.Exit:
typer.echo(f"ERROR: {message}", err=True)
return typer.Exit(code=1)


def post_install(
project_dir: Path,
dists: list[str],
models_dists: list[str],
exec_runner: ExecRunner,
) -> None:
"""uv sync → gen-pages → sync-js-deps → entry-point check → reminders."""
if exec_runner(["uv", "sync"], project_dir) != 0:
raise _fail("`uv sync` failed — pyproject was written; fix and re-run `uv sync`.")
if (project_dir / "client_app").is_dir():
exec_runner(["uv", "run", "smpy", "host", "gen-pages"], project_dir)
exec_runner(["uv", "run", "smpy", "host", "sync-js-deps"], project_dir)
for dist in dists:
code = exec_runner(["uv", "run", "python", "-c", _ENTRYPOINT_CHECK, dist], project_dir)
if code != 0:
raise _fail(
f"{dist} installed but exposes no [project.entry-points.simple_module] "
"entry point — it is not a SimpleModule module."
)
for dist in models_dists:
typer.echo(
f"{dist} ships database models — generate and apply the migration:\n"
f' make migration msg="add {dist}"\n make migrate'
)
83 changes: 83 additions & 0 deletions framework/cli/simple_module_cli/_add_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""PyPI and local-path sources for ``smpy add``."""

from __future__ import annotations

import os
from pathlib import Path

import typer

from simple_module_cli._add_install import ExecRunner, _fail, post_install
from simple_module_cli.git_source import derive_range, scan_modules
from simple_module_cli.pyproject_edit import (
save_pyproject,
write_dependency,
write_path_source,
write_workspace_source,
)
from simple_module_cli.uv_workspace import is_workspace_member


def _add_pypi(doc, pyproject: Path, raw: str, no_sync: bool, exec_runner: ExecRunner) -> list[str]:
name = raw
for i, ch in enumerate(raw):
if ch in "<>=!~[; ":
name = raw[:i]
break
constraint = raw[len(name) :]
write_dependency(doc, name, constraint)
save_pyproject(pyproject, doc)
typer.echo(f"Added {name} (PyPI).")
if not no_sync:
post_install(pyproject.parent, [name], [], exec_runner)
return [name]


def _resolve_local_path(project_dir: Path, spec_path: Path) -> tuple[Path, str]:
"""Resolve a local-path spec to ``(directory, text written to pyproject)``.

A relative path is resolved against the ``--path`` project (the pyproject
being edited, which is what ``[tool.uv.sources]`` paths are relative to).
If nothing exists there but the path exists relative to the cwd, the cwd
one is used and the written path is re-expressed relative to the project.
"""
if spec_path.is_absolute():
return spec_path, str(spec_path)
root = project_dir / spec_path
if not root.exists() and (Path.cwd() / spec_path).exists():
root = Path.cwd() / spec_path
return root, os.path.relpath(root.resolve(), project_dir.resolve())
return root, str(spec_path)


def _add_path(
doc, pyproject: Path, spec_path: Path, no_sync: bool, exec_runner: ExecRunner
) -> list[str]:
root, written = _resolve_local_path(pyproject.parent, spec_path)
found = [m for m in scan_modules(root) if m.subdirectory is None]
if not found:
raise _fail(
f"{root} has no [project.entry-points.simple_module] entry point at its root "
f"(relative paths resolve against the --path project, {pyproject.parent}, "
"falling back to the current directory)"
)
mod = found[0]
if is_workspace_member(pyproject.parent, root):
# uv refuses a path source for a workspace member; the scaffold's own
# sample module is wired the same way (unpinned + workspace = true).
write_dependency(doc, mod.dist_name, "")
write_workspace_source(doc, mod.dist_name)
typer.echo(f"Added {mod.dist_name} (uv workspace member {written}).")
else:
write_dependency(doc, mod.dist_name, derive_range(mod.version))
write_path_source(doc, mod.dist_name, path=written)
typer.echo(f"Added {mod.dist_name} (editable path {written}).")
save_pyproject(pyproject, doc)
if not no_sync:
post_install(
pyproject.parent,
[mod.dist_name],
[mod.dist_name] if mod.ships_models else [],
exec_runner,
)
return [mod.dist_name]
94 changes: 94 additions & 0 deletions framework/cli/simple_module_cli/_app_project_manifests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Dependency pins, ``.env`` DB URLs and ``pyproject.toml`` rewriting for ``smpy new``."""

from __future__ import annotations

from collections.abc import Sequence

from simple_module_cli.scaffolding import resolve_framework_version

_FRAMEWORK_VERSION = resolve_framework_version()

# Pin ``simple_module_cli`` as a dev dep so ``uv run smpy`` resolves to the
# project venv. The global ``uv tool`` install runs in its own isolated venv
# that can't see the project's plugin entry points (issue #134). The lint /
# test tooling (ruff, ty, pytest-*) backs the generated `make lint`/`make
# test` targets so a fresh app can run its own quality gates.
_APP_PY_DEV_DEPS = [
f"simple_module_test=={_FRAMEWORK_VERSION}",
f"simple_module_cli=={_FRAMEWORK_VERSION}",
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-playwright>=0.7.2",
# Floor: unpinned, uv backtracks playwright to 1.18 (GH #445).
"playwright>=1.45",
"ruff>=0.8",
"ty>=0.0.29",
]

_APP_NPM_DEPS = {
"@simple-module-py/ui": _FRAMEWORK_VERSION,
"@simple-module-py/i18n": _FRAMEWORK_VERSION,
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@inertiajs/react": "^3.7.0",
}
# Flat mode has no npm workspaces, so `client_app/package.json` is never
# installed — the root package.json seeded from these two dicts is the only
# manifest npm reads. It must therefore cover everything client_app needs to
# build, not just the framework pins: `vite.config.ts` imports
# `@tailwindcss/vite`, and `tsc` needs the @types packages. Keep this in step
# with templates/host/client_app/package.json.tpl.
_APP_NPM_DEV_DEPS = {
"@simple-module-py/tsconfig": _FRAMEWORK_VERSION,
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^26.5.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.1.1",
"tailwindcss": "^4.0.0",
"typescript": "^7.0.2",
"vite": "^8.2.2",
}


def _db_url(db: str, db_name: str, *, flat: bool, password: str = "<POSTGRES_PASSWORD>") -> str:
if db == "postgres":
return f"postgresql+asyncpg://postgres:{password}@localhost:5432/{db_name}"
# Workspace mode keeps the SQLite file next to host/'s alembic.ini so
# `cd host && uvicorn` and `cd host && alembic` resolve the same path.
return "sqlite+aiosqlite:///./app.db" if flat else "sqlite+aiosqlite:///./host/app.db"


def _rewrite_pyproject(
text: str,
deps: list[str],
dev_deps: list[str],
*,
sources: Sequence[str] = (),
project_name: str | None = None,
) -> str:
"""Replace deps in a host ``pyproject.toml`` and pin workspace sources.

``sources`` lists ``simple_module_*`` packages that should resolve from
the uv workspace (``modules/*``) instead of PyPI. Emits a
``[tool.uv.sources]`` block per entry. Empty in flat mode.

``project_name`` overrides ``[project].name`` — set in workspace mode
so the host's package name differs from the workspace root's.
"""
import tomlkit

doc = tomlkit.parse(text)
project = doc.setdefault("project", tomlkit.table())
if project_name is not None:
project["name"] = project_name
project["dependencies"] = list(deps)
groups = doc.setdefault("dependency-groups", tomlkit.table())
groups["dev"] = list(dev_deps)
if sources:
tool = doc.setdefault("tool", tomlkit.table())
uv_table = tool.setdefault("uv", tomlkit.table())
uv_sources = uv_table.setdefault("sources", tomlkit.table())
for src in sources:
uv_sources[src] = {"workspace": True}
return tomlkit.dumps(doc)
Loading
Loading