simple-module-cli 0.0.12__tar.gz → 0.0.14__tar.gz
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-0.0.12 → simple_module_cli-0.0.14}/PKG-INFO +1 -1
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/pyproject.toml +1 -1
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/app_project.py +38 -20
- simple_module_cli-0.0.14/simple_module_cli/case.py +84 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/new.py +41 -4
- simple_module_cli-0.0.14/simple_module_cli/scaffolding.py +222 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/package.json.tpl +1 -1
- simple_module_cli-0.0.14/simple_module_cli/templates/host/client_app/pages/Landing.tsx +78 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/vite.config.ts +95 -24
- simple_module_cli-0.0.14/simple_module_cli/templates/host/main.py +46 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/migrations/env.py +11 -1
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/pyproject.toml.tpl +1 -1
- simple_module_cli-0.0.14/simple_module_cli/templates/host/routes.py +28 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -1
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/README.md.tpl +2 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/package.json.tpl +1 -1
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/pyproject.toml.tpl +1 -1
- simple_module_cli-0.0.14/tests/test_case.py +63 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_cli_new.py +1 -0
- simple_module_cli-0.0.14/tests/test_cli_new_dest_tolerance.py +96 -0
- simple_module_cli-0.0.14/tests/test_cli_new_regressions.py +272 -0
- simple_module_cli-0.0.14/tests/test_env_helper.py +54 -0
- simple_module_cli-0.0.14/tests/test_scaffold_rollback.py +73 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_scaffolding_host.py +98 -0
- simple_module_cli-0.0.12/simple_module_cli/case.py +0 -38
- simple_module_cli-0.0.12/simple_module_cli/scaffolding.py +0 -156
- simple_module_cli-0.0.12/simple_module_cli/templates/host/main.py +0 -27
- simple_module_cli-0.0.12/tests/test_cli_new_regressions.py +0 -61
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/.gitignore +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/LICENSE +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/README.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/__init__.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/_env.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/catalog.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/cli.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/package_update.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/plugins.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/recipes.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/README.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/skills_cmd.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/.env.example +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/.gitignore +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/Makefile +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/README.md.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/alembic.ini +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/styles.css +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/templates/index.html +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/.gitignore +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/README.md.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/package.json.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/.env.example +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/.gitignore +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/Makefile +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/simple_module_cli/wizard.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_build_packaging.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_cli_catalog.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_cli_package_update.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_cli_recipes.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_cli_wizard.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_no_framework_deps.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_plugin_discovery.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_scaffolding_module.py +0 -0
- {simple_module_cli-0.0.12 → simple_module_cli-0.0.14}/tests/test_skills_cmd.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.14
|
|
4
4
|
Summary: Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host.
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import json as _json
|
|
17
17
|
import secrets as _secrets
|
|
18
|
+
import shutil as _shutil
|
|
18
19
|
from collections.abc import Sequence
|
|
19
20
|
from importlib.metadata import PackageNotFoundError
|
|
20
21
|
from importlib.metadata import version as _pkg_version
|
|
@@ -26,6 +27,7 @@ from simple_module_cli.case import to_kebab_case, to_pascal_case
|
|
|
26
27
|
from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps
|
|
27
28
|
from simple_module_cli.recipes import RECIPES, ScaffoldCtx
|
|
28
29
|
from simple_module_cli.scaffolding import (
|
|
30
|
+
SAFE_PRESERVED_NAMES,
|
|
29
31
|
_module_to_pypi_name,
|
|
30
32
|
create_host,
|
|
31
33
|
create_module,
|
|
@@ -55,7 +57,14 @@ def _resolve_framework_version() -> str:
|
|
|
55
57
|
|
|
56
58
|
_FRAMEWORK_VERSION = _resolve_framework_version()
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
# Pin ``simple_module_cli`` as a dev dep so ``uv run smpy`` resolves to the
|
|
61
|
+
# project venv. The global ``uv tool`` install runs in its own isolated venv
|
|
62
|
+
# that can't see the project's plugin entry points (issue #134).
|
|
63
|
+
_APP_PY_DEV_DEPS = [
|
|
64
|
+
f"simple_module_test=={_FRAMEWORK_VERSION}",
|
|
65
|
+
f"simple_module_cli=={_FRAMEWORK_VERSION}",
|
|
66
|
+
"pytest>=8.0",
|
|
67
|
+
]
|
|
59
68
|
|
|
60
69
|
_APP_NPM_DEPS = {
|
|
61
70
|
"@simple-module-py/ui": _FRAMEWORK_VERSION,
|
|
@@ -89,35 +98,39 @@ def create_app_project(
|
|
|
89
98
|
tenancy: bool = False,
|
|
90
99
|
selected: Sequence[str] | None = None,
|
|
91
100
|
flat: bool = False,
|
|
92
|
-
) ->
|
|
101
|
+
) -> tuple[Path, list[Path]]:
|
|
93
102
|
"""Greenfield ``simple-module new`` scaffold.
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
``
|
|
104
|
+
Workspace mode (default) lays down a uv + npm workspace at ``target/``
|
|
105
|
+
with the host under ``target/host/`` and a sample module under
|
|
106
|
+
``target/modules/hello/``. Flat mode keeps the legacy single-host layout.
|
|
107
|
+
Tolerates ``SAFE_PRESERVED_NAMES`` at ``target`` (leftovers from
|
|
108
|
+
``git init`` / ``gh repo create`` / IDE setup); other pre-existing
|
|
109
|
+
entries raise ``FileExistsError``.
|
|
100
110
|
|
|
101
|
-
|
|
102
|
-
``
|
|
103
|
-
|
|
104
|
-
(e.g. the ``background_tasks`` recipe drops a Celery worker stack).
|
|
111
|
+
Returns ``(host_dir, preserved)`` — the host directory (``target`` in
|
|
112
|
+
flat mode, ``target/host`` in workspace mode) and the paths whose
|
|
113
|
+
scaffold copy was skipped because the user already had one.
|
|
105
114
|
"""
|
|
106
|
-
if target.exists() and any(target.iterdir()):
|
|
107
|
-
raise FileExistsError(
|
|
108
|
-
f"Destination {target} already exists and is non-empty; "
|
|
109
|
-
"choose a new path or remove its contents first."
|
|
110
|
-
)
|
|
111
|
-
|
|
112
115
|
chosen = list(selected) if selected is not None else list(PRESETS["standard"])
|
|
113
116
|
resolved, _added = expand_deps(chosen)
|
|
114
117
|
|
|
115
118
|
display_names = [to_pascal_case(CATALOG[m].display) for m in resolved]
|
|
116
119
|
host_dir = target if flat else target / "host"
|
|
120
|
+
preserved: list[Path] = []
|
|
117
121
|
if not flat:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
preserved.extend(
|
|
123
|
+
create_workspace(target, name=name, preserve_existing=SAFE_PRESERVED_NAMES)
|
|
124
|
+
)
|
|
125
|
+
preserved.extend(
|
|
126
|
+
create_host(
|
|
127
|
+
host_dir,
|
|
128
|
+
name=name,
|
|
129
|
+
modules=display_names,
|
|
130
|
+
framework_version=_FRAMEWORK_VERSION,
|
|
131
|
+
preserve_existing=SAFE_PRESERVED_NAMES if flat else frozenset(),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
121
134
|
if not flat:
|
|
122
135
|
_strip_workspace_owned_files(host_dir)
|
|
123
136
|
|
|
@@ -169,6 +182,8 @@ def create_app_project(
|
|
|
169
182
|
if recipe_key is not None and recipe_key in RECIPES:
|
|
170
183
|
RECIPES[recipe_key].apply(target, ctx)
|
|
171
184
|
|
|
185
|
+
return host_dir, preserved
|
|
186
|
+
|
|
172
187
|
|
|
173
188
|
def _strip_workspace_owned_files(host_dir: Path) -> None:
|
|
174
189
|
"""Drop host copies of files the workspace root owns in workspace mode."""
|
|
@@ -181,6 +196,9 @@ def _scaffold_sample_module(target: Path) -> None:
|
|
|
181
196
|
if sample_dest.exists():
|
|
182
197
|
return
|
|
183
198
|
create_module(sample_dest, name=_SAMPLE_MODULE_NAME)
|
|
199
|
+
# GitHub only reads workflows from the repo root, so the template's
|
|
200
|
+
# .github/ is dead inside a workspace.
|
|
201
|
+
_shutil.rmtree(sample_dest / ".github")
|
|
184
202
|
_pin_sample_module_deps(sample_dest)
|
|
185
203
|
_seed_static_dist_placeholder(sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist")
|
|
186
204
|
|
|
@@ -0,0 +1,84 @@
|
|
|
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__ = [
|
|
13
|
+
"InvalidScaffoldNameError",
|
|
14
|
+
"to_kebab_case",
|
|
15
|
+
"to_pascal_case",
|
|
16
|
+
"to_snake_case",
|
|
17
|
+
"validate_scaffold_name",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidScaffoldNameError(ValueError):
|
|
22
|
+
"""Raised when a user-supplied scaffold name can't be canonicalized.
|
|
23
|
+
|
|
24
|
+
Names that mix case, lead with a digit, contain spaces or non
|
|
25
|
+
``[a-z0-9_-]`` characters, or mix ``_`` and ``-`` separators within
|
|
26
|
+
the same identifier are ambiguous: the scaffolder would have to guess
|
|
27
|
+
a canonical form, and the directory name would diverge from the
|
|
28
|
+
READMEs that reference it. Reject up front instead.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def to_snake_case(name: str) -> str:
|
|
33
|
+
"""'MyFeature' / 'my-feature' / 'My Feature' / 'URLPath' -> 'my_feature' / 'url_path'.
|
|
34
|
+
|
|
35
|
+
Handles acronyms by treating ``Acronym|Word`` and ``word|Capital`` as
|
|
36
|
+
boundaries: ``URLPath`` -> ``url_path``, ``APIClient`` -> ``api_client``,
|
|
37
|
+
``HTTPServer2`` -> ``http_server2``. The single-pass ``(?=[A-Z])`` form
|
|
38
|
+
that preceded this would emit ``u_r_l_path`` and propagate the typo
|
|
39
|
+
into the PyPI slug + display name.
|
|
40
|
+
"""
|
|
41
|
+
s = re.sub(r"[\s\-]+", "_", name)
|
|
42
|
+
s = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", s)
|
|
43
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
|
44
|
+
# Collapse runs of underscores that the boundary regexes can introduce
|
|
45
|
+
# when the input already contained a separator (e.g. ``My Feature`` →
|
|
46
|
+
# ``My_Feature`` → ``My__Feature``). Without this the PyPI slug emits a
|
|
47
|
+
# double hyphen.
|
|
48
|
+
s = re.sub(r"_+", "_", s)
|
|
49
|
+
return s.lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def to_kebab_case(name: str) -> str:
|
|
53
|
+
"""'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug)."""
|
|
54
|
+
return to_snake_case(name).replace("_", "-")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def to_pascal_case(name: str) -> str:
|
|
58
|
+
"""'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta)."""
|
|
59
|
+
snake = to_snake_case(name)
|
|
60
|
+
return "".join(part.capitalize() for part in snake.split("_") if part)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_scaffold_name(name: str) -> str:
|
|
67
|
+
"""Reject ambiguous host/module names; return the canonical display form.
|
|
68
|
+
|
|
69
|
+
A valid name is lowercase alphanumerics with at most one separator
|
|
70
|
+
style — either ``_`` or ``-``, not both. Examples:
|
|
71
|
+
|
|
72
|
+
* ``simple_module_chat`` -> ``simple_module_chat``
|
|
73
|
+
* ``simple-module-chat`` -> ``simple-module-chat``
|
|
74
|
+
* ``MyApp`` -> rejected (mixed case is ambiguous: ``my-app`` or ``my_app``?)
|
|
75
|
+
* ``1chat`` -> rejected (must start with a letter)
|
|
76
|
+
* ``foo_bar-baz`` -> rejected (mixed separators leave no canonical form)
|
|
77
|
+
"""
|
|
78
|
+
if not name or not _VALID_NAME_RE.match(name) or ("_" in name and "-" in name):
|
|
79
|
+
raise InvalidScaffoldNameError(
|
|
80
|
+
f"{name!r} is not a valid scaffold name. Use lowercase letters and "
|
|
81
|
+
"digits with at most one separator style (all '_' or all '-'), "
|
|
82
|
+
"starting with a letter. e.g. 'my_app', 'my-app', or 'myapp'."
|
|
83
|
+
)
|
|
84
|
+
return name
|
|
@@ -11,11 +11,14 @@ from typing import Annotated
|
|
|
11
11
|
import typer
|
|
12
12
|
|
|
13
13
|
from simple_module_cli.app_project import create_app_project
|
|
14
|
+
from simple_module_cli.case import InvalidScaffoldNameError, to_kebab_case, validate_scaffold_name
|
|
14
15
|
from simple_module_cli.catalog import PRESETS, expand_deps
|
|
15
16
|
from simple_module_cli.wizard import run_wizard
|
|
16
17
|
|
|
17
18
|
__all__ = ["new_project"]
|
|
18
19
|
|
|
20
|
+
_ALEMBIC = ("uv", "run", "alembic")
|
|
21
|
+
|
|
19
22
|
|
|
20
23
|
class Db(StrEnum):
|
|
21
24
|
sqlite = "sqlite"
|
|
@@ -61,7 +64,7 @@ def new_project(
|
|
|
61
64
|
bool,
|
|
62
65
|
typer.Option(
|
|
63
66
|
"--no-install",
|
|
64
|
-
help="Skip 'uv sync' / 'npm install' /
|
|
67
|
+
help=("Skip 'uv sync' / 'npm install' / initial alembic migration after scaffolding."),
|
|
65
68
|
),
|
|
66
69
|
] = False,
|
|
67
70
|
flat: Annotated[
|
|
@@ -76,6 +79,14 @@ def new_project(
|
|
|
76
79
|
] = False,
|
|
77
80
|
) -> None:
|
|
78
81
|
"""Scaffold a new SimpleModule app, optionally with background jobs."""
|
|
82
|
+
try:
|
|
83
|
+
validate_scaffold_name(name)
|
|
84
|
+
except InvalidScaffoldNameError as exc:
|
|
85
|
+
typer.echo(f"ERROR: {exc}", err=True)
|
|
86
|
+
raise typer.Exit(code=1) from exc
|
|
87
|
+
pypi_name = to_kebab_case(name)
|
|
88
|
+
if pypi_name != name:
|
|
89
|
+
typer.echo(f"Normalizing PyPI name to {pypi_name!r}.")
|
|
79
90
|
target = dest or Path.cwd() / name
|
|
80
91
|
extra_list = [m.strip() for m in extra.split(",") if m.strip()]
|
|
81
92
|
flag_driven = preset is not None or bool(extra_list)
|
|
@@ -100,7 +111,7 @@ def new_project(
|
|
|
100
111
|
raise typer.Exit(code=1) from None
|
|
101
112
|
|
|
102
113
|
try:
|
|
103
|
-
create_app_project(
|
|
114
|
+
host_dir, preserved = create_app_project(
|
|
104
115
|
target,
|
|
105
116
|
name=name,
|
|
106
117
|
db=db_final,
|
|
@@ -114,12 +125,21 @@ def new_project(
|
|
|
114
125
|
|
|
115
126
|
typer.echo(f"Created app '{name}' at {target}")
|
|
116
127
|
typer.echo(f"Modules: {', '.join(resolved)}")
|
|
128
|
+
if preserved:
|
|
129
|
+
typer.echo(
|
|
130
|
+
"\nPreserved existing files (scaffold's versions were skipped — "
|
|
131
|
+
"merge by hand if you want their contents):"
|
|
132
|
+
)
|
|
133
|
+
for path in preserved:
|
|
134
|
+
rel = path.relative_to(target) if path.is_relative_to(target) else path
|
|
135
|
+
typer.echo(f" {rel}")
|
|
117
136
|
typer.echo("\nNext steps:")
|
|
118
137
|
typer.echo(f" cd {target}")
|
|
119
138
|
if no_install:
|
|
120
139
|
typer.echo(" uv sync")
|
|
121
140
|
typer.echo(" npm install")
|
|
122
|
-
typer.echo(
|
|
141
|
+
typer.echo(' make migration msg="initial schema"')
|
|
142
|
+
typer.echo(" make migrate")
|
|
123
143
|
typer.echo(" make dev")
|
|
124
144
|
if "background_tasks" in resolved:
|
|
125
145
|
typer.echo(" docker compose up -d redis worker beat # background jobs")
|
|
@@ -143,7 +163,24 @@ def new_project(
|
|
|
143
163
|
)
|
|
144
164
|
return
|
|
145
165
|
|
|
146
|
-
|
|
166
|
+
_bootstrap_initial_migration(host_dir)
|
|
167
|
+
subprocess.run([*_ALEMBIC, "upgrade", "head"], cwd=host_dir, check=False)
|
|
147
168
|
typer.echo("\nSetup complete. Run `make dev` in the new directory.")
|
|
148
169
|
if "background_tasks" in resolved:
|
|
149
170
|
typer.echo("For background jobs, also run: docker compose up -d redis worker beat")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _bootstrap_initial_migration(host_dir: Path) -> None:
|
|
174
|
+
"""Autogenerate the baseline migration if the scaffold ships none.
|
|
175
|
+
|
|
176
|
+
Without a real revision, ``alembic upgrade head`` is a silent no-op
|
|
177
|
+
against an empty schema — the bundled modules' tables never exist.
|
|
178
|
+
"""
|
|
179
|
+
versions_dir = host_dir / "migrations" / "versions"
|
|
180
|
+
if any(p.name != "__init__.py" for p in versions_dir.glob("*.py")):
|
|
181
|
+
return
|
|
182
|
+
subprocess.run(
|
|
183
|
+
[*_ALEMBIC, "revision", "--autogenerate", "-m", "initial schema"],
|
|
184
|
+
cwd=host_dir,
|
|
185
|
+
check=False,
|
|
186
|
+
)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Host + module scaffolding via package-data templates.
|
|
2
|
+
|
|
3
|
+
* :func:`create_workspace` materializes the project-root workspace shell
|
|
4
|
+
(top-level ``pyproject.toml`` / ``package.json`` / ``Makefile``) from
|
|
5
|
+
``simple_module_cli/templates/workspace/``.
|
|
6
|
+
* :func:`create_host` materializes a new host project from the templates
|
|
7
|
+
under ``simple_module_cli/templates/host/``.
|
|
8
|
+
* :func:`create_module` materializes a new module package from
|
|
9
|
+
``simple_module_cli/templates/module/``.
|
|
10
|
+
|
|
11
|
+
The frontend pages manifest + per-module JS dep discovery live in
|
|
12
|
+
:mod:`simple_module_hosting.manifest` (those need module-discovery and
|
|
13
|
+
stay in hosting).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib.resources
|
|
19
|
+
import logging
|
|
20
|
+
import shutil
|
|
21
|
+
from collections.abc import Mapping, Sequence
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from simple_module_cli.case import (
|
|
25
|
+
to_kebab_case,
|
|
26
|
+
to_pascal_case,
|
|
27
|
+
to_snake_case,
|
|
28
|
+
validate_scaffold_name,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"SAFE_PRESERVED_NAMES",
|
|
33
|
+
"create_host",
|
|
34
|
+
"create_module",
|
|
35
|
+
"create_workspace",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
_TEMPLATES_PACKAGE = "simple_module_cli.templates"
|
|
41
|
+
_PACKAGE_PATH_TOKEN = "__PACKAGE__"
|
|
42
|
+
|
|
43
|
+
# Pre-existing entries we tolerate at a scaffold target — typical leftovers
|
|
44
|
+
# from ``git init`` / ``gh repo create`` / IDE setup.
|
|
45
|
+
SAFE_PRESERVED_NAMES = frozenset(
|
|
46
|
+
{".git", ".gitignore", ".gitattributes", ".editorconfig", ".DS_Store"}
|
|
47
|
+
| {".claude", ".vscode", ".idea"}
|
|
48
|
+
| {"README", "README.md", "README.rst"}
|
|
49
|
+
| {"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"}
|
|
50
|
+
| {"CHANGELOG.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md"}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _module_to_pypi_name(name: str) -> str:
|
|
55
|
+
return f"simple_module_{name.lower()}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _iter_template_files(template_root: Path):
|
|
59
|
+
"""Yield every file under ``template_root``. Skips ``_optional/`` paths."""
|
|
60
|
+
for path in template_root.rglob("*"):
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
continue
|
|
63
|
+
if "_optional" in path.relative_to(template_root).parts:
|
|
64
|
+
continue
|
|
65
|
+
yield path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _require_empty_dest(dest: Path, *, preserve_existing: frozenset[str] = frozenset()) -> None:
|
|
69
|
+
"""Refuse a non-empty destination unless every top-level entry is allowed.
|
|
70
|
+
|
|
71
|
+
``preserve_existing`` is matched against the *name* of each top-level entry,
|
|
72
|
+
so callers can permit common pre-existing files (``.git``, ``README.md``,
|
|
73
|
+
...) without silently overwriting unrelated user content.
|
|
74
|
+
"""
|
|
75
|
+
if dest.exists():
|
|
76
|
+
unexpected = sorted(p.name for p in dest.iterdir() if p.name not in preserve_existing)
|
|
77
|
+
if unexpected:
|
|
78
|
+
raise FileExistsError(
|
|
79
|
+
f"Destination {dest} exists and contains files that would collide "
|
|
80
|
+
f"with the scaffold: {', '.join(unexpected)}. "
|
|
81
|
+
"Move them aside or choose another path."
|
|
82
|
+
)
|
|
83
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_template_root(subdir: str, override: Path | None) -> Path:
|
|
87
|
+
if override is not None:
|
|
88
|
+
return Path(override)
|
|
89
|
+
return Path(str(importlib.resources.files(_TEMPLATES_PACKAGE) / subdir))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _apply_template_files(
|
|
93
|
+
src_root: Path,
|
|
94
|
+
dest: Path,
|
|
95
|
+
substitutions: Mapping[str, str],
|
|
96
|
+
*,
|
|
97
|
+
path_rewrites: Mapping[str, str] | None = None,
|
|
98
|
+
preserve_existing: frozenset[str] = frozenset(),
|
|
99
|
+
) -> list[Path]:
|
|
100
|
+
"""Write template files into ``dest``; return paths skipped to preserve the user's copy."""
|
|
101
|
+
preserved: list[Path] = []
|
|
102
|
+
for src in _iter_template_files(src_root):
|
|
103
|
+
rel_str = str(src.relative_to(src_root))
|
|
104
|
+
for old, new in (path_rewrites or {}).items():
|
|
105
|
+
rel_str = rel_str.replace(old, new)
|
|
106
|
+
rel_str = rel_str.removesuffix(".tpl")
|
|
107
|
+
target = dest / rel_str
|
|
108
|
+
top = Path(rel_str).parts[0] if rel_str else ""
|
|
109
|
+
if top in preserve_existing and target.exists():
|
|
110
|
+
preserved.append(target)
|
|
111
|
+
continue
|
|
112
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
if src.suffix == ".tpl":
|
|
114
|
+
text = src.read_text(encoding="utf-8")
|
|
115
|
+
for placeholder, value in substitutions.items():
|
|
116
|
+
text = text.replace(placeholder, value)
|
|
117
|
+
target.write_text(text, encoding="utf-8")
|
|
118
|
+
else:
|
|
119
|
+
shutil.copy2(src, target)
|
|
120
|
+
return preserved
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def create_workspace(
|
|
124
|
+
dest: Path,
|
|
125
|
+
name: str,
|
|
126
|
+
template_root: Path | None = None,
|
|
127
|
+
*,
|
|
128
|
+
preserve_existing: frozenset[str] = frozenset(),
|
|
129
|
+
) -> list[Path]:
|
|
130
|
+
"""Materialize the workspace-root shell at ``dest``; return preserved paths.
|
|
131
|
+
|
|
132
|
+
Lays down the top-level ``pyproject.toml`` (uv workspace), ``package.json``
|
|
133
|
+
(npm workspace), ``Makefile`` (delegates to host), ``.env.example``,
|
|
134
|
+
``.gitignore``, and ``README.md``. Does NOT create the host or any
|
|
135
|
+
modules — those go under ``dest/host`` and ``dest/modules/`` afterwards.
|
|
136
|
+
|
|
137
|
+
``preserve_existing`` lists top-level entry names that may already exist
|
|
138
|
+
in ``dest``; the scaffold's copy is skipped and the preserved path is
|
|
139
|
+
included in the returned list. Other pre-existing entries raise
|
|
140
|
+
``FileExistsError``.
|
|
141
|
+
"""
|
|
142
|
+
dest = Path(dest)
|
|
143
|
+
_require_empty_dest(dest, preserve_existing=preserve_existing)
|
|
144
|
+
preserved = _apply_template_files(
|
|
145
|
+
_resolve_template_root("workspace", template_root),
|
|
146
|
+
dest,
|
|
147
|
+
{
|
|
148
|
+
"{{HOST_NAME}}": validate_scaffold_name(name),
|
|
149
|
+
"{{HOST_PYPI_NAME}}": to_kebab_case(name),
|
|
150
|
+
},
|
|
151
|
+
preserve_existing=preserve_existing,
|
|
152
|
+
)
|
|
153
|
+
logger.info("Scaffolded workspace root at %s", dest)
|
|
154
|
+
return preserved
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def create_host(
|
|
158
|
+
dest: Path,
|
|
159
|
+
name: str,
|
|
160
|
+
modules: Sequence[str],
|
|
161
|
+
template_root: Path | None = None,
|
|
162
|
+
framework_version: str = "*",
|
|
163
|
+
*,
|
|
164
|
+
preserve_existing: frozenset[str] = frozenset(),
|
|
165
|
+
) -> list[Path]:
|
|
166
|
+
"""Scaffold a host project at ``dest``; return preserved pre-existing paths.
|
|
167
|
+
|
|
168
|
+
``preserve_existing`` semantics match :func:`create_workspace`.
|
|
169
|
+
"""
|
|
170
|
+
dest = Path(dest)
|
|
171
|
+
_require_empty_dest(dest, preserve_existing=preserve_existing)
|
|
172
|
+
module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules)
|
|
173
|
+
preserved = _apply_template_files(
|
|
174
|
+
_resolve_template_root("host", template_root),
|
|
175
|
+
dest,
|
|
176
|
+
{
|
|
177
|
+
"{{HOST_NAME}}": validate_scaffold_name(name),
|
|
178
|
+
"{{HOST_PYPI_NAME}}": to_kebab_case(name),
|
|
179
|
+
"{{MODULE_DEPS}}": module_dep_lines,
|
|
180
|
+
"{{FRAMEWORK_VERSION}}": framework_version,
|
|
181
|
+
},
|
|
182
|
+
preserve_existing=preserve_existing,
|
|
183
|
+
)
|
|
184
|
+
logger.info(
|
|
185
|
+
"Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "<none>"
|
|
186
|
+
)
|
|
187
|
+
return preserved
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def create_module(
|
|
191
|
+
dest: Path,
|
|
192
|
+
name: str,
|
|
193
|
+
template_root: Path | None = None,
|
|
194
|
+
) -> Path:
|
|
195
|
+
dest = Path(dest)
|
|
196
|
+
existed_before = dest.exists()
|
|
197
|
+
_require_empty_dest(dest)
|
|
198
|
+
display_name = to_pascal_case(name)
|
|
199
|
+
slug = to_kebab_case(name)
|
|
200
|
+
package_name = to_snake_case(name)
|
|
201
|
+
try:
|
|
202
|
+
_apply_template_files(
|
|
203
|
+
_resolve_template_root("module", template_root),
|
|
204
|
+
dest,
|
|
205
|
+
substitutions={
|
|
206
|
+
"{{MODULE_NAME}}": display_name,
|
|
207
|
+
"{{MODULE_SLUG}}": slug,
|
|
208
|
+
"{{PACKAGE_NAME}}": package_name,
|
|
209
|
+
"{{PACKAGE_NAME_UPPER}}": package_name.upper(),
|
|
210
|
+
},
|
|
211
|
+
path_rewrites={_PACKAGE_PATH_TOKEN: package_name},
|
|
212
|
+
)
|
|
213
|
+
except Exception:
|
|
214
|
+
# Rollback so a half-scaffolded directory doesn't leave the user
|
|
215
|
+
# with an unparseable Python package and the impression that a
|
|
216
|
+
# retry won't work because ``dest`` is now non-empty. We only
|
|
217
|
+
# nuke the directory we created — never one we found pre-existing.
|
|
218
|
+
if not existed_before and dest.is_dir():
|
|
219
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
220
|
+
raise
|
|
221
|
+
logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name)
|
|
222
|
+
return dest
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Landing page for the public root (`/`).
|
|
3
|
+
*
|
|
4
|
+
* Scaffolded by `smpy new` so a fresh app boots straight to a friendly
|
|
5
|
+
* page instead of a 404. Replace or restyle as your app's front door
|
|
6
|
+
* takes shape — the only contract is that something renders at "/".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
type LandingProps = {
|
|
10
|
+
isAuthenticated: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default function Landing({ isAuthenticated }: LandingProps) {
|
|
14
|
+
const primaryHref = isAuthenticated ? '/dashboard' : '/users/login';
|
|
15
|
+
const primaryLabel = isAuthenticated ? 'Open dashboard' : 'Sign in';
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-8 px-6 py-16">
|
|
19
|
+
<header className="space-y-3">
|
|
20
|
+
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
21
|
+
simple_module_py
|
|
22
|
+
</p>
|
|
23
|
+
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
|
24
|
+
Your app is up.
|
|
25
|
+
</h1>
|
|
26
|
+
<p className="text-base text-muted-foreground sm:text-lg">
|
|
27
|
+
This is the host's landing page — generated by{' '}
|
|
28
|
+
<code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-sm">smpy new</code>.
|
|
29
|
+
Edit{' '}
|
|
30
|
+
<code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-sm">
|
|
31
|
+
client_app/pages/Landing.tsx
|
|
32
|
+
</code>{' '}
|
|
33
|
+
to make it yours.
|
|
34
|
+
</p>
|
|
35
|
+
</header>
|
|
36
|
+
|
|
37
|
+
<div className="flex flex-wrap gap-3">
|
|
38
|
+
<a
|
|
39
|
+
href={primaryHref}
|
|
40
|
+
className="inline-flex items-center justify-center rounded-md bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
41
|
+
>
|
|
42
|
+
{primaryLabel}
|
|
43
|
+
</a>
|
|
44
|
+
<a
|
|
45
|
+
href="https://github.com/antosubash/simple_module_python"
|
|
46
|
+
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"
|
|
47
|
+
>
|
|
48
|
+
Read the docs
|
|
49
|
+
</a>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<section className="rounded-xl border border-border bg-card p-5">
|
|
53
|
+
<h2 className="mb-3 text-sm font-semibold">Next steps</h2>
|
|
54
|
+
<ul className="space-y-2 text-sm text-muted-foreground">
|
|
55
|
+
<li>
|
|
56
|
+
Create an admin:{' '}
|
|
57
|
+
<code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground">
|
|
58
|
+
uv run smpy users create-admin
|
|
59
|
+
</code>
|
|
60
|
+
</li>
|
|
61
|
+
<li>
|
|
62
|
+
Scaffold a module:{' '}
|
|
63
|
+
<code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground">
|
|
64
|
+
smpy create-module orders --dest modules/orders
|
|
65
|
+
</code>
|
|
66
|
+
</li>
|
|
67
|
+
<li>
|
|
68
|
+
Sign in at{' '}
|
|
69
|
+
<a href="/users/login" className="font-mono text-[13px] text-primary hover:underline">
|
|
70
|
+
/users/login
|
|
71
|
+
</a>{' '}
|
|
72
|
+
and explore the dashboard.
|
|
73
|
+
</li>
|
|
74
|
+
</ul>
|
|
75
|
+
</section>
|
|
76
|
+
</main>
|
|
77
|
+
);
|
|
78
|
+
}
|