simple-module-cli 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. simple_module_cli/__init__.py +0 -0
  2. simple_module_cli/_env.py +12 -0
  3. simple_module_cli/app_project.py +123 -0
  4. simple_module_cli/case.py +30 -0
  5. simple_module_cli/catalog.py +107 -0
  6. simple_module_cli/cli.py +104 -0
  7. simple_module_cli/new.py +124 -0
  8. simple_module_cli/plugins.py +56 -0
  9. simple_module_cli/recipes.py +93 -0
  10. simple_module_cli/scaffolding.py +124 -0
  11. simple_module_cli/templates/host/.env.example +20 -0
  12. simple_module_cli/templates/host/.gitignore +19 -0
  13. simple_module_cli/templates/host/Makefile +24 -0
  14. simple_module_cli/templates/host/README.md.tpl +59 -0
  15. simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +12 -0
  16. simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +60 -0
  17. simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +17 -0
  18. simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +37 -0
  19. simple_module_cli/templates/host/alembic.ini +36 -0
  20. simple_module_cli/templates/host/client_app/app.tsx +16 -0
  21. simple_module_cli/templates/host/client_app/main.tsx +2 -0
  22. simple_module_cli/templates/host/client_app/package.json.tpl +23 -0
  23. simple_module_cli/templates/host/client_app/pages/Error.tsx +13 -0
  24. simple_module_cli/templates/host/client_app/pages.ts +47 -0
  25. simple_module_cli/templates/host/client_app/styles.css +7 -0
  26. simple_module_cli/templates/host/client_app/tsconfig.json +16 -0
  27. simple_module_cli/templates/host/client_app/vite.config.ts +39 -0
  28. simple_module_cli/templates/host/main.py +27 -0
  29. simple_module_cli/templates/host/migrations/env.py +80 -0
  30. simple_module_cli/templates/host/migrations/script.py.mako +26 -0
  31. simple_module_cli/templates/host/migrations/versions/.gitkeep +1 -0
  32. simple_module_cli/templates/host/pyproject.toml.tpl +17 -0
  33. simple_module_cli/templates/host/templates/index.html +12 -0
  34. simple_module_cli/templates/module/.github/workflows/ci.yml +32 -0
  35. simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +52 -0
  36. simple_module_cli/templates/module/.gitignore +14 -0
  37. simple_module_cli/templates/module/README.md.tpl +82 -0
  38. simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  39. simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  40. simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
  41. simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +46 -0
  42. simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
  43. simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +22 -0
  44. simple_module_cli/templates/module/package.json.tpl +16 -0
  45. simple_module_cli/templates/module/pyproject.toml.tpl +39 -0
  46. simple_module_cli/templates/module/tests/__init__.py +0 -0
  47. simple_module_cli/templates/module/tests/test_module.py.tpl +27 -0
  48. simple_module_cli/templates/module/tsconfig.json.tpl +11 -0
  49. simple_module_cli/wizard.py +48 -0
  50. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/.env.example +20 -0
  51. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/.gitignore +19 -0
  52. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/Makefile +24 -0
  53. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/README.md.tpl +59 -0
  54. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +12 -0
  55. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +60 -0
  56. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +17 -0
  57. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +37 -0
  58. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/alembic.ini +36 -0
  59. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/app.tsx +16 -0
  60. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/main.tsx +2 -0
  61. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/package.json.tpl +23 -0
  62. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/pages/Error.tsx +13 -0
  63. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/pages.ts +47 -0
  64. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/styles.css +7 -0
  65. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/tsconfig.json +16 -0
  66. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/vite.config.ts +39 -0
  67. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/main.py +27 -0
  68. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/migrations/env.py +80 -0
  69. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/migrations/script.py.mako +26 -0
  70. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/migrations/versions/.gitkeep +1 -0
  71. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/pyproject.toml.tpl +17 -0
  72. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/templates/index.html +12 -0
  73. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/.github/workflows/ci.yml +32 -0
  74. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +52 -0
  75. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/.gitignore +14 -0
  76. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/README.md.tpl +82 -0
  77. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  78. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  79. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
  80. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +46 -0
  81. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
  82. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +22 -0
  83. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/package.json.tpl +16 -0
  84. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/pyproject.toml.tpl +39 -0
  85. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/tests/__init__.py +0 -0
  86. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/tests/test_module.py.tpl +27 -0
  87. simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/tsconfig.json.tpl +11 -0
  88. simple_module_cli-0.0.2.dist-info/METADATA +63 -0
  89. simple_module_cli-0.0.2.dist-info/RECORD +92 -0
  90. simple_module_cli-0.0.2.dist-info/WHEEL +4 -0
  91. simple_module_cli-0.0.2.dist-info/entry_points.txt +3 -0
  92. simple_module_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
