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.
- simple_module_cli/__init__.py +0 -0
- simple_module_cli/_env.py +12 -0
- simple_module_cli/app_project.py +123 -0
- simple_module_cli/case.py +30 -0
- simple_module_cli/catalog.py +107 -0
- simple_module_cli/cli.py +104 -0
- simple_module_cli/new.py +124 -0
- simple_module_cli/plugins.py +56 -0
- simple_module_cli/recipes.py +93 -0
- simple_module_cli/scaffolding.py +124 -0
- simple_module_cli/templates/host/.env.example +20 -0
- simple_module_cli/templates/host/.gitignore +19 -0
- simple_module_cli/templates/host/Makefile +24 -0
- simple_module_cli/templates/host/README.md.tpl +59 -0
- simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +12 -0
- simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +60 -0
- simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +17 -0
- simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +37 -0
- simple_module_cli/templates/host/alembic.ini +36 -0
- simple_module_cli/templates/host/client_app/app.tsx +16 -0
- simple_module_cli/templates/host/client_app/main.tsx +2 -0
- simple_module_cli/templates/host/client_app/package.json.tpl +23 -0
- simple_module_cli/templates/host/client_app/pages/Error.tsx +13 -0
- simple_module_cli/templates/host/client_app/pages.ts +47 -0
- simple_module_cli/templates/host/client_app/styles.css +7 -0
- simple_module_cli/templates/host/client_app/tsconfig.json +16 -0
- simple_module_cli/templates/host/client_app/vite.config.ts +39 -0
- simple_module_cli/templates/host/main.py +27 -0
- simple_module_cli/templates/host/migrations/env.py +80 -0
- simple_module_cli/templates/host/migrations/script.py.mako +26 -0
- simple_module_cli/templates/host/migrations/versions/.gitkeep +1 -0
- simple_module_cli/templates/host/pyproject.toml.tpl +17 -0
- simple_module_cli/templates/host/templates/index.html +12 -0
- simple_module_cli/templates/module/.github/workflows/ci.yml +32 -0
- simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +52 -0
- simple_module_cli/templates/module/.gitignore +14 -0
- simple_module_cli/templates/module/README.md.tpl +82 -0
- simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
- simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
- simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
- simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +46 -0
- simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
- simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +22 -0
- simple_module_cli/templates/module/package.json.tpl +16 -0
- simple_module_cli/templates/module/pyproject.toml.tpl +39 -0
- simple_module_cli/templates/module/tests/__init__.py +0 -0
- simple_module_cli/templates/module/tests/test_module.py.tpl +27 -0
- simple_module_cli/templates/module/tsconfig.json.tpl +11 -0
- simple_module_cli/wizard.py +48 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/.env.example +20 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/.gitignore +19 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/Makefile +24 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/README.md.tpl +59 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +12 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +60 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +17 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +37 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/alembic.ini +36 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/app.tsx +16 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/main.tsx +2 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/package.json.tpl +23 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/pages/Error.tsx +13 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/pages.ts +47 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/styles.css +7 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/tsconfig.json +16 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/client_app/vite.config.ts +39 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/main.py +27 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/migrations/env.py +80 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/migrations/script.py.mako +26 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/migrations/versions/.gitkeep +1 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/pyproject.toml.tpl +17 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/host/templates/index.html +12 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/.github/workflows/ci.yml +32 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +52 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/.gitignore +14 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/README.md.tpl +82 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +46 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +22 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/package.json.tpl +16 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/pyproject.toml.tpl +39 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/tests/__init__.py +0 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/tests/test_module.py.tpl +27 -0
- simple_module_cli-0.0.2.data/data/simple_module_cli/templates/module/tsconfig.json.tpl +11 -0
- simple_module_cli-0.0.2.dist-info/METADATA +63 -0
- simple_module_cli-0.0.2.dist-info/RECORD +92 -0
- simple_module_cli-0.0.2.dist-info/WHEEL +4 -0
- simple_module_cli-0.0.2.dist-info/entry_points.txt +3 -0
- 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
|
simple_module_cli/cli.py
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()
|
simple_module_cli/new.py
ADDED
|
@@ -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
|
+
}
|