File without changes
@@ -0,0 +1,12 @@
1
+ """Shared helpers for editing dotenv-style files at scaffold time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["set_env_key"]
6
+
7
+
8
+ def set_env_key(text: str, key: str, value: str) -> str:
9
+ """Replace or append ``KEY=VALUE`` in an env-style file body."""
10
+ lines = [ln for ln in text.splitlines() if not ln.startswith(f"{key}=")]
11
+ lines.append(f"{key}={value}")
12
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,123 @@
1
+ """Greenfield ``simple-module new`` scaffolding.
2
+
3
+ Wraps :func:`simple_module_hosting.scaffolding.create_host` with the
4
+ opinionated bits — module-list resolution from the CLI catalog, secret
5
+ generation, DB URL selection, ``pyproject.toml`` / ``package.json``
6
+ rewriting, and post-scaffold recipe application.
7
+
8
+ Lives in its own module to keep ``scaffolding.py`` under the per-file
9
+ line cap and to make the surface area of "host scaffold" vs "app
10
+ scaffold" obvious to readers.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json as _json
16
+ import secrets as _secrets
17
+ from collections.abc import Sequence
18
+ from pathlib import Path
19
+
20
+ from simple_module_cli._env import set_env_key
21
+ from simple_module_cli.case import to_kebab_case, to_pascal_case
22
+ from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps
23
+ from simple_module_cli.recipes import RECIPES, ScaffoldCtx
24
+ from simple_module_cli.scaffolding import create_host
25
+
26
+ __all__ = ["create_app_project"]
27
+
28
+ _FRAMEWORK_VERSION = "0.0.1"
29
+
30
+ _APP_PY_DEV_DEPS = [f"simple_module_test=={_FRAMEWORK_VERSION}", "pytest>=8.0"]
31
+
32
+ _APP_NPM_DEPS = {
33
+ "@simple-module-py/ui": _FRAMEWORK_VERSION,
34
+ "@simple-module-py/i18n": _FRAMEWORK_VERSION,
35
+ "react": "^19.0.0",
36
+ "react-dom": "^19.0.0",
37
+ "@inertiajs/react": "^1.0.0",
38
+ }
39
+ _APP_NPM_DEV_DEPS = {
40
+ "@simple-module-py/tsconfig": _FRAMEWORK_VERSION,
41
+ "@vitejs/plugin-react": "^5.0.0",
42
+ "typescript": "^5.6.0",
43
+ "vite": "^8.0.0",
44
+ }
45
+
46
+
47
+ def create_app_project(
48
+ target: Path,
49
+ *,
50
+ name: str,
51
+ db: str = "sqlite",
52
+ tenancy: bool = False,
53
+ selected: Sequence[str] | None = None,
54
+ ) -> None:
55
+ """Greenfield ``simple-module new`` scaffold.
56
+
57
+ Wraps :func:`create_host` with a chosen module list (defaults to the
58
+ ``standard`` preset), generates a secret, picks a DB URL, rewrites
59
+ the generated ``package.json`` / ``pyproject.toml`` to pin exact
60
+ framework versions, and applies any matching post-scaffold recipes
61
+ (e.g. the ``background_tasks`` recipe drops a Celery worker stack).
62
+ """
63
+ if target.exists() and any(target.iterdir()):
64
+ raise FileExistsError(
65
+ f"Destination {target} already exists and is non-empty; "
66
+ "choose a new path or remove its contents first."
67
+ )
68
+
69
+ chosen = list(selected) if selected is not None else list(PRESETS["standard"])
70
+ resolved, _added = expand_deps(chosen)
71
+
72
+ display_names = [to_pascal_case(CATALOG[m].display) for m in resolved]
73
+ create_host(target, name=name, modules=display_names)
74
+
75
+ py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [
76
+ f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved
77
+ ]
78
+
79
+ env_path = target / ".env.example"
80
+ env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
81
+ env_text = set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32))
82
+ env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name)))
83
+ env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false")
84
+ env_path.write_text(env_text, encoding="utf-8")
85
+
86
+ pyproject = target / "pyproject.toml"
87
+ if pyproject.exists():
88
+ text = pyproject.read_text(encoding="utf-8")
89
+ text = _inject_py_deps(text, py_deps, _APP_PY_DEV_DEPS)
90
+ pyproject.write_text(text, encoding="utf-8")
91
+
92
+ pkg_path = target / "package.json"
93
+ if pkg_path.exists():
94
+ data = _json.loads(pkg_path.read_text(encoding="utf-8"))
95
+ else:
96
+ data = {"name": to_kebab_case(name), "private": True, "type": "module"}
97
+ data.setdefault("dependencies", {}).update(_APP_NPM_DEPS)
98
+ data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS)
99
+ pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8")
100
+
101
+ ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved))
102
+ for mod_name in resolved:
103
+ recipe_key = CATALOG[mod_name].recipe
104
+ if recipe_key is not None and recipe_key in RECIPES:
105
+ RECIPES[recipe_key].apply(target, ctx)
106
+
107
+
108
+ def _db_url(db: str, slug: str) -> str:
109
+ if db == "postgres":
110
+ return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}"
111
+ return "sqlite+aiosqlite:///./app.db"
112
+
113
+
114
+ def _inject_py_deps(text: str, deps: list[str], dev_deps: list[str]) -> str:
115
+ """Replace project.dependencies + dependency-groups.dev in a pyproject.toml."""
116
+ import tomlkit
117
+
118
+ doc = tomlkit.parse(text)
119
+ project = doc.setdefault("project", tomlkit.table())
120
+ project["dependencies"] = list(deps)
121
+ groups = doc.setdefault("dependency-groups", tomlkit.table())
122
+ groups["dev"] = list(dev_deps)
123
+ return tomlkit.dumps(doc)
@@ -0,0 +1,30 @@
1
+ """Identifier case-conversion helpers used by every scaffolder.
2
+
3
+ Module/host names are accepted in any case style and normalized to the
4
+ three forms the templates need: snake_case (Python package + entry-point
5
+ key), kebab-case (PyPI slug), and PascalCase (display name in Meta).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ __all__ = ["to_kebab_case", "to_pascal_case", "to_snake_case"]
13
+
14
+
15
+ def to_snake_case(name: str) -> str:
16
+ """'MyFeature' / 'my-feature' / 'My Feature' -> 'my_feature'."""
17
+ s = re.sub(r"(?<!^)(?=[A-Z])", "_", name)
18
+ s = re.sub(r"[\s\-]+", "_", s)
19
+ return s.lower()
20
+
21
+
22
+ def to_kebab_case(name: str) -> str:
23
+ """'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug)."""
24
+ return to_snake_case(name).replace("_", "-")
25
+
26
+
27
+ def to_pascal_case(name: str) -> str:
28
+ """'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta)."""
29
+ snake = to_snake_case(name)
30
+ return "".join(part.capitalize() for part in snake.split("_") if part)
@@ -0,0 +1,107 @@
1
+ """Hardcoded catalog of installable SimpleModule modules.
2
+
3
+ Each :class:`ModuleEntry` declares the PyPI package name, a human display
4
+ name, transitive ``requires`` (other catalog keys), and an optional
5
+ ``recipe`` key for post-scaffold actions handled by :mod:`.recipes`.
6
+
7
+ :func:`expand_deps` takes a user-selected subset and returns a
8
+ topologically ordered superset including every transitive requirement,
9
+ plus the list of ``(added, required_by)`` pairs for printing back to the
10
+ user.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable
16
+ from dataclasses import dataclass, field
17
+
18
+ __all__ = ["CATALOG", "PRESETS", "ModuleEntry", "expand_deps"]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ModuleEntry:
23
+ name: str
24
+ package: str
25
+ display: str
26
+ requires: tuple[str, ...] = field(default_factory=tuple)
27
+ recipe: str | None = None
28
+
29
+
30
+ CATALOG: dict[str, ModuleEntry] = {
31
+ "auth": ModuleEntry("auth", "simple_module_auth", "Auth"),
32
+ "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)),
33
+ "permissions": ModuleEntry(
34
+ "permissions",
35
+ "simple_module_permissions",
36
+ "Permissions",
37
+ requires=("auth", "users"),
38
+ ),
39
+ "products": ModuleEntry("products", "simple_module_products", "Products"),
40
+ "dashboard": ModuleEntry(
41
+ "dashboard",
42
+ "simple_module_dashboard",
43
+ "Dashboard",
44
+ requires=("users", "products"),
45
+ ),
46
+ "settings": ModuleEntry("settings", "simple_module_settings", "Settings"),
47
+ "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"),
48
+ "file_storage": ModuleEntry(
49
+ "file_storage",
50
+ "simple_module_file_storage",
51
+ "File Storage",
52
+ requires=("settings",),
53
+ ),
54
+ "background_tasks": ModuleEntry(
55
+ "background_tasks",
56
+ "simple_module_background_tasks",
57
+ "Background Tasks",
58
+ requires=("users",),
59
+ recipe="background_tasks",
60
+ ),
61
+ "datasets": ModuleEntry(
62
+ "datasets",
63
+ "simple_module_datasets",
64
+ "Datasets",
65
+ requires=("file_storage", "background_tasks"),
66
+ ),
67
+ }
68
+
69
+
70
+ PRESETS: dict[str, tuple[str, ...]] = {
71
+ "minimal": ("users",),
72
+ "standard": ("users", "dashboard", "permissions"),
73
+ "full": tuple(CATALOG),
74
+ }
75
+
76
+
77
+ def expand_deps(selected: Iterable[str]) -> tuple[list[str], list[tuple[str, str]]]:
78
+ """Return ``(topo-ordered resolved list, [(added, required_by), ...])``.
79
+
80
+ Raises :class:`KeyError` if any input name is missing from the
81
+ catalog. The error message lists the available catalog keys so a
82
+ user typo (`--with=does_not_exist`) is self-correcting.
83
+ """
84
+ selected_list = list(selected)
85
+ for name in selected_list:
86
+ if name not in CATALOG:
87
+ available = ", ".join(sorted(CATALOG))
88
+ raise KeyError(f"unknown module: {name!r}; available: {available}")
89
+
90
+ explicit = set(selected_list)
91
+ resolved: list[str] = []
92
+ in_resolved: set[str] = set()
93
+ added: list[tuple[str, str]] = []
94
+
95
+ def _visit(name: str, required_by: str | None) -> None:
96
+ if name in in_resolved:
97
+ return
98
+ for dep in CATALOG[name].requires:
99
+ _visit(dep, required_by=name)
100
+ resolved.append(name)
101
+ in_resolved.add(name)
102
+ if required_by is not None and name not in explicit:
103
+ added.append((name, required_by))
104
+
105
+ for name in selected_list:
106
+ _visit(name, required_by=None)
107
+ return resolved, added
@@ -0,0 +1,104 @@
1
+ """Root `sm` Typer app — scaffolders + plugin mount.
2
+
3
+ Built-in commands:
4
+ sm new
5
+ sm create-host
6
+ sm create-module
7
+
8
+ Plugins discovered via the ``simple_module_cli.cli_plugins`` entry-point
9
+ group are mounted as named subgroups (e.g. ``sm host gen-pages``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Annotated
16
+
17
+ import typer
18
+
19
+ from simple_module_cli.case import to_kebab_case
20
+ from simple_module_cli.new import new_project
21
+ from simple_module_cli.plugins import discover_and_mount
22
+ from simple_module_cli.scaffolding import create_host as _create_host
23
+ from simple_module_cli.scaffolding import create_module as _create_module
24
+
25
+ app = typer.Typer(
26
+ help="SimpleModule developer CLI.",
27
+ no_args_is_help=True,
28
+ add_completion=False,
29
+ )
30
+
31
+ app.command("new")(new_project)
32
+
33
+
34
+ @app.command("create-host")
35
+ def create_host(
36
+ name: Annotated[str, typer.Argument(help="Host project name.")],
37
+ dest: Annotated[
38
+ Path | None,
39
+ typer.Option("--dest", help="Destination directory. Defaults to ./<name>."),
40
+ ] = None,
41
+ modules: Annotated[
42
+ str,
43
+ typer.Option(
44
+ "--with",
45
+ help="Comma-separated module names to declare as deps (e.g. Auth,Products).",
46
+ ),
47
+ ] = "",
48
+ ) -> None:
49
+ """Scaffold a new SimpleModule host project at ./<NAME>."""
50
+ target = dest or Path.cwd() / name
51
+ selected = [m.strip() for m in modules.split(",") if m.strip()]
52
+ try:
53
+ _create_host(target, name=name, modules=selected)
54
+ except FileExistsError as exc:
55
+ typer.echo(f"ERROR: {exc}", err=True)
56
+ raise typer.Exit(code=1) from exc
57
+
58
+ typer.echo(f"Created host '{name}' at {target}")
59
+ if selected:
60
+ typer.echo(f"Declared modules: {', '.join(selected)}")
61
+ typer.echo("\nNext steps:")
62
+ typer.echo(f" cd {target}")
63
+ typer.echo(" uv sync")
64
+ typer.echo(" cp .env.example .env")
65
+ typer.echo(' alembic revision --autogenerate -m "initial schema"')
66
+ typer.echo(" alembic upgrade head")
67
+ typer.echo(" python main.py")
68
+
69
+
70
+ @app.command("create-module")
71
+ def create_module(
72
+ name: Annotated[str, typer.Argument(help="Module name (any case).")],
73
+ dest: Annotated[
74
+ Path | None,
75
+ typer.Option("--dest", help="Destination dir. Defaults to ./simple_module_<name>."),
76
+ ] = None,
77
+ ) -> None:
78
+ """Scaffold a publishable SimpleModule module package."""
79
+ slug = to_kebab_case(name)
80
+ package = slug.replace("-", "_")
81
+ target = dest or Path.cwd() / f"simple_module_{package}"
82
+ try:
83
+ _create_module(target, name=name)
84
+ except FileExistsError as exc:
85
+ typer.echo(f"ERROR: {exc}", err=True)
86
+ raise typer.Exit(code=1) from exc
87
+
88
+ typer.echo(f"Created module 'simple_module_{package}' at {target}")
89
+ typer.echo("\nNext steps:")
90
+ typer.echo(f" cd {target}")
91
+ typer.echo(" uv sync --extra dev")
92
+ typer.echo(" uv run pytest")
93
+
94
+
95
+ discover_and_mount(app)
96
+
97
+
98
+ def main() -> None:
99
+ """Entry point for the `sm` console script."""
100
+ app()
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
@@ -0,0 +1,124 @@
1
+ """``sm new`` Typer command — flag-driven or interactive scaffolder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from simple_module_cli.app_project import create_app_project
13
+ from simple_module_cli.catalog import PRESETS, expand_deps
14
+ from simple_module_cli.wizard import run_wizard
15
+
16
+ __all__ = ["new_project"]
17
+
18
+
19
+ class Db(StrEnum):
20
+ sqlite = "sqlite"
21
+ postgres = "postgres"
22
+
23
+
24
+ class Preset(StrEnum):
25
+ minimal = "minimal"
26
+ standard = "standard"
27
+ full = "full"
28
+
29
+
30
+ def new_project(
31
+ name: Annotated[str, typer.Argument(help="App name (used for directory + package).")],
32
+ dest: Annotated[
33
+ Path | None,
34
+ typer.Option("--dest", help="Destination directory. Defaults to ./<name>."),
35
+ ] = None,
36
+ db: Annotated[
37
+ Db,
38
+ typer.Option("--db", help="Database backend to configure in .env.example."),
39
+ ] = Db.sqlite,
40
+ tenancy: Annotated[
41
+ bool,
42
+ typer.Option("--tenancy/--no-tenancy", help="Enable the multi-tenant middleware."),
43
+ ] = False,
44
+ preset: Annotated[
45
+ Preset | None,
46
+ typer.Option("--preset", help="Module preset. Combine with --with."),
47
+ ] = None,
48
+ extra: Annotated[
49
+ str,
50
+ typer.Option(
51
+ "--with",
52
+ help="Comma-separated extra modules (e.g. background_tasks,file_storage).",
53
+ ),
54
+ ] = "",
55
+ yes: Annotated[
56
+ bool,
57
+ typer.Option("--yes", "-y", help="Skip interactive prompts; accept defaults."),
58
+ ] = False,
59
+ no_install: Annotated[
60
+ bool,
61
+ typer.Option(
62
+ "--no-install",
63
+ help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.",
64
+ ),
65
+ ] = False,
66
+ ) -> None:
67
+ """Scaffold a new SimpleModule app, optionally with background jobs."""
68
+ target = dest or Path.cwd() / name
69
+ extra_list = [m.strip() for m in extra.split(",") if m.strip()]
70
+ flag_driven = preset is not None or bool(extra_list)
71
+
72
+ if yes or flag_driven:
73
+ chosen = list(PRESETS[(preset or Preset.standard).value]) + extra_list
74
+ try:
75
+ resolved, added = expand_deps(chosen)
76
+ except KeyError as exc:
77
+ typer.echo(f"ERROR: {exc}", err=True)
78
+ raise typer.Exit(code=1) from exc
79
+ for added_name, required_by in added:
80
+ typer.echo(f"Added {added_name} (required by {required_by})")
81
+ db_final, tenancy_final = db.value, tenancy
82
+ else:
83
+ try:
84
+ db_final, tenancy_final, resolved = run_wizard(
85
+ default_db=db.value, default_tenancy=tenancy
86
+ )
87
+ except typer.Abort:
88
+ typer.echo("Aborted.", err=True)
89
+ raise typer.Exit(code=1) from None
90
+
91
+ try:
92
+ create_app_project(target, name=name, db=db_final, tenancy=tenancy_final, selected=resolved)
93
+ except FileExistsError as exc:
94
+ typer.echo(f"ERROR: {exc}", err=True)
95
+ raise typer.Exit(code=1) from exc
96
+
97
+ typer.echo(f"Created app '{name}' at {target}")
98
+ typer.echo(f"Modules: {', '.join(resolved)}")
99
+ typer.echo("\nNext steps:")
100
+ typer.echo(f" cd {target}")
101
+ if no_install:
102
+ typer.echo(" uv sync")
103
+ typer.echo(" npm install")
104
+ typer.echo(" alembic upgrade head")
105
+ typer.echo(" make dev")
106
+ if "background_tasks" in resolved:
107
+ typer.echo(" docker compose up -d redis worker beat # background jobs")
108
+ return
109
+
110
+ typer.echo("Installing dependencies...")
111
+ for cmd in (["uv", "sync"], ["npm", "install"]):
112
+ result = subprocess.run(cmd, cwd=target, check=False)
113
+ if result.returncode != 0:
114
+ typer.echo(
115
+ f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); "
116
+ "finish setup manually.",
117
+ err=True,
118
+ )
119
+ return
120
+
121
+ subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False)
122
+ typer.echo("\nSetup complete. Run `make dev` in the new directory.")
123
+ if "background_tasks" in resolved:
124
+ typer.echo("For background jobs, also run: docker compose up -d redis worker beat")
@@ -0,0 +1,56 @@
1
+ """Plugin discovery for ``sm`` via the ``simple_module_cli.cli_plugins`` group.
2
+
3
+ Each entry-point's value (``module:attr``) must resolve to a
4
+ :class:`typer.Typer` instance. The entry-point name becomes the
5
+ subcommand namespace under ``sm`` (e.g. ``sm host gen-pages``).
6
+
7
+ Failed loads (broken import, wrong type) print one line to stderr and
8
+ are skipped — ``sm`` keeps working with whatever else loads.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from collections.abc import Iterator
15
+ from importlib.metadata import EntryPoint, entry_points
16
+
17
+ import typer
18
+
19
+ __all__ = ["discover_and_mount"]
20
+
21
+ _GROUP = "simple_module_cli.cli_plugins"
22
+
23
+
24
+ def _iter_plugin_entries() -> Iterator[EntryPoint]:
25
+ """Indirection point for tests to inject fake entry points."""
26
+ yield from entry_points(group=_GROUP)
27
+
28
+
29
+ def discover_and_mount(root: typer.Typer) -> None:
30
+ """Mount every installed plugin under its entry-point name."""
31
+ seen: set[str] = set()
32
+ for entry in _iter_plugin_entries():
33
+ if entry.name in seen:
34
+ print(
35
+ f"[simple-module] duplicate plugin subgroup '{entry.name}' "
36
+ f"from {entry.value!r}; keeping first registration.",
37
+ file=sys.stderr,
38
+ )
39
+ continue
40
+ try:
41
+ plugin_app = entry.load()
42
+ except Exception as exc:
43
+ print(
44
+ f"[simple-module] failed to load plugin '{entry.name}' ({entry.value}): {exc}",
45
+ file=sys.stderr,
46
+ )
47
+ continue
48
+ if not isinstance(plugin_app, typer.Typer):
49
+ print(
50
+ f"[simple-module] plugin '{entry.name}' did not export a "
51
+ f"typer.Typer instance (got {type(plugin_app).__name__}); skipping.",
52
+ file=sys.stderr,
53
+ )
54
+ continue
55
+ root.add_typer(plugin_app, name=entry.name)
56
+ seen.add(entry.name)
@@ -0,0 +1,93 @@
1
+ """Per-module post-scaffold recipes.
2
+
3
+ A recipe is invoked by ``sm new`` after the base host scaffold lands. It
4
+ performs module-specific actions (write helper scripts, append Make
5
+ targets, drop a docker-compose stack). The framework layer is kept free
6
+ of devex concerns — recipes know about Makefiles and compose, framework
7
+ scaffolding does not.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.resources
13
+ import shutil
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Protocol
18
+
19
+ from simple_module_cli._env import set_env_key
20
+
21
+ __all__ = [
22
+ "RECIPES",
23
+ "BackgroundTasksRecipe",
24
+ "Recipe",
25
+ "ScaffoldCtx",
26
+ ]
27
+
28
+ _BG_BROKER_ENV_KEY = "SM_BG_TASKS_BROKER_URL"
29
+ _BG_BROKER_DEFAULT = "redis://redis:6379/0"
30
+ _MAKEFILE_MARKER = "# --- background_tasks --"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ScaffoldCtx:
35
+ name: str
36
+ db: str
37
+ tenancy: bool
38
+ selected: Sequence[str]
39
+
40
+
41
+ class Recipe(Protocol):
42
+ def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ...
43
+
44
+
45
+ def _optional_template_root(name: str) -> Path:
46
+ """Resolve ``templates/host/_optional/<name>/`` from package data."""
47
+ base = importlib.resources.files("simple_module_cli")
48
+ return Path(str(base / "templates" / "host" / "_optional" / name))
49
+
50
+
51
+ class BackgroundTasksRecipe:
52
+ """Lays down run_worker.py + compose + Dockerfile + Make targets."""
53
+
54
+ def apply(self, target: Path, ctx: ScaffoldCtx) -> None:
55
+ templates = _optional_template_root("background_tasks")
56
+
57
+ run_worker_dest = target / "scripts" / "run_worker.py"
58
+ compose_dest = target / "docker-compose.yml"
59
+ dockerfile_dest = target / "docker" / "worker.Dockerfile"
60
+
61
+ for path in (run_worker_dest, compose_dest, dockerfile_dest):
62
+ if path.exists():
63
+ raise FileExistsError(
64
+ f"{path} already exists — refusing to clobber. "
65
+ "Remove the file or run `sm new` against an empty directory."
66
+ )
67
+
68
+ run_worker_dest.parent.mkdir(parents=True, exist_ok=True)
69
+ shutil.copy2(templates / "run_worker.py", run_worker_dest)
70
+
71
+ shutil.copy2(templates / "docker-compose.yml", compose_dest)
72
+
73
+ dockerfile_dest.parent.mkdir(parents=True, exist_ok=True)
74
+ shutil.copy2(templates / "worker.Dockerfile", dockerfile_dest)
75
+
76
+ env_path = target / ".env.example"
77
+ env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
78
+ env_path.write_text(
79
+ set_env_key(env_text, _BG_BROKER_ENV_KEY, _BG_BROKER_DEFAULT),
80
+ encoding="utf-8",
81
+ )
82
+
83
+ makefile_path = target / "Makefile"
84
+ snippet = (templates / "Makefile.snippet").read_text(encoding="utf-8")
85
+ existing = makefile_path.read_text(encoding="utf-8") if makefile_path.exists() else ""
86
+ if _MAKEFILE_MARKER not in existing:
87
+ sep = "" if existing.endswith("\n") or not existing else "\n"
88
+ makefile_path.write_text(existing + sep + snippet, encoding="utf-8")
89
+
90
+
91
+ RECIPES: dict[str, Recipe] = {
92
+ "background_tasks": BackgroundTasksRecipe(),
93
+ }